Compare commits

...

90 Commits

Author SHA1 Message Date
Kelvin 5548783337 Merge branch 'revert-d902306f' into 'master'
Revert "Revert old ffmpeg"

See merge request videostreaming/grayjay!157
2025-11-26 20:06:29 +00:00
Kelvin 0dca8798cb Revert "Revert old ffmpeg"
This reverts commit d902306fe4
2025-11-26 20:06:16 +00:00
Kelvin K d902306fe4 Revert old ffmpeg 2025-11-26 13:35:09 -06:00
Kelvin K baa2a4fcf3 Deps 2025-11-26 12:05:24 -06:00
Kelvin K 8be7ad9f68 Alignment for more menu 2025-11-26 10:12:52 -06:00
Kelvin K 992cbcb3a0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-26 09:38:03 -06:00
Kelvin K e0857aea9b Window pan for keyboard 2025-11-26 09:37:35 -06:00
Koen J 50cd0723c9 JNI fixes. 2025-11-26 13:45:01 +01:00
Koen J 4c4b322682 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-26 11:50:33 +01:00
Koen J 7cff8568c0 Reverted to use FFMPEG for combining HLS segments. 2025-11-26 11:33:46 +01:00
Kelvin K 801c646a09 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-25 11:30:02 -06:00
Kelvin K df4ec87613 Possible crash fix for attempted access to fragment before available, fix loader showing on no results 2025-11-25 11:29:47 -06:00
Koen J b08a79b7cb Fixed plugin config missing from httpclient. 2025-11-25 14:13:06 +01:00
Koen J 396e9f9f43 Implemented support for isUrlAllowed in HttpImp. 2025-11-25 12:44:57 +01:00
Koen J 0e5a87a911 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-25 12:33:16 +01:00
Koen J 64d72f6d10 Implemented cookie support for httpimp. 2025-11-25 12:29:18 +01:00
Kelvin K 5b40da109b Artist thumbnail now fall back to album thumbnail 2025-11-25 00:40:38 +01:00
Kelvin K 949294952f Show alpha warning only once 2025-11-25 00:09:17 +01:00
Kelvin K 40a058e369 Fix empty library clear, comment ui change 2025-11-24 23:36:12 +01:00
Kelvin K a070d78dd9 Crash fix on polycentric failure 2025-11-24 22:19:41 +01:00
Kelvin K 105ac538bb Diff android version check 2025-11-24 21:59:19 +01:00
Kelvin K ce2029774e Deal with older Android versions 2025-11-24 21:57:03 +01:00
Kelvin 50c63d7e8d Merge branch 'new-menu' into 'master'
New Grid-style menu

See merge request videostreaming/grayjay!156
2025-11-24 18:00:01 +00:00
Kelvin K d3534080d7 abstract out a request proccessor 2025-11-24 17:09:53 +01:00
Koen J b5025193a5 Added www.odysee.com in AndroidManifest. 2025-11-24 11:14:43 +01:00
Koen J 3f85b7ed78 Further work on http imp. 2025-11-24 10:33:18 +01:00
Kelvin K 98d008ef6c new menu polished, toggles, etc 2025-11-22 01:02:20 +01:00
Kelvin K 20eb53fc38 New menu system 2025-11-21 19:27:28 +01:00
Koen J 1ea7b307fa Removed spotify from plugin config. 2025-11-21 14:20:40 +01:00
Koen J f18571e0b2 Removed spotify from Android. 2025-11-21 14:19:32 +01:00
Koen J 70872d429a Fixed loop video in the case where you switched to cast. 2025-11-21 10:42:26 +01:00
Koen J cbf3db6e30 Fixed loop and autoplay while casting. 2025-11-21 10:05:33 +01:00
Koen J 0be0dcfadc More fixes to CastView sizing. 2025-11-20 19:34:29 +01:00
Koen J abd226c33d Implemented fix for castview becoming too tall. 2025-11-20 19:19:51 +01:00
Koen J 89dbdc99a0 Fixed issue where audio mode toggle wouldn't work properly when coming back into app while in a playlist. 2025-11-20 15:24:41 +01:00
Koen J f89ed18a49 CChanged copy comment to button. 2025-11-20 14:29:36 +01:00
Koen J 77ac2b537c Fixed get last queue. 2025-11-20 13:50:25 +01:00
Koen J 8ab03b6b66 Adjusted center area. 2025-11-20 13:41:33 +01:00
Koen J dad70e57c6 Implemented center double tap to play/pause. 2025-11-20 12:25:52 +01:00
Koen J eb9c6c8330 Implemented support for 3x default playback speed. 2025-11-20 12:07:07 +01:00
Koen J 68da797f4d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-19 14:48:44 +01:00
Koen J 25948dd296 More robust HLS downloading. 2025-11-19 14:48:29 +01:00
Koen 10d39d6ed1 Merge branch 'aw/polycentric-profiles' into 'master'
Fix QR code generation for large polycentric export bundles

See merge request videostreaming/grayjay!155
2025-11-19 12:01:34 +00:00
Koen 85e8e674dd Edit Settings.kt 2025-11-19 08:45:22 +00:00
Kelvin 0d70392bf0 Minor fixes 2025-11-18 23:55:29 +01:00
Kelvin f89b074d28 Various improvements to library and other fixes 2025-11-18 23:35:34 +01:00
Kelvin ee2af411aa Request modifier download fixes. 2025-11-18 15:23:35 +01:00
Kelvin 9ffbe6dd03 Merge branch 'download-request-modifier' into 'master'
download request modifier

See merge request videostreaming/grayjay!141
2025-11-18 13:15:32 +00:00
Kelvin 0ae6ac2fac Always update dialog option, login as fragment fixes, ongoing cursor crash fix 2025-11-18 01:03:21 +01:00
Kelvin fd835cc54e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-17 21:28:21 +01:00
Kelvin 07f3140038 Add timeout for plugin updates and better texts, bottom menu lighter disable shade, library cursor cleanup 2025-11-17 21:28:07 +01:00
Koen J 3e753d70de Last queue playlist fix. 2025-11-17 16:47:11 +01:00
Koen J d578c47975 Properly propagated request modifier in casting. 2025-11-17 13:47:16 +01:00
Koen J b7a61425ca Implemented history position playlist id tracking. 2025-11-15 15:22:52 +01:00
Koen J 727f977672 Implemented last queue saving. 2025-11-15 12:41:22 +01:00
Koen J fc9d5eeb27 Fixing export download video progress viewing. 2025-11-15 12:01:52 +01:00
Kelvin f17e147b4e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-15 03:42:17 +01:00
Kelvin 1c569b465b Possible crash fix 2025-11-15 03:42:04 +01:00
Koen J 6289c85bd5 Devportal fix with settings. 2025-11-14 13:20:21 +01:00
Koen J 098599853b Fixed issue where a pending video would not be added to queue when using add to queue feature. 2025-11-14 12:26:38 +01:00
Koen J 68d11f6d58 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-14 09:39:55 +01:00
Koen J 74f6b9aa62 Language should be optional. 2025-11-14 09:39:19 +01:00
austin fc2aba0120 import identity from file 2025-11-13 18:40:30 -06:00
austin e4f51bb130 export profile to file 2025-11-13 18:22:58 -06:00
austin 9e9d26c752 Full screen QR code viewer 2025-11-13 18:22:30 -06:00
austin 5c5dd3af44 Merge branch 'master' into aw/polycentric-profiles 2025-11-13 17:40:53 -06:00
Kelvin 4433364cd8 Fix build error 2025-11-13 23:46:57 +01:00
Kelvin 2c957d7188 Submods 2025-11-13 15:01:15 +01:00
Kelvin f229f4ed1f Merge branch 'wip-library' into 'master'
Library Support (On-device music & videos)

See merge request videostreaming/grayjay!154
2025-11-13 13:53:14 +00:00
Kelvin e8d1f73e29 Bottom bar highlighting change 2025-11-13 14:52:16 +01:00
Koen J dd2cf18cb2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-13 14:49:50 +01:00
Koen J 5355602577 Implemented httpimp. 2025-11-13 14:49:05 +01:00
Kelvin 8cc82e4d16 Possible fix for dropping live playback 2025-11-13 04:05:08 +01:00
austin d6468ba283 Merge branch 'master' into aw/polycentric-profiles 2025-11-12 19:23:30 -06:00
Kelvin 4b5ed38175 Reset settings and share settings buttons 2025-11-13 01:23:43 +01:00
Kelvin 75eb7359de Fix various ref to old activity settings 2025-11-12 23:55:44 +01:00
Kelvin fd519d48cf Settings as fragments instead 2025-11-12 23:01:41 +01:00
Koen 6f1866ac27 Merge branch 'marcus/casting-stop-playback-before-disconnect' into 'master'
Casting: stop video playback before disconnecting from the active device

See merge request videostreaming/grayjay!152
2025-11-12 12:41:10 +00:00
Kelvin 0dc0f07785 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-library 2025-11-11 23:16:36 +01:00
Kelvin bae8cb7bc4 Library search support 2025-11-11 23:16:16 +01:00
Kelvin d5a696289b Even more library work 2025-11-11 01:13:35 +01:00
Kelvin 75ef7085eb Library UI, artists listing, new album layout, etc 2025-11-09 23:43:13 +01:00
Kelvin 347ef855b3 Library continuation, disable auto backup ask, minor tweaks. 2025-11-08 19:02:38 +01:00
Kelvin 9b97e05e3b File browser support 2025-10-30 21:19:47 +01:00
austin 62a2f42d68 Fix QR code generation for large polycentric export bundles
- Add GZIP compression for large export data (>2000 chars)
- Implement fallback QR generation with different error correction levels
- Add automatic decompression support in import functionality
- Improve error handling with fallback to text display
- Add localized error messages for QR code failures
- Add compression ratio logging for debugging

This fixes the 'Data too big' error when generating QR codes for
polycentric profile exports by automatically compressing large data
and providing multiple fallback mechanisms.
2025-10-28 18:26:36 -05:00
Kelvin da44e86163 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-library 2025-10-23 01:59:30 +02:00
Kelvin 682b86330e Library work 2025-10-23 01:59:03 +02:00
Marcus Hanestad c9ba8a09e2 casting: stop video playback before disconnecting from device 2025-10-22 13:26:08 +02:00
Kelvin 87d93c2ed8 WIP library support, albums, artists, videos 2025-10-15 01:03:47 +02:00
Kai 7c70e58129 use request modifier when downloading url sources
Changelog: changed
2025-08-18 10:40:47 -04:00
196 changed files with 10143 additions and 866 deletions
+4
View File
@@ -1,2 +1,6 @@
aar/* filter=lfs diff=lfs merge=lfs -text
app/aar/* filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text
-6
View File
@@ -64,12 +64,6 @@
[submodule "app/src/stable/assets/sources/bilibili"]
path = app/src/stable/assets/sources/bilibili
url = ../plugins/bilibili.git
[submodule "app/src/stable/assets/sources/spotify"]
path = app/src/stable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/unstable/assets/sources/spotify"]
path = app/src/unstable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/stable/assets/sources/bitchute"]
path = app/src/stable/assets/sources/bitchute
url = ../plugins/bitchute.git
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
size 36133152
+1
View File
@@ -146,6 +146,7 @@ android {
}
sourceSets {
main {
jniLibs.srcDirs = ['src/main/jniLibs']
assets {
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
}
+8
View File
@@ -16,6 +16,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -58,6 +61,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
android:launchMode="singleInstance"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
@@ -242,5 +246,9 @@
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>
+15 -12
View File
@@ -1025,18 +1025,21 @@
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;
const settings = this.Plugin?.currentPlugin?.settings;
if (settings) {
for (let setting of 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;
}
}
}
}
@@ -0,0 +1,43 @@
package com.futo.platformplayer
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
object AppCaUpdater {
private const val CA_URL = "https://curl.se/ca/cacert.pem"
private const val CACHE_FILENAME = "curl-ca-bundle.pem"
private const val MAX_AGE_DAYS = 30
suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) {
val file = File(context.noBackupFilesDir, CACHE_FILENAME)
val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS)
if (needsUpdate) {
downloadToFile(CA_URL, file)
}
return@withContext file
}
private fun isOlderThanDays(file: File, days: Int): Boolean {
val ageMs = System.currentTimeMillis() - file.lastModified()
return ageMs > days * 24L * 60L * 60L * 1000L
}
private fun downloadToFile(urlStr: String, dest: File) {
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 15000
instanceFollowRedirects = true
}
conn.inputStream.use { input ->
dest.parentFile?.mkdirs()
dest.outputStream().use { output ->
input.copyTo(output)
}
}
conn.disconnect()
}
}
@@ -8,6 +8,7 @@ import com.caoccao.javet.values.reference.V8ValueError
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
@@ -387,4 +388,15 @@ suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
}
throw ex;
}
}
fun <T> IPager<T>.toList(): List<T> {
val list = this.getResults().toMutableList();
while(this.hasMorePages()) {
this.nextPage();
list.addAll(this.getResults());
}
return list.toList();
}
@@ -10,11 +10,11 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.activities.PolycentricProfileActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SyncHomeActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.OffsetDateTime
@@ -64,7 +63,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
@FormFieldButton(R.drawable.ic_update)
fun syncGrayjay() {
SettingsActivity.getActivity()?.let {
StateApp?.instance?.activity?.let {
it.startActivity(Intent(it, SyncHomeActivity::class.java))
}
}
@@ -73,7 +72,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
StateApp?.instance?.activity?.let {
if (StatePolycentric.instance.enabled) {
if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
@@ -91,7 +90,7 @@ class Settings : FragmentedStorageFileJson() {
fun openFAQ() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
SettingsActivity.getActivity()?.startActivity(browserIntent);
StateApp?.instance?.activity?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
@@ -101,7 +100,7 @@ class Settings : FragmentedStorageFileJson() {
fun openIssues() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
SettingsActivity.getActivity()?.startActivity(browserIntent);
StateApp?.instance?.activity?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
@@ -132,7 +131,7 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
SettingsActivity.getActivity()?.let {
StateApp?.instance?.activity?.let {
it.startActivity(Intent(it, ManageTabsActivity::class.java));
}
} catch (e: Throwable) {
@@ -145,7 +144,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
@FormFieldButton(R.drawable.ic_move_up)
fun import() {
val act = SettingsActivity.getActivity() ?: return;
val act = StateApp.instance.activity ?: return;
val intent = MainActivity.getImportOptionsIntent(act);
act.startActivity(intent);
}
@@ -154,7 +153,7 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldButton(R.drawable.ic_link)
fun manageLinks() {
try {
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show url handling prompt", e)
}
@@ -163,7 +162,7 @@ class Settings : FragmentedStorageFileJson() {
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
@FormFieldButton(R.drawable.battery_full_24px)
fun ignoreBatteryOptimization() {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
val intent = Intent()
val packageName = it.packageName
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
@@ -244,7 +243,7 @@ class Settings : FragmentedStorageFileJson() {
fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
@@ -374,9 +373,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
StateCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
}
}
@@ -430,6 +429,9 @@ class Settings : FragmentedStorageFileJson() {
6 -> 1.75f;
7 -> 2.0f;
8 -> 2.25f;
9 -> 2.5f;
10 -> 2.75f;
11 -> 3.0f;
else -> 1.0f;
};
@@ -726,7 +728,7 @@ class Settings : FragmentedStorageFileJson() {
@AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = false
var experimentalCasting: Boolean = true
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -760,7 +762,7 @@ class Settings : FragmentedStorageFileJson() {
try {
if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) {
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
}
}
} catch (e: Throwable) {
@@ -777,7 +779,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
}
}
@@ -845,13 +847,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
@@ -860,7 +862,7 @@ class Settings : FragmentedStorageFileJson() {
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
}
@@ -897,13 +899,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true)
}
}
} else {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) {
@@ -915,7 +917,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
fun viewChangelog() {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
@@ -955,7 +957,7 @@ class Settings : FragmentedStorageFileJson() {
class Backup {
@Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = false;
var didAskAutoBackup: Boolean = true;
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null;
@@ -964,13 +966,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
SettingsFragment.currentView?.reloadSettings();
};
}
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
val activity = SettingsActivity.getActivity()!!
val activity = StateApp.instance.activity!!
if(!StateBackup.hasAutomaticBackup())
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
@@ -981,8 +983,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
fun export() {
val activity = SettingsActivity.getActivity() ?: return;
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
val activity = StateApp.instance.activity ?: return;
val fragView = SettingsFragment.currentView ?: return;
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
StateBackup.shareExternalBackup();
}),
@@ -998,11 +1001,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class Payment {
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
val paymentStatus: String get() = StateApp.instance.activity?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
fun viewLicenseStatus() {
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
try {
if (StatePayment.instance.hasPaid) {
val paymentKey = StatePayment.instance.getPaymentKey()
@@ -1018,12 +1021,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
fun clearPayment() {
SettingsActivity.getActivity()?.let { context ->
StateApp.instance.activity?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
StateApp.instance.activity?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
SettingsFragment.currentView?.reloadSettings();
}
})
}
@@ -1120,7 +1123,7 @@ class Settings : FragmentedStorageFileJson() {
@AdvancedField
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
fun configureSyncServer() {
SettingsActivity.getActivity()?.let { context ->
StateApp.instance.activity?.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.",
@@ -1131,13 +1134,13 @@ class Settings : FragmentedStorageFileJson() {
UIDialogs.Action("Reset", {
syncServerUrl = null;
instance.save();
context.reloadSettings();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action.withInput("Configure", {
syncServerUrl = it?.text
instance.save();
context.reloadSettings();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.PRIMARY),
)
@@ -8,9 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.DeveloperActivity
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -20,6 +18,8 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.states.StateAnnouncement
@@ -97,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
fun subscriptionsCache5000() {
Logger.i("SettingsDev", "Started caching 5000 sub items");
UIDialogs.toast(
SettingsActivity.getActivity()!!,
StateApp.instance.activity!!,
"Started caching 5000 sub items"
);
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
val button = DeveloperFragment.currentView?.getField("subscription_cache_button");
if(button is ButtonField)
button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) {
@@ -121,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis();
UIDialogs.toast(
SettingsActivity.getActivity()!!,
StateApp.instance.activity!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
);
}
@@ -130,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) {
UIDialogs.toast(
SettingsActivity.getActivity()!!,
StateApp.instance.activity!!,
"FINISHED Page: ${page}, Total: ${total}"
);
}
@@ -152,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
fun historyCache100() {
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
UIDialogs.toast(
SettingsActivity.getActivity()!!,
StateApp.instance.activity!!,
"Started caching 100 history items (from home)"
);
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
val button = DeveloperFragment.currentView?.getField("history_cache_button");
if(button is ButtonField)
button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) {
@@ -186,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis();
UIDialogs.toast(
SettingsActivity.getActivity()!!,
StateApp.instance.activity!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
);
}
@@ -195,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) {
UIDialogs.toast(
SettingsActivity.getActivity()!!,
StateApp.instance.activity!!,
"FINISHED Page: ${page}, Total: ${total}"
);
}
@@ -235,9 +235,9 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 4)
fun triggerBackgroundUpdate() {
val act = SettingsActivity.getActivity()!!;
val act = StateApp.instance.activity!!;
try {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
val wm = WorkManager.getInstance(act);
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
@@ -251,9 +251,9 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 4)
fun clearChannelContentCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
StateCache.instance.clearToday();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
}
@@ -14,7 +14,6 @@ import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -74,6 +73,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
class UISlideOverlays {
companion object {
@@ -331,15 +331,9 @@ class UISlideOverlays {
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);
StateApp.instance.activity?.let {
it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
}
}, UIDialogs.ActionStyle.PRIMARY)
);
}
@@ -101,7 +101,7 @@ fun String.isHexColor(): Boolean {
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
@@ -1,58 +0,0 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperActivity : AppCompatActivity() {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
fun getField(id: String): IField? {
return _form.findField(id);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
DeveloperActivity._lastActivity = this;
setContentView(R.layout.activity_dev);
setNavigationBarColorAndIcons();
_buttonBack = findViewById(R.id.button_back);
_form = findViewById(R.id.settings_form);
_form.fromObject(SettingsDev.instance);
_form.onChanged.subscribe { _, _ ->
_form.setObjectValues();
SettingsDev.instance.save();
};
_buttonBack.setOnClickListener {
finish();
}
}
override fun finish() {
super.finish()
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: DeveloperActivity? = null;
fun getActivity(): DeveloperActivity? {
val act = _lastActivity;
if(act != null)
return act;
return null;
}
}
}
@@ -52,17 +52,28 @@ import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
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.SettingsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
@@ -76,6 +87,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.St
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
@@ -147,6 +159,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
lateinit var _fragTopBarImport: ImportTopBarFragment;
lateinit var _fragTopBarAdd: AddTopBarFragment;
lateinit var _fragTopBarFiles: FilesTopBarFragment;
//Frags BotBar
lateinit var _fragBotBarMenu: MenuBottomBarFragment;
@@ -179,6 +192,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragBuy: BuyFragment;
lateinit var _fragSubGroup: SubscriptionGroupFragment;
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
lateinit var _fragLibrary: LibraryFragment;
lateinit var _fragLibraryAlbums: LibraryAlbumsFragment;
lateinit var _fragLibraryAlbum: LibraryAlbumFragment;
lateinit var _fragLibraryArtists: LibraryArtistsFragment;
lateinit var _fragLibraryArtist: LibraryArtistFragment;
lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment;
lateinit var _fragBrowser: BrowserFragment;
@@ -187,7 +211,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//State
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent: MainFragment private set;
var fragCurrent: MainFragment? = null; private set;
private var _parameterCurrent: Any? = null;
var fragBeforeOverlay: MainFragment? = null; private set;
@@ -220,6 +244,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
}
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
};
fun requestNotificationPermissions() {
_notificationPermissionLauncher?.launch(_notifPermission);
}
val mainId = UUID.randomUUID().toString().substring(0, 5)
@@ -275,6 +312,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
Logger.w(TAG, "MainActivity Starting [$mainId]");
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
StateApp.instance.mainAppStarting(this);
@@ -318,6 +356,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
_fragTopBarImport = ImportTopBarFragment.newInstance();
_fragTopBarAdd = AddTopBarFragment.newInstance();
_fragTopBarFiles = FilesTopBarFragment.newInstance();
//BotBars
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
@@ -350,6 +389,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragBuy = BuyFragment.newInstance();
_fragSubGroup = SubscriptionGroupFragment.newInstance();
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
_fragLibrary = LibraryFragment.newInstance();
_fragLibraryAlbums = LibraryAlbumsFragment.newInstance();
_fragLibraryAlbum = LibraryAlbumFragment.newInstance();
_fragLibraryArtists = LibraryArtistsFragment.newInstance();
_fragLibraryArtist = LibraryArtistFragment.newInstance();
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance();
@@ -481,6 +531,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragImportSubscriptions.topBar = _fragTopBarImport;
_fragImportPlaylists.topBar = _fragTopBarImport;
_fragSubGroupList.topBar = _fragTopBarAdd;
_fragLibrary.topBar = _fragTopBarGeneral;
_fragLibraryAlbums.topBar = _fragTopBarNavigation;
_fragLibraryAlbum.topBar = _fragTopBarNavigation;
_fragLibraryArtists.topBar = _fragTopBarNavigation;
_fragLibraryArtist.topBar = _fragTopBarNavigation;
_fragLibraryVideos.topBar = _fragTopBarNavigation;
_fragLibraryFiles.topBar = _fragTopBarFiles;
_fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation;
_fragBrowser.topBar = _fragTopBarNavigation;
@@ -506,7 +566,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
defaultTab.action(_fragBotBarMenu);
StateSubscriptions.instance;
fragCurrent.onShown(null, false);
fragCurrent?.onShown(null, false);
//Other stuff
rootView.progress = 0f;
@@ -1093,7 +1153,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
return;
if (!fragCurrent.onBackPressed())
if (!(fragCurrent?.onBackPressed() ?: true))
closeSegment();
}
@@ -1144,6 +1204,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
val segment = getFragment<T>();
navigate(segment as MainFragment, parameter, withHistory, isBack);
}
/**
* Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment
@@ -1166,27 +1231,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return;
}
fragCurrent.onHide();
fragCurrent?.onHide();
if (segment.isMainView) {
var transaction = supportFragmentManager.beginTransaction();
if (segment.topBar != null) {
if (segment.topBar != fragCurrent.topBar) {
if (segment.topBar != fragCurrent?.topBar) {
transaction = transaction
.show(segment.topBar as Fragment)
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
fragCurrent.topBar?.onHide();
fragCurrent?.topBar?.onHide();
}
} else if (fragCurrent.topBar != null)
transaction.hide(fragCurrent.topBar as Fragment);
} else if (fragCurrent?.topBar != null)
transaction.hide(fragCurrent?.topBar as Fragment);
transaction = transaction.replace(R.id.fragment_main, segment);
if (segment.hasBottomBar) {
if (!fragCurrent.hasBottomBar)
if (!(fragCurrent?.hasBottomBar ?: false))
transaction = transaction.show(_fragBotBarMenu);
} else {
if (fragCurrent.hasBottomBar)
if (fragCurrent?.hasBottomBar ?: false)
transaction = transaction.hide(_fragBotBarMenu);
}
transaction.commitNow();
@@ -1199,10 +1264,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent, _parameterCurrent));
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent;
fragCurrent = segment;
@@ -1256,6 +1321,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
VideoDetailFragment::class -> _fragVideoDetail as T;
MenuBottomBarFragment::class -> _fragBotBarMenu as T;
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
FilesTopBarFragment::class -> _fragTopBarFiles as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T;
CommentsFragment::class -> _fragMainComments as T;
@@ -1280,6 +1346,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
BuyFragment::class -> _fragBuy as T;
SubscriptionGroupFragment::class -> _fragSubGroup as T;
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
LibraryFragment::class -> _fragLibrary as T;
LibraryAlbumsFragment::class -> _fragLibraryAlbums as T;
LibraryAlbumFragment::class -> _fragLibraryAlbum as T;
LibraryArtistsFragment::class -> _fragLibraryArtists as T;
LibraryArtistFragment::class -> _fragLibraryArtist as T;
LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T;
SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
@@ -1287,7 +1364,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun updateSegmentPaddings() {
var paddingBottom = 0f;
if (fragCurrent.hasBottomBar)
if (fragCurrent?.hasBottomBar ?: false)
paddingBottom += HEIGHT_MENU_DP;
_fragContainerOverlay.setPadding(
@@ -1304,6 +1381,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
);
}
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionAudio?.invoke(isGranted);
});
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionVideo?.invoke(isGranted);
});
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionAudio = cb;
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
}
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionVideo = cb;
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
}
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
@@ -13,15 +13,18 @@ import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateApp.Companion.withContext
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _buttonShare: BigButton;
private lateinit var _buttonCopy: BigButton;
private lateinit var _buttonExportFile: BigButton;
private lateinit var _imageQR: ImageView;
private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView;
private lateinit var _textQRHint: TextView;
private lateinit var _loader: View
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
uri?.let { fileUri ->
try {
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
outputStream.write(_exportBundle.toByteArray())
}
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
} catch (e: Exception) {
Logger.e(TAG, "Failed to write to document", e)
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
_buttonShare = findViewById(R.id.button_share)
_buttonCopy = findViewById(R.id.button_copy)
_buttonExportFile = findViewById(R.id.button_export_file)
_imageQR = findViewById(R.id.image_qr)
_textQR = findViewById(R.id.text_qr)
_textQRHint = findViewById(R.id.text_qr_hint)
_loader = findViewById(R.id.progress_loader)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_textQRHint.visibility = View.INVISIBLE
_loader.visibility = View.VISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
_buttonExportFile.visibility = View.INVISIBLE
lifecycleScope.launch {
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
_exportBundle = bundle
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
try {
val pair = withContext(Dispatchers.IO) {
val bundle = createExportBundle()
if (!isContentSuitableForQRCode(bundle)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt()
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
Pair(bundle, qr)
}
_exportBundle = pair.first
_imageQR.setImageBitmap(pair.second)
_imageQR.visibility = View.VISIBLE
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.VISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
_imageQR.setOnClickListener {
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
startActivity(intent)
}
} catch (e: Exception) {
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
if (e.message?.contains("Data too big") == true) {
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
_buttonExportFile.visibility = View.VISIBLE
} else {
_textQR.text = getString(R.string.failed_to_generate_qr_code)
}
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.INVISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
// Hide QR image since generation failed
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
} finally {
_loader.visibility = View.GONE
}
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip);
};
_buttonExportFile.onClick.subscribe {
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
_createDocumentLauncher.launch(fileName)
};
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
return bitMatrixToBitmap(bitMatrix);
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
.setBody(exportBundle.toByteString())
.build();
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
val data = urlInfo.toByteArray()
return "polycentric://" + data.toBase64Url()
}
companion object {
@@ -32,100 +32,166 @@ import userpackage.Protocol
import userpackage.Protocol.ExportBundle
class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private lateinit var _buttonHelp: ImageButton
private lateinit var _buttonScanProfile: LinearLayout
private lateinit var _buttonImportFile: LinearLayout
private lateinit var _buttonImportProfile: LinearLayout
private lateinit var _editProfile: EditText
private lateinit var _loaderOverlay: LoaderOverlay
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
private val _qrCodeResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult =
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
private val _filePickerLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { fileUri ->
try {
// Check file size before reading
val fileSize =
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
if (fileSize > maxFileSize) {
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
return@let
}
if (fileSize == 0L) {
UIDialogs.toast(this, "Selected file is empty.")
return@let
}
val content =
contentResolver
.openInputStream(fileUri)
?.bufferedReader()
?.readText()
content?.let { fileContent ->
val trimmedContent = fileContent.trim()
// Check if content is empty after trimming
if (trimmedContent.isEmpty()) {
UIDialogs.toast(this, "Selected file contains no data.")
return@let
}
// Check if content looks like a valid polycentric URL
if (!trimmedContent.startsWith("polycentric://")) {
UIDialogs.toast(
this,
"Selected file does not contain a valid polycentric profile URL."
)
return@let
}
import(trimmedContent)
}
?: run { UIDialogs.toast(this, "Could not read file content.") }
} catch (e: SecurityException) {
Logger.e(TAG, "Security exception reading file", e)
UIDialogs.toast(this, "Permission denied to read file.")
} catch (e: OutOfMemoryError) {
Logger.e(TAG, "Out of memory reading file", e)
UIDialogs.toast(this, "File too large to process.")
} catch (e: Exception) {
Logger.e(TAG, "Failed to read file", e)
UIDialogs.toast(this, "Failed to read file: ${e.message}")
}
}
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
setNavigationBarColorAndIcons();
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_import_profile)
setNavigationBarColorAndIcons()
_buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
};
_buttonHelp = findViewById(R.id.button_help)
_buttonScanProfile = findViewById(R.id.button_scan_profile)
_buttonImportFile = findViewById(R.id.button_import_file)
_buttonImportProfile = findViewById(R.id.button_import_profile)
_loaderOverlay = findViewById(R.id.loader_overlay)
_editProfile = findViewById(R.id.edit_profile)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
_buttonHelp.setOnClickListener {
startActivity(Intent(this, PolycentricWhyActivity::class.java));
};
startActivity(Intent(this, PolycentricWhyActivity::class.java))
}
_buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setOrientationLocked(true)
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
integrator.setCaptureActivity(QRCaptureActivity::class.java)
_qrCodeResultLauncher.launch(integrator.createScanIntent())
};
}
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
_buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
return@setOnClickListener;
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
return@setOnClickListener
}
import(_editProfile.text.toString());
};
import(_editProfile.text.toString())
}
val url = intent.getStringExtra("url");
val url = intent.getStringExtra("url")
if (url != null) {
import(url);
import(url)
}
}
private fun import(url: String) {
if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return;
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
return
}
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
val data = url.substring("polycentric://".length).base64UrlToByteArray()
val urlInfo = Protocol.URLInfo.parseFrom(data)
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
if (existingProcessSecret != null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.this_profile_is_already_imported)
)
}
return@launch;
return@launch
}
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
val processSecret = ProcessSecret(keyPair, Process.random())
Store.instance.addProcessSecret(processSecret)
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
val processHandle = processSecret.toProcessHandle();
val processHandle = processSecret.toProcessHandle()
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
val se = SignedEvent.fromProto(e)
Store.instance.putSignedEvent(se)
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
Logger.w(TAG, "Ignored invalid event", e)
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(ApiMethods.SERVER);
StatePolycentric.instance.setProcessHandle(processHandle)
processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
startActivity(
Intent(
this@PolycentricImportProfileActivity,
PolycentricProfileActivity::class.java
)
)
finish()
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
Logger.w(TAG, "Failed to import profile", e)
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.failed_to_import_profile) + " '${e.message}'"
)
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
}
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
}
}
}
companion object {
private const val TAG = "PolycentricImportProfileActivity";
private const val TAG = "PolycentricImportProfileActivity"
}
}
}
@@ -0,0 +1,109 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
class QRCodeFullscreenActivity : AppCompatActivity() {
companion object {
private const val EXTRA_QR_TEXT = "qr_text"
fun createIntent(context: Context, qrText: String): android.content.Intent {
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
putExtra(EXTRA_QR_TEXT, qrText)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_qr_code_fullscreen)
setNavigationBarColorAndIcons()
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
// Generate QR code bitmap from text
qrText?.let { text ->
try {
if (!isContentSuitableForQRCode(text)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
).toInt()
val qrBitmap = generateQRCode(text, dimension, dimension)
imageQR.setImageBitmap(qrBitmap)
} catch (e: Exception) {
// If QR generation fails, show error or fallback
imageQR.setImageResource(R.drawable.ic_qr)
}
}
buttonBack.setOnClickListener {
finish()
}
buttonClose.setOnClickListener {
finish()
}
imageQR.setOnClickListener {
finish()
}
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bmp
}
}
@@ -1,208 +0,0 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
setNavigationBarColorAndIcons();
_form = findViewById(R.id.settings_form);
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loaderView = findViewById(R.id.loader);
overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, _ ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues();
Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update") {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(this, "Notifications aren't enabled");
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
"Notifications need to be enabled for background updating to function", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
}
}
};
_buttonBack.setOnClickListener {
finish();
}
_buttonDev.setOnClickListener {
startActivity(Intent(this, DeveloperActivity::class.java));
}
_lastActivity = this;
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}
};
}
override fun onResume() {
super.onResume()
updateDevMode();
}
fun updateDevMode() {
if(SettingsDev.instance.developerMode)
_devSets.visibility = View.VISIBLE;
else
_devSets.visibility = View.GONE;
}
override fun finish() {
super.finish()
_isFinished = true;
if(_lastActivity == this)
_lastActivity = null;
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
override fun onDestroy() {
super.onDestroy()
settingsActivityClosed.emit()
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: SettingsActivity? = null;
val settingsActivityClosed = Event0()
fun getActivity(): SettingsActivity? {
val act = _lastActivity;
if(act != null && !act._isFinished)
return act;
return null;
}
}
}
@@ -5,6 +5,7 @@ import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.modifier.IRequest
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.parsers.HttpResponseParser
import com.futo.platformplayer.readLine
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
private var _injectReferer = false;
private val _client = ManagedHttpClient();
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
override fun handle(context: HttpContext) {
if (useTcp) {
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl);
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
var url = targetUrl
if (req != null) {
req.url?.let {
url = it
}
req.headers.let {
proxyHeaders.clear()
proxyHeaders.putAll(it)
}
}
val parsed = Uri.parse(url);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
proxyHeaders.put("Referer", url);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders);
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
"HEAD" -> _client.head(targetUrl, proxyHeaders)
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
"GET" -> _client.get(url, proxyHeaders);
"POST" -> _client.post(url, content ?: "", proxyHeaders);
"HEAD" -> _client.head(url, proxyHeaders)
else -> _client.requestMethod(useMethod, url, proxyHeaders);
};
Logger.i(TAG, "Proxied Response [${resp.code}]");
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl);
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
var url = targetUrl
if (req != null) {
req.url?.let {
url = it
}
req.headers.let {
proxyHeaders.clear()
proxyHeaders.putAll(it)
}
}
val parsed = Uri.parse(url);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
proxyHeaders.put("Referer", url);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
_ignoreRequestHeaders.add("referer");
return this;
}
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
_requestModifier = modifier;
return this;
}
companion object {
private const val TAG = "HttpProxyHandler"
@@ -153,8 +153,8 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -186,8 +186,8 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.Dictionary
@Serializable
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
val details: String? = null,
val once: Boolean? = true
) {
@Contextual
@Transient
private var _regex: Regex? = null;
fun getRegex(): Regex {
@@ -23,7 +23,7 @@ class SourcePluginConfig(
//Script
val repositoryUrl: String? = null,
val scriptUrl: String = "",
val version: Int = -1,
var version: Int = -1,
val iconUrl: String? = null,
var id: String = UUID.randomUUID().toString(),
@@ -23,6 +23,7 @@ import java.util.UUID
class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
val config get() = _jsConfig
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
return resp;
}
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
if(doUpdateCookies) {
val domain = url.host?.lowercase() ?: return;
val domainParts = domain.split(".");
val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in headers) {
if(header.key.lowercase() == "set-cookie") {
var domainToUse = domain;
val cookie = cookieStringToPair(header.value.first());
var cookieValue = cookie.second;
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0)
continue;
cookieValue = cookieParts[0].trim();
val cookieVariables = cookieParts.drop(1).map {
val splitIndex = it.indexOf("=");
if (splitIndex < 0)
return@map Pair(it.trim().lowercase(), "");
return@map Pair<String, String>(
it.substring(0, splitIndex).lowercase().trim(),
it.substring(splitIndex + 1).trim()
);
}.toMap();
domainToUse = if (cookieVariables.containsKey("domain"))
cookieVariables["domain"]!!.lowercase();
else defaultCookieDomain;
//TODO: Make sure this has no negative effect besides apply cookies to root domain
if(!domainToUse.startsWith("."))
domainToUse = ".${domainToUse}";
}
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
_currentCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_currentCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
else {
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
_otherCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_otherCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
}
}
}
if(_jsClient is DevJSClient) {
//val peekBody = resp.peekBody(1000 * 1000).string();
StateDeveloper.instance.addDevHttpExchange(
StateDeveloper.DevHttpExchange(
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
));
}
}
private fun cookieStringToPair(cookie: String): Pair<String, String> {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
@@ -30,7 +31,7 @@ class JSSubtitleSource : ISubtitleSource {
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrThrow(config, "language", context, false);
language = v8Value.getOrDefault(config, "language", context, null);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
@@ -1,5 +1,160 @@
package com.futo.platformplayer.api.media.platforms.local
class LocalClient {
//TODO
import android.content.ContentResolver
import android.net.Uri
import android.provider.MediaStore
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
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.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.StateLibrary
import java.net.MalformedURLException
class LocalClient: IPlatformClient {
override val id: String = "LOCAL"
override val name: String = "Local"
override val icon: ImageVariable? = ImageVariable.fromResource(R.drawable.ic_library)
override val capabilities: PlatformClientCapabilities = PlatformClientCapabilities()
override fun initialize() {}
override fun disable() {
}
override fun getHome(): IPager<IPlatformContent>
= EmptyPager();
override fun isContentDetailsUrl(url: String): Boolean {
try {
val uri = Uri.parse(url);
return ContentResolver.SCHEME_CONTENT == uri.scheme
&& (
MediaStore.AUTHORITY == uri.authority ||
uri.authority == "com.android.externalstorage.documents"
)
}
catch(ex: MalformedURLException) {
return false;
}
}
val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a");
override fun getContentDetails(url: String): IPlatformContentDetails {
val uri = Uri.parse(url);
if("audio" in uri.pathSegments) {
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
}
else if("video" in uri.pathSegments) {
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
}
else if(uri.toString().contains("com.android.externalstorage.documents")) {
if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false })
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
else
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
}
else
throw Exception("Unknown content url [${url}]");
}
override fun getSearchCapabilities(): ResultCapabilities
= ResultCapabilities();
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
return EmptyPager(); //TODO
}
override fun getSearchChannelContentsCapabilities(): ResultCapabilities
= ResultCapabilities();
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
return EmptyPager(); //TODO
}
override fun searchChannels(query: String): IPager<PlatformAuthorLink> {
return EmptyPager(); //TODO
}
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
return EmptyPager(); //TODO
}
override fun isChannelUrl(url: String): Boolean {
return false //TODO
}
override fun getChannel(channelUrl: String): IPlatformChannel {
throw NotImplementedError();
}
override fun getChannelCapabilities(): ResultCapabilities
= ResultCapabilities();
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
return EmptyPager();
}
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> {
return EmptyPager();
}
override fun getPeekChannelTypes(): List<String> = listOf();
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent>
= listOf();
override fun getShorts(): IPager<IPlatformVideo> = EmptyPager();
override fun searchSuggestions(query: String): Array<String> = arrayOf();
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String?
= null;
override fun getContentChapters(url: String): List<IChapter>
= listOf();
override fun getPlaybackTracker(url: String): IPlaybackTracker?
= null;
override fun getContentRecommendations(url: String): IPager<IPlatformContent>?
= null;
override fun getComments(url: String): IPager<IPlatformComment>
= EmptyPager();
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment>
= EmptyPager();
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?
= null;
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>?
= null;
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent>
= throw NotImplementedError();
override fun isPlaylistUrl(url: String): Boolean = false;
override fun getPlaylist(url: String): IPlatformPlaylistDetails
= throw NotImplementedError();
override fun getUserPlaylists(): Array<String> = throw NotImplementedError();
override fun getUserSubscriptions(): Array<String> = throw NotImplementedError();
override fun getUserHistory(): IPager<IPlatformContent> = throw NotImplementedError();
override fun isClaimTypeSupported(claimType: Int): Boolean = false;
}
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
@@ -295,20 +296,63 @@ abstract class StateCasting {
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, metadataFromVideo(video));
val videoPath = "/video-$id"
val upstreamUrl = videoSource.getVideoUrl()
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
val jsReqMod = (videoSource as? JSSource)?.getRequestModifier()
if (proxyStreams) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, upstreamUrl, true)
.withIRequestModifier(jsReqMod)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castSingular")
}
Logger.i(TAG, "Casting as singular video (proxy=$proxyStreams, url=$videoUrl)")
ad.loadVideo(
if (video.isLive) "LIVE" else "BUFFERED",
videoSource.container,
videoUrl,
resumePosition,
video.duration.toDouble(),
speed,
metadataFromVideo(video)
)
} 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, metadataFromVideo(video));
val audioPath = "/audio-$id"
val upstreamUrl = audioSource.getAudioUrl()
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
val jsReqMod = (audioSource as? JSSource)?.getRequestModifier()
if (proxyStreams) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, upstreamUrl, true)
.withIRequestModifier(jsReqMod)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castSingular")
}
Logger.i(TAG, "Casting as singular audio (proxy=$proxyStreams, url=$audioUrl)")
ad.loadVideo(
if (video.isLive) "LIVE" else "BUFFERED",
audioSource.container,
audioUrl,
resumePosition,
video.duration.toDouble(),
speed,
metadataFromVideo(video)
)
} else if (videoSource is IHLSManifestSource) {
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
} 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, metadataFromVideo(video));
@@ -316,7 +360,7 @@ abstract class StateCasting {
} else if (audioSource is IHLSManifestAudioSource) {
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
} 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, metadataFromVideo(video));
@@ -347,6 +391,11 @@ abstract class StateCasting {
}
}
private fun HttpProxyHandler.withIRequestModifier(requestModifier: IRequestModifier?): HttpProxyHandler {
if (requestModifier == null) return this
return withRequestModifier { url, headers -> requestModifier.modifyRequest(url, headers) }
}
fun resumeVideo(): Boolean {
val ad = activeDevice ?: return false;
try {
@@ -665,7 +714,8 @@ abstract class StateCasting {
sourceUrl: String,
codec: String?,
resumePosition: Double,
speed: Double?
speed: Double?,
requestModifier: IRequestModifier?
): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster")
@@ -686,7 +736,9 @@ abstract class StateCasting {
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val masterPlaylistResponse = _client.get(sourceUrl)
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -706,7 +758,7 @@ abstract class StateCasting {
val variantPlaylist =
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
return@HttpFunctionHandler
@@ -747,7 +799,7 @@ abstract class StateCasting {
val variantPlaylist =
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -784,7 +836,7 @@ abstract class StateCasting {
val variantPlaylist =
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(
url, playlistId, variantPlaylist, video.isLive
url, playlistId, variantPlaylist, video.isLive, requestModifier
)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
@@ -826,13 +878,13 @@ abstract class StateCasting {
return listOf(hlsUrl);
}
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, requestModifier: IRequestModifier?, proxySegments: Boolean = true): HLS.VariantPlaylist {
val newSegments = arrayListOf<HLS.Segment>()
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
}
} else {
newSegments.addAll(variantPlaylist.segments)
@@ -850,7 +902,7 @@ abstract class StateCasting {
)
}
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long, requestModifier: IRequestModifier?): HLS.Segment {
if (segment is HLS.MediaSegment) {
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
val newSegmentUrl = url + newSegmentPath;
@@ -858,6 +910,7 @@ abstract class StateCasting {
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withIRequestModifier(requestModifier)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets
@@ -268,11 +269,15 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(403, "This plugin doesn't support auth");
return;
}
LoginFragment.showLogin(config){
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
/*
LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
}; */
context.respondCode(200, "Login started");
}
catch(ex: Throwable) {
@@ -90,6 +90,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener {
try {
StateCasting.instance.stopVideo()
StateCasting.instance.activeDevice?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Active device failed to disconnect: $e")
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonCancel1: Button;
private lateinit var _buttonCancel2: Button;
private lateinit var _buttonAlways: LinearLayout;
private lateinit var _buttonUpdate: LinearLayout;
private lateinit var _buttonOk: LinearLayout;
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView;
private lateinit var _textResult: TextView;
private lateinit var _textChangelogResult: TextView;
private lateinit var _uiChoiceTop: FrameLayout;
private lateinit var _uiProgressTop: FrameLayout;
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonCancel1 = findViewById(R.id.button_cancel_1);
_buttonCancel2 = findViewById(R.id.button_cancel_2);
_buttonAlways = findViewById(R.id.button_always);
_buttonUpdate = findViewById(R.id.button_update);
_buttonOk = findViewById(R.id.button_ok);
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
_textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result);
_textChangelogResult = findViewById(R.id.text_changelog_result);
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
val changelog = _newConfig.changelog!![changelogVersion]!!;
if(changelog.size > 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
_textChangelogResult.text = _textChangelog.text;
}
else if(changelog.size == 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
_textChangelogResult.text = _textChangelog.text;
}
else
else {
_textChangelog.visibility = View.GONE;
} else
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
}
} else {
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
}
}
catch(ex: Throwable) {
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
Logger.e(TAG, "Invalid changelog? ", ex);
}
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
_isUpdating = true;
update();
};
_buttonAlways.setOnClickListener {
if (_isUpdating)
return@setOnClickListener;
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
if(plugin != null) {
plugin.appSettings.automaticUpdate = true;
StatePlugins.instance.savePlugin(_oldConfig.id);
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
}
_isUpdating = true;
update();
};
Glide.with(_iconPlugin)
.load(_oldConfig.absoluteIconUrl)
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
if (_isUpdating)
return;
_isUpdating = true;
update();
update(true);
}
}
}
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
super.dismiss();
}
private fun update() {
private fun update(automatic: Boolean = false) {
_uiChoiceTop.visibility = View.GONE;
_uiRiskTop.visibility = View.GONE;
_uiChoiceBot.visibility = View.GONE;
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
val scope = StateApp.instance.scopeOrNull;
scope?.launch(Dispatchers.IO) {
try {
withContext(Dispatchers.Main) {
_textProgres.setText("Loading current script file...");
}
val client = ManagedHttpClient();
client.setTimeout(10000);
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
withContext(Dispatchers.Main) {
_textProgres.setText("Requesting new script file...");
}
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
if(newScript.isNullOrEmpty())
throw IllegalStateException("No script found");
@@ -1,6 +1,9 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaMuxer
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
@@ -8,6 +11,7 @@ import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
@@ -136,6 +140,8 @@ class VideoDownload {
var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false;
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
var progress: Double = 0.0;
var isCancelled = false;
@@ -203,8 +209,10 @@ class VideoDownload {
this.prepareTime = OffsetDateTime.now();
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -478,8 +486,8 @@ class VideoDownload {
if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
@@ -518,8 +526,8 @@ class VideoDownload {
if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
@@ -580,83 +588,12 @@ class VideoDownload {
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
var downloadedTotalLength = 0L
val segmentFiles = arrayListOf<File>()
try {
val response = client.get(hlsUrl)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
} else {
null
}
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) {
return@forEachIndexed
}
Logger.i(TAG, "Download '$name' segment $index Sequential");
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outputStream = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
}
downloadedTotalLength += segmentLength
} finally {
outputStream.close()
}
}
Logger.i(TAG, "Combining segments into $targetFile");
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "${name} downloadSource Finished");
}
catch(ioex: IOException) {
if(targetFile.exists())
targetFile.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
throw ioex;
}
catch(ex: Throwable) {
if(targetFile.exists())
targetFile.delete();
throw ex;
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength;
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
}
@@ -665,6 +602,7 @@ class VideoDownload {
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
@@ -672,6 +610,7 @@ class VideoDownload {
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
@@ -686,6 +625,237 @@ class VideoDownload {
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>()
if (rangeStart != null) {
if (rangeLength != null && rangeLength > 0) {
val end = rangeStart + rangeLength - 1
headers["Range"] = "bytes=$rangeStart-$end"
} else {
headers["Range"] = "bytes=$rangeStart-"
}
}
val modified = modifier?.modifyRequest(url, headers)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
if (!resp.isOk) {
resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
}
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
val bytes = body.bytes()
body.close()
return bytes
}
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
return ByteBuffer.allocate(16)
.putLong(0L)
.putLong(sequenceNumber)
.array()
}
val segmentFiles = arrayListOf<File>()
try {
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
val vpContent = playlistResp.body?.string()
?: throw IllegalStateException("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val hlsDec = variantPlaylist.decryptionInfo
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
var keyBytes: ByteArray? = null
var staticIvBytes: ByteArray? = null
if (useDecryption) {
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
}
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
keyBytes = downloadBytes(keyUrl)
if (!hlsDec.iv.isNullOrEmpty()) {
staticIvBytes = hlsDec.iv.hexStringToByteArray()
}
}
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
val rangeOffsets = mutableMapOf<String, Long>()
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Downloading HLS initialization map")
var mapRangeStart: Long? = null
var mapRangeLength: Long? = null
if (variantPlaylist.mapBytesLength > 0) {
mapRangeLength = variantPlaylist.mapBytesLength
val mapUrl = variantPlaylist.mapUrl
if (variantPlaylist.mapBytesStart >= 0) {
mapRangeStart = variantPlaylist.mapBytesStart
rangeOffsets[mapUrl] =
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
} else {
val offset = rangeOffsets[mapUrl] ?: 0L
mapRangeStart = offset
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
}
}
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val iv = staticIvBytes
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
mapBytes = decryptSegment(mapBytes, kb, iv)
}
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
throw IllegalStateException("HLS MAP segment too large to handle.")
}
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outStr = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
outStr.write(mapBytes)
outStr.flush()
} finally {
outStr.close()
}
downloadedTotalLength += mapBytes.size
}
val totalSegments = variantPlaylist.segments.size
var mediaSegmentIndex = 0
var bytesSinceLastSpeedUpdate = 0L
var lastSpeedUpdateTime = System.currentTimeMillis()
var lastSpeed = 0L
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) return@forEachIndexed
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Download '$name' segment $index sequential")
var rangeStart: Long? = null
var rangeLength: Long? = null
if (segment.bytesLength > 0) {
rangeLength = segment.bytesLength
val urlKey = segment.uri
if (segment.bytesStart >= 0) {
rangeStart = segment.bytesStart
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
} else {
val offset = rangeOffsets[urlKey] ?: 0L
rangeStart = offset
rangeOffsets[urlKey] = offset + segment.bytesLength
}
}
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val ivBytes = if (staticIvBytes != null) {
staticIvBytes
} else {
val sequenceNumber = mediaSequence + mediaSegmentIndex
buildSequenceIv(sequenceNumber)
}
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
}
val segmentLength = segmentBytes.size.toLong()
if (segmentLength > Int.MAX_VALUE) {
throw IllegalStateException("HLS media segment too large to handle.")
}
val avgLen = if (index == 0) {
segmentLength
} else {
if (index > 0) downloadedTotalLength / index else segmentLength
}
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outStr = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
outStr.write(segmentBytes)
} finally {
outStr.close()
}
downloadedTotalLength += segmentLength
bytesSinceLastSpeedUpdate += segmentLength
val now = System.currentTimeMillis()
val elapsed = now - lastSpeedUpdateTime
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
bytesSinceLastSpeedUpdate = 0
lastSpeedUpdateTime = now
}
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
mediaSegmentIndex++
}
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "Finished HLS Source for $name")
} catch (ioex: IOException) {
if (targetFile.exists())
targetFile.delete()
if (ioex.message?.contains("ENOSPC") == true)
throw Exception("Not enough space on device", ioex)
else
throw ioex
} catch (ex: Throwable) {
if (targetFile.exists())
targetFile.delete()
throw ex
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength
}
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -715,6 +885,11 @@ class VideoDownload {
source.getRequestExecutor();
else
null;
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
val speedTracker = SpeedTracker(1000);
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
@@ -726,12 +901,14 @@ class VideoDownload {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", url, null, mapOf());
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(url, mutableMapOf());
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
@@ -766,7 +943,7 @@ class VideoDownload {
}
return sourceLength!!;
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -775,7 +952,12 @@ class VideoDownload {
val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile);
try{
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try {
val head = client.tryHead(videoUrl);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
@@ -786,12 +968,12 @@ class VideoDownload {
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
sourceLength = head["content-length"]!!.toLong();
onProgress(sourceLength, 0, 0);
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
}
else {
Logger.i(TAG, "Download $name Sequential");
try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e
@@ -842,7 +1024,7 @@ class VideoDownload {
}
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadSource_Sequential(client: ManagedHttpClient, modifier: IRequestModifier? = null, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5;
@@ -851,7 +1033,12 @@ class VideoDownload {
var lastSpeed: Long = 0;
val result = client.get(url);
val result = if (modifier != null) {
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
if (!result.isOk) {
result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -988,7 +1175,7 @@ class VideoDownload {
onProgress(sourceLength, totalRead, 0)
return sourceLength
}*/
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5;
@@ -1007,7 +1194,7 @@ class VideoDownload {
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead,
val byteRangeResults = requestByteRangeParallel(client, pool, modifier, url, sourceLength, concurrency, totalRead,
rangeSize, 1024 * 64);
for(byteRange in byteRangeResults) {
@@ -1038,7 +1225,7 @@ class VideoDownload {
onProgress(sourceLength, totalRead, 0);
}
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, modifier: IRequestModifier?, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
var readPosition = rangePosition;
for(i in 0 until concurrency) {
@@ -1052,21 +1239,25 @@ class VideoDownload {
else readPosition + toRead;
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
return@submit requestByteRange(client, url, rangeStart, rangeEnd);
return@submit requestByteRange(client, modifier, url, rangeStart, rangeEnd);
});
readPosition = rangeEnd + 1;
}
return tasks.map { it.get() };
}
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
var retryCount = 0
var lastException: Throwable? = null
var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) {
try {
val toRead = rangeEnd - rangeStart;
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
if (!req.isOk) {
val bodyString = req.body?.string()
req.body?.close()
@@ -36,6 +36,7 @@ import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageHttpImp
import com.futo.platformplayer.engine.packages.PackageJSDOM
import com.futo.platformplayer.engine.packages.PackageUtilities
import com.futo.platformplayer.engine.packages.V8Package
@@ -383,6 +384,7 @@ class V8Plugin {
return when(packageName) {
"DOMParser" -> PackageDOMParser(this)
"Http" -> PackageHttp(this, config)
"HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(this, config)
"JSDOM" -> PackageJSDOM(this, config)
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
@@ -0,0 +1,208 @@
package com.curlbind
import androidx.annotation.Keep
import java.io.ByteArrayOutputStream
import java.nio.charset.Charset
import kotlin.collections.iterator
import kotlin.math.min
@Keep
object Libcurl {
init {
System.loadLibrary("curl-impersonate")
System.loadLibrary("curl-impersonate-jni")
// CURL_GLOBAL_ALL = 3
require(ce_global_init(3) == CURLcode.CURLE_OK) { "curl_global_init failed" }
}
@Keep
data class Request(
var url: String,
var method: String = "GET",
var headers: Map<String, String> = emptyMap(),
var body: ByteArray? = null,
var impersonateTarget: String = "chrome136",
var useBuiltInHeaders: Boolean = true,
var timeoutMs: Int = 30_000
)
@Keep
data class Response(
val status: Int,
val effectiveUrl: String,
val bodyBytes: ByteArray,
val headers: Map<String, List<String>>
)
object CURLcode {
const val CURLE_OK = 0
const val CURLE_UNKNOWN_OPTION = 48
}
object CurlInfoConsts {
const val CURLINFO_STRING = 0x100000
const val CURLINFO_LONG = 0x200000
const val CURLINFO_DOUBLE = 0x300000
const val CURLINFO_SLIST = 0x400000
const val CURLINFO_PTR = 0x400000
const val CURLINFO_SOCKET = 0x500000
const val CURLINFO_OFF_T = 0x600000
const val CURLINFO_MASK = 0x0fffff
const val CURLINFO_TYPEMASK = 0xf00000
}
object CURLINFO {
const val NONE = 0
const val EFFECTIVE_URL = CurlInfoConsts.CURLINFO_STRING + 1
const val RESPONSE_CODE = CurlInfoConsts.CURLINFO_LONG + 2
}
object CURLOPT {
const val URL = 10002
const val FOLLOWLOCATION = 52
const val MAXREDIRS = 68
const val CONNECTTIMEOUT_MS = 156
const val TIMEOUT_MS = 155
const val HTTP_VERSION = 84
const val ACCEPT_ENCODING = 10102
const val HTTPHEADER = 10023
const val COOKIEFILE = 10031
const val COOKIEJAR = 10082
const val CUSTOMREQUEST = 10036
const val IPRESOLVE = 113
const val POSTFIELDS = 10015
const val POSTFIELDSIZE = 60
const val WRITEFUNCTION = 20011
const val HEADERFUNCTION = 20079
const val WRITEDATA = 10001
const val HEADERDATA = 10029
const val COPYPOSTFIELDS = 10165
const val CURLOPT_DNS_SERVERS = 10211
const val CAPATH = 10097
const val CAINFO = 10065
}
object CURL_HTTP_VERSION { const val TWO_TLS = 4 }
object CURL_IPRESOLVE { const val WHATEVER = 0; const val V4 = 1; const val V6 = 2 }
@Keep interface WriteCallback { fun onWrite(chunk: ByteArray): Int }
@Keep interface HeaderCallback { fun onHeader(line: ByteArray): Int }
@Volatile private var defaultCAPath: String? = null
@Keep fun setDefaultCAPath(path: String) { defaultCAPath = path }
fun perform(req: Request): Response {
val easy = ce_easy_init()
require(easy != 0L) { "curl_easy_init failed" }
var slist: Long = 0L
val bodySink = ByteArrayOutputStream(64 * 1024)
val rawHeaderLines = ArrayList<String>(64)
try {
val imp = ce_easy_impersonate(easy, req.impersonateTarget, req.useBuiltInHeaders)
if (imp != CURLcode.CURLE_OK && imp != CURLcode.CURLE_UNKNOWN_OPTION) {
error("curl_easy_impersonate failed: ${ce_easy_strerror(imp)}")
}
checkOK(ce_setopt_str(easy, CURLOPT.URL, req.url))
checkOK(ce_setopt_long(easy, CURLOPT.FOLLOWLOCATION, 1))
checkOK(ce_setopt_long(easy, CURLOPT.MAXREDIRS, 10))
checkOK(ce_setopt_long(easy, CURLOPT.CONNECTTIMEOUT_MS, req.timeoutMs.toLong()))
checkOK(ce_setopt_long(easy, CURLOPT.TIMEOUT_MS, req.timeoutMs.toLong()))
checkOK(ce_setopt_long(easy, CURLOPT.HTTP_VERSION, CURL_HTTP_VERSION.TWO_TLS.toLong()))
checkOK(ce_setopt_str(easy, CURLOPT.ACCEPT_ENCODING, "")) // enable auto-decompress
if (req.headers.isNotEmpty()) {
for ((k, v) in req.headers) slist = ce_slist_append(slist, "$k: $v")
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
}
val method = req.method
if (!method.equals("GET", ignoreCase = true)) {
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
val body = req.body
if (body != null && body.isNotEmpty()) {
checkOK(ce_set_postfields(easy, body))
}
}
checkOK(ce_set_write_callback(easy, object : WriteCallback {
override fun onWrite(chunk: ByteArray): Int {
bodySink.write(chunk)
return chunk.size
}
}))
checkOK(ce_set_header_callback(easy, object : HeaderCallback {
override fun onHeader(line: ByteArray): Int {
// Keep raw but trim CRLF for convenience
val s = line.toString(Charset.forName("ISO-8859-1")).trimEnd('\r', '\n')
if (s.isNotBlank()) rawHeaderLines.add(s)
return line.size
}
}))
checkOK(ce_setopt_str(easy, CURLOPT.CURLOPT_DNS_SERVERS, "1.1.1.1,8.8.8.8"));
defaultCAPath?.let { checkOK(ce_setopt_str(easy, CURLOPT.CAINFO, it)) }
val rc = ce_easy_perform(easy)
if (rc != CURLcode.CURLE_OK) error("curl_easy_perform failed: ${ce_easy_strerror(rc)}")
val codeArr = longArrayOf(0)
checkOK(ce_easy_getinfo_long(easy, CURLINFO.RESPONSE_CODE, codeArr))
val effective = ce_easy_getinfo_string(easy, CURLINFO.EFFECTIVE_URL) ?: req.url
return Response(
status = codeArr[0].toInt(),
effectiveUrl = effective,
bodyBytes = bodySink.toByteArray(),
headers = parseHeaders(rawHeaderLines)
)
} finally {
if (slist != 0L) ce_slist_free_all(slist)
ce_easy_cleanup(easy)
}
}
private fun defaultCookieJarPath(): String {
val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
return if (tmp.endsWith("/")) "${tmp}imphttp.cookies.txt" else "$tmp/imphttp.cookies.txt"
}
private fun checkOK(code: Int) {
if (code != CURLcode.CURLE_OK) throw IllegalStateException("libcurl error: ${ce_easy_strerror(code)}")
}
private fun parseHeaders(lines: List<String>): Map<String, List<String>> {
val map = linkedMapOf<String, MutableList<String>>()
for (line in lines) {
val idx = line.indexOf(':')
if (idx <= 0) continue
val name = line.substring(0, idx).trim()
val value = line.substring(min(idx + 1, line.length)).trim()
map.getOrPut(name) { mutableListOf() }.add(value)
}
return map
}
@JvmStatic external fun ce_set_write_callback(easy: Long, cb: WriteCallback?): Int
@JvmStatic external fun ce_set_header_callback(easy: Long, cb: HeaderCallback?): Int
@JvmStatic external fun ce_global_init(flags: Long): Int
@JvmStatic external fun ce_global_cleanup()
@JvmStatic external fun ce_easy_init(): Long
@JvmStatic external fun ce_easy_cleanup(easy: Long)
@JvmStatic external fun ce_easy_perform(easy: Long): Int
@JvmStatic external fun ce_easy_impersonate(easy: Long, target: String, defaultHeaders: Boolean): Int
@JvmStatic external fun ce_setopt_long(easy: Long, opt: Int, value: Long): Int
@JvmStatic external fun ce_setopt_str(easy: Long, opt: Int, value: String): Int
@JvmStatic external fun ce_setopt_ptr(easy: Long, opt: Int, ptr: Long): Int
@JvmStatic external fun ce_slist_append(list: Long, header: String): Long
@JvmStatic external fun ce_slist_free_all(list: Long)
@JvmStatic external fun ce_easy_getinfo_long(easy: Long, info: Int, outVal: LongArray): Int
@JvmStatic external fun ce_easy_getinfo_string(easy: Long, info: Int): String?
@JvmStatic external fun ce_set_postfields(easy: Long, body: ByteArray): Int
@JvmStatic external fun ce_easy_strerror(code: Int): String
}
@@ -55,7 +55,7 @@ class PackageDOMParser : V8Package {
}
@V8Property
fun lastChild(): DOMNode? {
val result = _element.firstElementChild()?.let { DOMNode(_package, it) };
val result = _element.lastElementChild()?.let { DOMNode(_package, it) };
if(result != null)
_children.add(result);
return result;
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
open class MainActivityFragment : Fragment() {
protected val currentMain : MainFragment
protected val currentMain : MainFragment?
get() {
isValidMainActivity();
return (activity as MainActivity).fragCurrent;
@@ -8,19 +8,25 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.animation.doOnEnd
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
@@ -28,6 +34,10 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.pills.RoundButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.floor
@@ -70,9 +80,15 @@ class MenuBottomBarFragment : MainActivityFragment() {
private val _inflater: LayoutInflater;
private val _subscribedActivity: MainActivity?;
private val _containerMoreHeader: ConstraintLayout;
private val _toggleAirplaneMode: LinearLayout;
private val _togglePrivacy: LinearLayout;
private var _overlayMore: FrameLayout;
private var _overlayMoreBackground: FrameLayout;
private var _layoutMoreButtons: LinearLayout;
private var _layoutMoreButtons: RecyclerView;
private val _layoutMoreButtonItems = arrayListOf<MenuButtonItem>();
private var _layoutMoreButtonsAdapter: AnyAdapterView<MenuButtonItem, MenuButtonItemViewHolder>;
private var _layoutBottomBarButtons: LinearLayout;
private var _moreVisible = false;
@@ -86,15 +102,79 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
private var moreColumns = 3;
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
_inflater = inflater;
inflater.inflate(R.layout.fragment_overview_bottom_bar, this);
_containerMoreHeader = findViewById(R.id.container_more_options);
_toggleAirplaneMode = findViewById(R.id.container_toggle_airplane);
_togglePrivacy = findViewById(R.id.container_toggle_privacy);
_toggleAirplaneMode.isVisible = false //TODO: Remove when airplane mode implemented
StateApp.instance.airplaneModeChanged.subscribe {
if(!StateApp.instance.airplaneMode)
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
else
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
}
if(!StateApp.instance.airplaneMode)
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
else
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
_toggleAirplaneMode.setOnClickListener {
if(StateApp.instance.airplaneMode) {
StateApp.instance.setAirMode(false);
UIDialogs.appToast("Airplane mode disabled");
}
else {
StateApp.instance.setAirMode(true);
UIDialogs.appToast("Airplane mode enabled");
}
}
StateApp.instance.privateModeChanged.subscribe {
if(!StateApp.instance.privateMode)
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
else
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
}
if(!StateApp.instance.privateMode)
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
else
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
_togglePrivacy.setOnClickListener {
if(StateApp.instance.privateMode) {
StateApp.instance.setPrivacyMode(false);
UIDialogs.appToast("Privacy mode disabled");
}
else {
StateApp.instance.setPrivacyMode(true);
UIDialogs.appToast("Privacy mode enabled");
}
}
_overlayMore = findViewById(R.id.more_overlay);
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
_layoutMoreButtons = findViewById(R.id.more_menu_buttons);
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons)
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons);
val totalWidthDp = resources.displayMetrics.widthPixels / resources.displayMetrics.density;
val columns = MenuButtonItemViewHolder.getAutoSizeColumns(totalWidthDp);
_layoutMoreButtonsAdapter = _layoutMoreButtons.asAny<MenuButtonItem, MenuButtonItemViewHolder>(_layoutMoreButtonItems,
RecyclerView.VERTICAL, false, { button ->
button.setAutoSize(totalWidthDp);
button.parentFragment = this@MenuBottomBarView._fragment;
button.onClick.subscribe {
setMoreVisible(false);
}
})
moreColumns = columns;
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
_layoutMoreButtons.layoutManager = layoutManager;
_overlayMoreBackground.setOnClickListener { setMoreVisible(false); };
@@ -121,6 +201,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
private fun setMoreVisible(visible: Boolean) {
//TODO: issues with these bools
if (_moreVisibleAnimating) {
return
}
@@ -129,9 +211,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
return
}
/*
val height = _moreButtons.firstOrNull()?.let {
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
} ?: return
*/
_moreVisibleAnimating = true
val moreOverlayBackground = _overlayMoreBackground
@@ -143,10 +228,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
moreOverlay.visibility = VISIBLE
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 0.0f, 1.0f).setDuration(duration))
animations.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 0.0f, 1.0f).setDuration(duration))
_bottomButtons.find { it.definition.id == 99 }?.let {
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.5f, 1.0f)
.setDuration(duration));
}
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", resources.displayMetrics.heightPixels.toFloat(), 0.0f).setDuration(duration))
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
//animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
}
val animatorSet = AnimatorSet()
@@ -158,11 +250,24 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.start()
} else {
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f).setDuration(duration))
animations
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
.setDuration(duration))
animations
.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 1.0f, 0.0f)
.setDuration(duration))
animations
.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 1.0f, 0.0f)
.setDuration(duration))
_bottomButtons.find { it.definition.id == 99 }?.let {
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.5f)
.setDuration(duration));
}
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", 0.0f, resources.displayMetrics.heightPixels.toFloat()).setDuration(duration))
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
//animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
}
val animatorSet = AnimatorSet()
@@ -174,11 +279,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.playTogether(animations)
animatorSet.start()
}
}
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
if (hasMore) {
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(true) }))
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(!_moreVisible) }))
}
_bottomButtons.clear();
@@ -218,32 +324,42 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutMoreButtons.removeAllViews();
var insertedButtons = 0;
//Force settings to be first
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
if (settingsIndex != -1) {
val button = buttons[settingsIndex]
buttons.removeAt(settingsIndex)
buttons.add(0, button)
//insertedButtons++;
}
//Force buy to be on top for more buttons
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
if (buyIndex != -1) {
val button = buttons[buyIndex]
buttons.removeAt(buyIndex)
buttons.add(0, button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(if (insertedButtons == 1) 1 else 0, button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
//Force privacy to be third
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
if (privacyIndex != -1) {
val button = buttons[privacyIndex]
buttons.removeAt(privacyIndex)
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
val newButtons = mutableListOf<MenuButtonItem>();
for (data in buttons) {
/*
val button = MenuButton(context, data, _fragment, true);
button.setOnClickListener {
updateMenuIcons()
@@ -253,14 +369,19 @@ class MenuBottomBarFragment : MainActivityFragment() {
_moreButtons.add(button);
_layoutMoreButtons.addView(button);
*/
val buttonItem = MenuButtonItem(data);
newButtons.add(buttonItem);
}
_layoutMoreButtonsAdapter.setData(newButtons);
_layoutMoreButtonsAdapter.notifyContentChanged();
}
private fun updateMenuIcons() {
for(button in _bottomButtons.toList())
button.updateActive(_fragment);
for(button in _moreButtons.toList())
button.updateActive(_fragment);
button.updateActive(_fragment, true);
}
override fun onConfigurationChanged(newConfig: Configuration?) {
@@ -341,6 +462,71 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
class MenuButtonItem(val def: ButtonDefinition);
class MenuButtonItemViewHolder(private val _viewGroup: ViewGroup): AnyAdapter.AnyViewHolder<MenuButtonItem>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_menu_tile,
_viewGroup, false)) {
val onClick = Event1<MenuButtonItem>();
val root: ConstraintLayout;
val imageIcon: ImageView;
val textName: TextView;
var button: MenuButtonItem? = null;
var parentFragment: MenuBottomBarFragment? = null;
init {
root = _view.findViewById(R.id.root);
imageIcon = _view.findViewById(R.id.image_icon);
textName = _view.findViewById(R.id.text_name);
root.setOnClickListener {
button?.let {
it.def.action(parentFragment ?: return@let);
onClick.emit(it);
}
}
}
override fun bind(value: MenuButtonItem) {
button = value;
textName.text = _view.context.getString(value.def.string);
imageIcon.setImageResource(value.def.iconActive);
}
fun setWidth(dp: Int) {
root.updateLayoutParams {
this.width = (dp - 6).dp(_viewGroup.context.resources);
this.height = (dp - 6).dp(_viewGroup.context.resources);
}
imageIcon.updateLayoutParams {
this.width = (dp - 54).dp(_viewGroup.context.resources);
this.height = (dp - 54).dp(_viewGroup.context.resources);
}
}
fun setAutoSize(totalWidth: Float) {
val dpWidth = totalWidth;
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
val remainder = dpWidth - columns * viewWidthDp;
val targetSize = viewWidthDp + (remainder / columns).toInt();
setWidth(targetSize);
}
companion object {
val viewWidthDp = 90;
fun getAutoSizeColumns(totalWidth: Float): Int {
val dpWidth = totalWidth;
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
return columns;
}
}
}
class MenuButton: LinearLayout {
val definition: ButtonDefinition;
@@ -354,7 +540,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
this.definition = def;
_buttonImage = findViewById(R.id.image_button);
_buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon);
//_buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon);
_buttonImage.setImageResource(definition.iconActive);
if(definition.isActive(fragment) || isMore) {
this.alpha = 1f;
}
else {
this.alpha = 0.5f;
}
_textButton = findViewById(R.id.text_button);
_textButton.text = resources.getString(def.string);
@@ -365,8 +558,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
}
fun updateActive(fragment: MenuBottomBarFragment) {
_buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon);
fun updateActive(fragment: MenuBottomBarFragment, isMore: Boolean = false, overrideValue: Boolean? = null) {
//_buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon);
_buttonImage.setImageResource(definition.iconActive);
val isActive = overrideValue ?: definition.isActive(fragment) || isMore
if(isActive) {
this.alpha = 1f;
}
else {
this.alpha = 0.5f;
}
}
}
}
@@ -389,6 +590,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
}),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
//if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) })
,//else null,
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) }),
@@ -399,6 +603,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
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 }, {
it.navigate<SettingsFragment>();
/*
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
@@ -406,8 +612,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
c.startActivity(intent);
if (c is Activity) {
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
}
}),
}*/
}),/*
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
@@ -417,14 +623,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
UIDialogs.Action("Enable", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.PRIMARY));
}),
}),*/
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
})
//96 is reserved for privacy button
//98 is reserved for buy button
//99 is reserved for more button
);
).filterNotNull();
}
data class ButtonDefinition(
@@ -0,0 +1,88 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.MediaStore
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
class BaseFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = BaseFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: BaseFragment;
constructor(fragment: BaseFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library, this);
this.fragment = fragment;
}
fun onShown() {
}
}
}
@@ -64,7 +64,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
_exoPlayer = player;
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle ?: FeedStyle.THUMBNAIL, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this);
}
}
@@ -0,0 +1,91 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
_currentView = view;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
_currentView = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = DeveloperFragment().apply {}
private var _currentView: FragView? = null;
val currentView: FragView?
get() = _currentView;
}
class FragView: ConstraintLayout {
val fragment: DeveloperFragment;
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
constructor(fragment: DeveloperFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.activity_dev, this);
this.fragment = fragment;
val activity = fragment.activity;
findViewById<LinearLayout>(R.id.container_topbar).isVisible = false;
_buttonBack = findViewById(R.id.button_back);
_form = findViewById(R.id.settings_form);
_form.fromObject(SettingsDev.instance);
_form.onChanged.subscribe { _, _ ->
_form.setObjectValues();
SettingsDev.instance.save();
};
}
fun getField(id: String): IField? {
return _form.findField(id);
}
fun onShown() {
}
}
}
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
@@ -39,6 +40,7 @@ import java.time.OffsetDateTime
import kotlin.math.max
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
protected val _feedRoot: ConstraintLayout;
protected val _recyclerResults: RecyclerView;
protected val _overlayContainer: FrameLayout;
protected val _swipeRefresh: SwipeRefreshLayout;
@@ -51,6 +53,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _emptyPagerContainer: FrameLayout;
protected val _toolbarContentView: LinearLayout;
protected val _bottomContentView: LinearLayout;
private var _loading: Boolean = true;
@@ -67,7 +70,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private var _sortByOptions: List<String>? = null;
private var _activeTags: List<String>? = null;
private var _nextPageHandler: TaskHandler<TPager, List<TResult>>;
private var _nextPageHandler: TaskHandler<TPager, Pair<TPager, List<TResult>>>;
val recyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>;
val fragment: TFragment;
@@ -80,6 +83,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
this.fragment = fragment;
inflater.inflate(R.layout.fragment_feed, this);
_feedRoot = findViewById(R.id.feed_root);
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar);
@@ -134,24 +138,29 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
setActiveTags(null);
_toolbarContentView = findViewById(R.id.container_toolbar_content);
_bottomContentView = findViewById(R.id.container_bottom);
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
else
it.nextPage();
processPagerExceptions(it);
return@TaskHandler it.getResults();
return@TaskHandler Pair(it, it.getResults());
}).success {
val pager = it.first;
val results = it.second
setLoading(false);
val posBefore = recyclerData.results.size;
val filteredResults = filterResults(it);
val filteredResults = filterResults(results);
recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it);
recyclerData.resultsUnfiltered.addAll(results);
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
ensureEnoughContentVisible(filteredResults)
if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults)
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -390,6 +399,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected fun finishRefreshLayoutLoader() {
_swipeRefresh.isRefreshing = false;
}
protected fun disableRefreshLayout() {
_swipeRefresh.isEnabled = false;
}
fun clearResults(){
setPager(EmptyPager<TResult>() as TPager);
@@ -472,7 +484,8 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle;
ensureEnoughContentVisible(filteredResults)
if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults)
}
private fun detachPagerEvents() {
@@ -26,6 +26,7 @@ import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
@@ -243,12 +244,23 @@ class HistoryFragment : MainFragment() {
return;
}
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
val diff = v.video.duration - v.position;
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
StatePlayer.instance.clearQueue();
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
val playlistId = v.playlistId
val playlist = playlistId?.let { StatePlaylists.instance.getPlaylist(it) }
val playlistIndex = playlist?.videos?.indexOfFirst { it.url == v.video.url }
if (playlist != null && playlistIndex != null && playlistIndex >= 0) {
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
StatePlayer.instance.setPlaylist(playlist, playlistIndex)
} else {
StatePlayer.instance.clearQueue();
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
}
_editSearch.clearFocus();
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
_fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
finishRefreshLayoutLoader();
setLoading(false);
setPager(pager);
if(pager.getResults().isEmpty() && !pager.hasMorePages())
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
setLoading(false);
setEmptyPager(true);
}
}
}
@@ -0,0 +1,159 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AlbumHeaderView
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
class LibraryAlbumFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = FragView(this, inflater);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter);
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryAlbumFragment().apply {}
}
class FragView : FeedView<LibraryAlbumFragment, IPlatformVideo, IPlatformVideo, IPager<IPlatformVideo>, TrackViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
private val _header: AlbumHeaderView;
private var _album: Album? = null;
private var _tracks: List<IPlatformVideo>? = null;
private var _url: String? = null;
constructor(fragment: LibraryAlbumFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_header = AlbumHeaderView(context);
_toolbarContentView.addView(_header);
_header.onPlayAll.subscribe {
val playlist = _album?.toPlaylist(_tracks);
if (playlist != null) {
StatePlayer.instance.setPlaylist(playlist, focus = true);
}
}
_header.onShuffle.subscribe {
val playlist = _album?.toPlaylist(_tracks);
if (playlist != null) {
StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true);
}
}
/*
_feedRoot.updateLayoutParams<LinearLayout.LayoutParams> {
this.setMargins(0,-50.dp(resources),0,0)
} */
}
fun onShown(parameter: Any?) {
val album = if(parameter is String)
StateLibrary.instance.getAlbum(parameter);
else if(parameter is Long)
StateLibrary.instance.getAlbum(parameter);
else if(parameter is Album)
parameter;
else null;
if(album == null) {
_album = null;
_tracks = null;
setPager(EmptyPager());
return;
}
_header.setName(album.name);
_header.setThumbnail(album.thumbnail);
val tracks = album.getTracks();
_album = album;
_tracks = tracks;
_header.setMetadata("${tracks.size} tracks" + if(tracks.size > 0) ("" + tracks.sumOf { it.duration }.toHumanDuration(false)) else "");
setPager(AdhocPager({listOf()}, tracks));
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformVideo>): InsertedViewAdapterWithLoader<TrackViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = TrackViewHolder(viewGroup);
holder.onClick.subscribe { c ->
val playlist = _album?.toPlaylist(_tracks);
val index = playlist?.videos?.indexOfFirst { it.name == c.name } ?: -1;
if (playlist != null) {
if (index == -1)
return@subscribe;
StatePlayer.instance.setPlaylist(playlist, index, true);
}
};
holder.onOptions.subscribe {
if(it is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryArtistsFragmentsView";
}
}
}
@@ -0,0 +1,185 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LibraryTypeHeaderView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibraryAlbumsFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryAlbumsFragment().apply {}
}
class FragView : FeedView<LibraryAlbumsFragment, Album, Album, IPager<Album>, AlbumTileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val libraryTypeHeader: LibraryTypeHeaderView;
constructor(fragment: LibraryAlbumsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
libraryTypeHeader = LibraryTypeHeaderView(context);
libraryTypeHeader.setSelectedType(LibraryTypeHeaderView.SelectedType.Albums);
libraryTypeHeader.setMetadata("");
libraryTypeHeader.onSelectedChanged.subscribe {
when(it) {
LibraryTypeHeaderView.SelectedType.Artists -> fragment.navigate<LibraryArtistsFragment>();
else -> {}
}
}
_toolbarContentView.addView(libraryTypeHeader);
disableRefreshLayout();
}
fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
libraryTypeHeader.setMetadata("${initialAlbums.size} albums");
setPager(AdhocPager<Album>({ listOf(); }, initialAlbums));
}
override fun reload() {
super.reload();
finishRefreshLayoutLoader();
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Album>): InsertedViewAdapterWithLoader<AlbumTileViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = AlbumTileViewHolder(viewGroup);
holder.setAutoSize(resources.displayMetrics.widthPixels / resources.displayMetrics.density)
holder.onClick.subscribe { c -> fragment.navigate<LibraryAlbumFragment>(c) };
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, AlbumTileViewHolder.getAutoSizeColumns(resources.displayMetrics.widthPixels / resources.displayMetrics.density))
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
leftMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
3f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class AlbumViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Album>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album,
_viewGroup, false)) {
val onClick = Event1<Album?>();
protected var _album: Album? = null;
protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
init {
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_view.setOnClickListener { onClick.emit(_album) };
}
override fun bind(album: Album) {
_album = album;
_imageThumbnail?.let {
if (album.thumbnail != null)
Glide.with(it)
.load(album.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(it)
else
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
};
_textName.text = album.name;
_textMetadata.text = album.artist ?: "";
}
}
}
@@ -0,0 +1,634 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
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
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.assume
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment.AlbumViewHolder
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LibraryArtistFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _textMeta: TextView? = null;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter, isBack);
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryArtistFragment().apply {}
}
class FragView(fragment: LibraryArtistFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) {
private val _fragment: LibraryArtistFragment = fragment
private var _textChannel: TextView
private var _textChannelSub: TextView
private var _creatorThumbnail: CreatorThumbnail
private var _imageBanner: AppCompatImageView
private var _tabs: TabLayout
private var _viewPager: ViewPager2
// private var _adapter: ChannelViewPagerAdapter;
private var _tabLayoutMediator: TabLayoutMediator
private var _buttonSubscribe: SubscribeButton
private var _buttonSubscriptionSettings: ImageButton
private var _overlayContainer: FrameLayout
private var _overlayLoading: LinearLayout
private var _overlayLoadingSpinner: ImageView
private var _slideUpOverlay: SlideUpMenuOverlay? = null
private var _isLoading: Boolean = false
private var _selectedTabIndex: Int = -1
var channel: Artist? = null
private set
private var _url: String? = null
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
init {
inflater.inflate(R.layout.fragment_artist, this)
val tabs: TabLayout = findViewById(R.id.tabs)
val viewPager: ViewPager2 = findViewById(R.id.view_pager)
_textChannel = findViewById(R.id.text_channel_name)
_textChannelSub = findViewById(R.id.text_metadata)
_creatorThumbnail = findViewById(R.id.creator_thumbnail)
_imageBanner = findViewById(R.id.image_channel_banner)
_buttonSubscribe = findViewById(R.id.button_subscribe)
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings)
_overlayLoading = findViewById(R.id.channel_loading_overlay)
_overlayLoadingSpinner = findViewById(R.id.channel_loader_frag)
_overlayContainer = findViewById(R.id.overlay_container)
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
}
_buttonSubscribe.onUnSubscribed.subscribe {
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
}
_buttonSubscriptionSettings.setOnClickListener {
val url = channel?.contentUrl ?: _url ?: return@setOnClickListener
val sub =
StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer)
}
//TODO: Determine if this is really the only solution (isSaveEnabled=false)
viewPager.isSaveEnabled = false
viewPager.registerOnPageChangeCallback(_onPageChangeCallback)
val adapter = ArtistViewPagerAdapter(fragment, fragment.childFragmentManager, fragment.lifecycle)
adapter.onChannelClicked.subscribe { c -> fragment.navigate<ChannelFragment>(c) }
adapter.onContentClicked.subscribe { v, _ ->
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
}
is IPlatformPlaylist -> {
fragment.navigate<RemotePlaylistFragment>(v)
}
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
}
}
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 =
UISlideOverlays.showVideoOptionsOverlay(content, it)
}
}
adapter.onAddToQueueClicked.subscribe { content ->
if (content is IPlatformVideo) {
StatePlayer.instance.addToQueue(content)
}
}
adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
else
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
}
}
adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url)
}
adapter.onContentUrlClicked.subscribe { url, contentType ->
when (contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
else -> {}
}
}
adapter.onLongPress.subscribe { content ->
_overlayContainer.let {
if (content is IPlatformVideo) _slideUpOverlay =
UISlideOverlays.showVideoOptionsOverlay(content, it)
}
}
viewPager.adapter = adapter
val tabLayoutMediator = TabLayoutMediator(
tabs, viewPager, (viewPager.adapter as ArtistViewPagerAdapter)::getTabNames
)
tabLayoutMediator.attach()
_tabLayoutMediator = tabLayoutMediator
_tabs = tabs
_viewPager = viewPager
if (_selectedTabIndex != -1) {
selectTab(_selectedTabIndex)
}
setLoading(true)
}
fun selectTab(tab: ArtistTab) {
(_viewPager.adapter as ArtistViewPagerAdapter).getTabPosition(tab)
}
fun cleanup() {
_tabLayoutMediator.detach()
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback)
hideSlideUpOverlay()
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
}
fun onShown(parameter: Any?, isBack: Boolean) {
hideSlideUpOverlay()
_selectedTabIndex = -1
if (!isBack || _url == null) {
_imageBanner.setImageDrawable(null)
when (parameter) {
is String -> {
_buttonSubscribe.setSubscribeChannel(parameter)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
_url = parameter
val parsed = Uri.parse(parameter);
val idLong = parsed.lastPathSegment?.toLongOrNull();
if(idLong != null) {
val artist = StateLibrary.instance.getArtist(idLong) ?: return;
showArtist(artist);
}
}
is Artist -> {
showArtist(parameter)
_url = parameter.contentUrl
}
}
}
}
private fun selectTab(selectedTabIndex: Int) {
_selectedTabIndex = selectedTabIndex
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
}
private fun setLoading(isLoading: Boolean) {
if (_isLoading == isLoading) {
return
}
_isLoading = isLoading
if (isLoading) {
_overlayLoading.visibility = View.VISIBLE
(_overlayLoadingSpinner.drawable as Animatable?)?.start()
} else {
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
_overlayLoading.visibility = View.GONE
}
}
fun onBackPressed(): Boolean {
if (_slideUpOverlay != null) {
hideSlideUpOverlay()
return true
}
return false
}
private fun hideSlideUpOverlay() {
_slideUpOverlay?.hide(false)
_slideUpOverlay = null
}
private fun showArtist(channel: Artist) {
setLoading(false)
_fragment.topBar?.onShown(channel)
val buttons = arrayListOf<Pair<Int, ()->Unit>>();
_fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
withContext(Dispatchers.Main) {
buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(
SuggestionsFragmentData(
"", SearchType.VIDEO
)
)
})
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
}
_buttonSubscribe.visibility = GONE;
_buttonSubscriptionSettings.visibility = View.GONE
_textChannel.text = channel.name
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
var supportsPlaylists = false;
val playlistPosition = 1
// keep the current tab selected
if (_viewPager.currentItem >= playlistPosition) {
_viewPager.setCurrentItem(_viewPager.currentItem + 1, false)
}
(_viewPager.adapter as ArtistViewPagerAdapter).insert(
playlistPosition,
ArtistTab.ALBUMS
)
// sets the channel for each tab
for (fragment in _fragment.childFragmentManager.fragments) {
(fragment as IArtistTabFragment).setArtist(channel)
}
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
_viewPager.adapter!!.notifyDataSetChanged();
val artistThumbnail = channel.getThumbnailOrAlbum();
if(artistThumbnail != null) {
_creatorThumbnail.isVisible = true;
_creatorThumbnail.setThumbnail(channel.getThumbnailOrAlbum(), true, true);
Glide.with(_imageBanner)
.load(artistThumbnail)
.into(_imageBanner);
}
else {
_creatorThumbnail.isVisible = false;
Glide.with(_imageBanner).clear(_imageBanner);
}
this.channel = channel
}
companion object {
private const val TAG = "LibraryArtistFragmentsView";
}
}
enum class ArtistTab {
SONGS, ALBUMS
}
class ArtistViewPagerAdapter(private val fragment: LibraryArtistFragment, fragmentManager: FragmentManager, lifecycle: Lifecycle) :
FragmentStateAdapter(fragmentManager, lifecycle) {
private val _supportedFragments = mutableMapOf(
ArtistTab.SONGS.ordinal to ArtistTab.SONGS
)
private val _tabs = arrayListOf(ArtistTab.SONGS, ArtistTab.ALBUMS)
var artist: Artist? = null
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>()
val onAddToWatchLaterClicked = Event1<IPlatformContent>()
val onLongPress = Event1<IPlatformContent>()
override fun getItemId(position: Int): Long {
return _tabs[position].ordinal.toLong()
}
override fun containsItem(itemId: Long): Boolean {
return _supportedFragments.containsKey(itemId.toInt())
}
override fun getItemCount(): Int {
return _supportedFragments.size
}
fun getTabPosition(tab: ArtistTab): Int {
return _tabs.indexOf(tab)
}
fun getTabNames(tab: TabLayout.Tab, position: Int) {
tab.text = _tabs[position].name
}
fun insert(position: Int, tab: ArtistTab) {
_supportedFragments[tab.ordinal] = tab
_tabs.add(position, tab)
notifyItemInserted(position)
}
fun remove(position: Int) {
_supportedFragments.remove(_tabs[position].ordinal)
_tabs.removeAt(position)
notifyItemRemoved(position)
}
override fun createFragment(position: Int): Fragment {
val fragment: Fragment
when (_tabs[position]) {
ArtistTab.SONGS -> {
fragment = ChannelContentsFragment(this.fragment).apply {
}
}
ArtistTab.ALBUMS -> {
fragment = ArtistAlbumsFragment(this.fragment).apply {
}
}
}
artist?.let { (fragment as IArtistTabFragment).setArtist(it) }
return fragment
}
}
interface IArtistTabFragment {
fun setArtist(artist: Artist);
}
class ChannelContentsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment {
var view: ArtistContentView? = null;
private var _lastArtist: Artist? = null;
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
view = ArtistContentView(frag, inflater);
_lastArtist?.let {
view?.setArtist(it);
}
return view;
}
override fun onDestroyView() {
view = null;
super.onDestroyView()
}
override fun setArtist(artist: Artist) {
view?.setArtist(artist);
_lastArtist = artist;
}
}
class ArtistContentView : FeedView<LibraryArtistFragment, IPlatformContent, IPlatformVideo, IPager<IPlatformContent>, TrackViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
protected var _artist: Artist? = null;
constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater) {
}
fun setArtist(artist: Artist) {
this._artist = artist;
val tracks = artist.getAudioTracks();
if(tracks.getResults().isEmpty())
UIDialogs.appToast("No tracks found");
setPager(tracks);
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformVideo> {
return results.filter { it is IPlatformVideo }.map { it as IPlatformVideo };
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformVideo>): InsertedViewAdapterWithLoader<TrackViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = TrackViewHolder(viewGroup);
holder.onClick.subscribe { c ->
val playlist = _artist?.toPlaylist();
if (playlist != null) {
val sameVideo = playlist.videos.find { it.name == c.name };
val index = sameVideo?.let {
playlist.videos.indexOf(sameVideo)
} ?: -1;
if (index == -1)
return@subscribe;
StatePlayer.instance.setPlaylist(playlist, index, true);
}
};
holder.onOptions.subscribe {
if(it is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
override fun updateSpanCount(){ }
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class ArtistAlbumsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment {
var view: ArtistAlbumsView? = null;
private var _lastArtist: Artist? = null;
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
view = ArtistAlbumsView(frag, inflater);
_lastArtist?.let {
view?.setArtist(it);
}
return view;
}
override fun onDestroyView() {
view = null;
super.onDestroyView()
}
override fun setArtist(artist: Artist) {
view?.setArtist(artist);
_lastArtist = artist;
}
}
class ArtistAlbumsView : FeedView<LibraryArtistFragment, Album, Album, IPager<Album>, AlbumTileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater)
fun onShown() {
}
fun setArtist(artist: Artist) {
val initialAlbums = artist.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
setPager(AdhocPager({ listOf() }, initialAlbums));
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Album>): InsertedViewAdapterWithLoader<AlbumTileViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = AlbumTileViewHolder(viewGroup);
holder.setAutoSize(resources.displayMetrics.widthPixels / resources.displayMetrics.density)
holder.onClick.subscribe { c -> fragment.navigate<LibraryAlbumFragment>(c) };
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, AlbumTileViewHolder.getAutoSizeColumns(resources.displayMetrics.widthPixels / resources.displayMetrics.density))
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
}
@@ -0,0 +1,200 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LibraryTypeHeaderView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibraryArtistsFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _textMeta: TextView? = null;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryArtistsFragment().apply {}
}
class FragView : FeedView<LibraryArtistsFragment, Artist, Artist, IPager<Artist>, ArtistViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val libraryTypeHeader: LibraryTypeHeaderView;
constructor(fragment: LibraryArtistsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
libraryTypeHeader = LibraryTypeHeaderView(context);
libraryTypeHeader.setSelectedType(LibraryTypeHeaderView.SelectedType.Artists);
libraryTypeHeader.setMetadata("");
libraryTypeHeader.onSelectedChanged.subscribe {
when(it) {
LibraryTypeHeaderView.SelectedType.Albums -> fragment.navigate<LibraryAlbumsFragment>();
else -> {}
}
}
_toolbarContentView.addView(libraryTypeHeader);
disableRefreshLayout();
}
fun onShown() {
reload();
}
override fun reload() {
try {
setLoading(true);
val intialArtists = StateLibrary.instance.getArtists(ArtistOrdering.Alphabethic);
Logger.i(TAG, "Initial album count: " + intialArtists.size);
libraryTypeHeader.setMetadata("${intialArtists.size} artists");
setPager(AdhocPager<Artist>({ listOf(); }, intialArtists));
}
finally {
setLoading(false);
}
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Artist>): InsertedViewAdapterWithLoader<ArtistViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = ArtistViewHolder(viewGroup);
holder.onClick.subscribe { c ->
fragment.navigate<LibraryArtistFragment>(c)
};
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryArtistsFragmentsView";
}
}
class ArtistViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Artist>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_artist,
_viewGroup, false)) {
val onClick = Event1<Artist>();
protected var _artist: Artist? = null;
//protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
init {
//_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_view.setOnClickListener { _artist?.let { onClick.emit(it) } };
}
override fun bind(artist: Artist) {
_artist = artist;
/*
_imageThumbnail?.let {
if (artist.thumbnail != null)
Glide.with(it)
.load(artist.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(it)
else
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
};
*/
_textName.text = artist.name;
val metaComps = listOf(
artist.countTracks?.let { "${it} tracks" },
artist.countAlbums?.let { "${it} albums" }
).filterNotNull();
_textMetadata.text = metaComps.joinToString(", ");
}
}
}
@@ -0,0 +1,265 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.buttons.ButtonsContainer
class LibraryFilesFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter);
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryFilesFragment().apply {}
}
class FragView : FeedView<LibraryFilesFragment, FileEntry, FileEntry, IPager<FileEntry>, FileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val navStack = mutableListOf<FileStack>()
var buttonUp: BigButton? = null;
var buttonAdd: BigButton? = null;
private var root: FileEntry? = null;
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
disableRefreshLayout();
}
fun onShown(parameter: Any? = null) {
this.root = if(parameter is FileEntry) parameter else null;
loadTop();
}
fun loadTop() {
var initialDirectories = listOf<FileEntry>();
var path = "";
if(root == null) {
initialDirectories = StateLibrary.instance.getFileDirectories();
if (initialDirectories.size == 0) {
setEmptyPager(true);
setPager(EmptyPager());
buttonAdd?.let {
it.isVisible = false;
}
buttonUp?.let {
it.isVisible = false;
}
return;
} else
setEmptyPager(false);
}
else {
buttonAdd?.let {
it.isVisible = false;
}
buttonUp?.let {
it.isVisible = false;
}
initialDirectories = root?.getSubFiles() ?: listOf();
path = root?.path ?: "";
}
navStack.clear();
val entry = FileStack(path, initialDirectories);
navStack.add(entry);
openDirectory(navStack.last());
fragment.topBar?.let {
if(it is FilesTopBarFragment) {
it.setUpNavigate(null);
it.setTitle(entry);
}
}
}
fun leaveDirectory() {
if(navStack.size > 1) {
navStack.removeLast();
openDirectory(navStack.last());
}
else {}
}
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
if(addToStack)
navStack.add(stack);
fragment.topBar?.let {
if(it is FilesTopBarFragment) {
it.setTitle(stack);
}
}
buttonAdd?.let {
it.isVisible = navStack.size < 2
}
buttonUp?.let {
it.isVisible = navStack.size > 1;
}
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
setLoading(false);
val allSongs = stack.files.filter { !it.isDirectory };
if(allSongs.any()) {
_bottomContentView.addView(ButtonsContainer(context,
listOf(
ButtonsContainer.Button("Play All", R.drawable.background_button_primary) {
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
}), focus = true, shuffle = false)
},
ButtonsContainer.Button("Shuffle", R.drawable.background_button_accent) {
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
}), focus = true, shuffle = true)
}
)).apply {
this.layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
})
}
else
_bottomContentView.removeAllViews();
fragment.topBar?.let {
if(it is FilesTopBarFragment) {
if(navStack.size > 1)
it.setUpNavigate{
leaveDirectory();
};
else it.setUpNavigate(null);
it.setTitle(stack);
}
}
}
fun setBack() {
fragment.topBar?.view
}
override fun getEmptyPagerView(): View? {
return NoResultsView(context, "No Directories Added",
"To see files in Grayjay you have to add directories to view",
R.drawable.ic_library, listOf(
BigButton(context, "Add Directory", "Select a directory to add", R.drawable.ic_add, {
StateLibrary.instance.addFileDirectory({
loadTop();
}, true);
})
))
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<FileEntry>): InsertedViewAdapterWithLoader<FileViewHolder> {
/*
val buttonUp = BigButton(fragment.requireContext(), "Go up", "Go up a directory", R.drawable.ic_move_up) {
if(navStack.size > 1)
leaveDirectory();
}
val buttonAdd = BigButton(fragment.requireContext(), "Add Directory", "Select a directory to add", R.drawable.ic_add) {
StateLibrary.instance.addFileDirectory {
loadTop();
};
}
*/
//this.buttonUp = buttonUp;
//this.buttonAdd = buttonAdd;
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = FileViewHolder(viewGroup);
holder.onClick.subscribe { c ->
if (c != null) {
if(c.isDirectory) {
openDirectory(FileStack(c.path, c.getSubFiles()), true);
} else {
fragment.navigate<VideoDetailFragment>(c.path)
}
}
};
holder.onDelete.subscribe { c ->
if(c != null) {
StateLibrary.instance.deleteFileDirectory(c.path);
loadTop();
}
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class FileStack(
val path: String,
val files: List<FileEntry>
)
}
@@ -0,0 +1,393 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.collection.emptyLongSet
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.Dispatcher
class LibraryFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
private var allowedMusic = false;
private var allowedVideo = false;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = FragView(this, allowedMusic, allowedVideo);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
requestPermissionMusic();
requestPermissionVideo();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
fun setPermissionResultAudio(access: Boolean) {
allowedMusic = access;
view?.setMusicPermissions(access);
StateApp.instance.hasMediaStoreAudioPermission = (access);
}
fun setPermissionResultVideo(access: Boolean) {
allowedVideo = access;
view?.setVideoPermissions(access);
StateApp.instance.hasMediaStoreVideoPermission = (access);
}
fun requestPermissionMusic() {
when {
ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_GRANTED -> {
setPermissionResultAudio(true);
}
ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_AUDIO) -> {
UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
"Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1,
UIDialogs.Action("Ok", {
StateApp?.instance?.activity?.requestPermissionAudio {
setPermissionResultAudio(it);
}
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", {
}, UIDialogs.ActionStyle.NONE));
}
else -> {
StateApp?.instance?.activity?.requestPermissionAudio {
setPermissionResultAudio(it);
}
}
}
}
fun requestPermissionVideo() {
when {
ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED -> {
setPermissionResultVideo(true);
}
ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_VIDEO) -> {
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
"Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1,
UIDialogs.Action("Ok", {
StateApp?.instance?.activity?.requestPermissionVideo {
setPermissionResultVideo(it);
}
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", {
}, UIDialogs.ActionStyle.NONE));
}
else -> {
StateApp?.instance?.activity?.requestPermissionVideo {
setPermissionResultVideo(it);
}
}
}
}
companion object {
fun newInstance() = LibraryFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: LibraryFragment;
var sectionArtists: LibrarySection;
var sectionAlbums: LibrarySection;
var sectionVideos: LibrarySection;
var sectionFiles: LibrarySection;
var noContent: NoResultsView;
//var buttonFiles: BigButton;
val recycler: RecyclerView;
var adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>? = null;
//var metaInfo: TextView;
var allowMusic: Boolean = false;
var allowVideo: Boolean = false;
constructor(fragment: LibraryFragment, allowMusic: Boolean?, allowVideo: Boolean?) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library, this);
this.fragment = fragment;
recycler = findViewById(R.id.recycler);
sectionArtists = LibrarySection(context)//findViewById<LibrarySection>(R.id.section_artists);
sectionArtists.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 140.dp(resources)).apply {
this.setMargins(0,10.dp(resources), 0, 0);
}
sectionAlbums = LibrarySection(context)//findViewById(R.id.section_albums);
sectionAlbums.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 185.dp(resources)).apply {
this.setMargins(0,0, 0, 0);
}
sectionVideos = LibrarySection(context)//findViewById(R.id.section_videos);
sectionVideos.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 170.dp(resources)).apply {
this.setMargins(0,0, 0, 0);
}
sectionFiles = LibrarySection(context)//findViewById(R.id.section_videos);
sectionFiles.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 40.dp(resources)).apply {
this.setMargins(0,0, 0, 0);
}
sectionFiles.setSection("Directories") {
StateLibrary.instance.addFileDirectory({
reloadFiles();
}, true)
}
sectionFiles.setNavIcon(R.drawable.ic_add);
//buttonFiles = findViewById<BigButton>(R.id.button_files);
//metaInfo = findViewById(R.id.meta_info);
noContent = NoResultsView(context, "No directories", "No directories have been added.\nAdd them using the (+) icon.", -1, listOf());
noContent.isVisible = false;
this.allowMusic = allowMusic ?: false;
this.allowVideo = allowVideo ?: false;
sectionArtists.setSection("Artists", {
if(this.allowMusic)
fragment.navigate<LibraryArtistsFragment>();
else
fragment.requestPermissionMusic();
});
sectionAlbums.setSection("Albums", {
if(this.allowMusic)
fragment.navigate<LibraryAlbumsFragment>();
else
fragment.requestPermissionMusic();
});
sectionVideos.setSection("Videos", {
if(this.allowVideo)
fragment.navigate<LibraryVideosFragment>();
else
fragment.requestPermissionVideo();
});
reloadLibraryUI();
/*
buttonFiles.onClick.subscribe {
fragment.navigate<LibraryFilesFragment>()
} */
//buttonFiles.setButtonEnabled(false);
setMusicPermissions(allowMusic ?: false);
setVideoPermissions(allowVideo ?: false);
}
fun reloadFiles() {
val files = StateLibrary.instance.getFileDirectories();
adapterFiles?.setData(files);
if(files.size == 0) {
noContent.isVisible = true;
}
else
noContent.isVisible = false;
}
fun reloadLibraryUI() {
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryAlbumFragment>(it);
}
});
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryArtistFragment>(it);
}
});
val adapterVideos = sectionVideos.getAnyAdapter<IPlatformVideo, LocalVideoTileViewHolder>({
it.onClick.subscribe {
if(it != null)
fragment.navigate<VideoDetailFragment>(it);
}
});
if(this.allowMusic) {
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
adapterArtists.setData(artists);
if (artists.size == 0)
sectionArtists.setEmpty(
"No artists",
"No artists were found on your device",
-1
);
else
sectionArtists.clearEmpty();
}
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
sectionAlbums.isVisible = false;
}
else {
sectionArtists.setEmpty(
"No Music Permissions",
"You have not granted music access permissions to Grayjay",
-1
);
}
if(this.allowMusic) {
val albums = StateLibrary.instance.getAlbums();
adapterAlbums.setData(albums);
if (albums.size == 0)
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
else
sectionAlbums.clearEmpty();
}
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
sectionArtists.isVisible = false;
}
else {
sectionAlbums.setEmpty(
"No Music Permissions",
"You have not granted music access permissions to Grayjay",
-1
);
}
if(this.allowVideo) {
val videos = StateLibrary.instance.getRecentVideos(null, 20);
adapterVideos.setData(videos);
if (videos.size == 0)
sectionVideos.setEmpty("No videos", "No videos were found on your device", -1);
else
sectionVideos.clearEmpty();
}
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
sectionVideos.isVisible = false;
}
else {
sectionVideos.setEmpty(
"No Video Permissions",
"You have not granted video access permissions to Grayjay",
-1
);
}
adapterFiles = recycler.asAnyWithViews<FileEntry, FileViewHolder>(
arrayListOf(
sectionArtists,
sectionAlbums,
sectionVideos,
sectionFiles,
noContent
),
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryFilesFragment>(it);
}
it.onDelete.subscribe {
if(it != null) {
StateLibrary.instance.deleteFileDirectory(it.path);
reloadFiles();
}
}
}
);
reloadFiles();
}
fun setMusicPermissions(access: Boolean) {
allowMusic = access;
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
sectionArtists.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
//buttonArtists.setButtonEnabled(access);
//metaInfo.text = listOf(
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
//).filterNotNull().joinToString("\n");
fragment.lifecycleScope.launch(Dispatchers.Main) {
reloadLibraryUI();
}
}
fun setVideoPermissions(access: Boolean) {
allowVideo = access;
sectionVideos.setContentEmptyMessage(R.drawable.ic_library, "No video permissions");
//metaInfo.text = listOf(
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
//).filterNotNull().joinToString("\n");
// }
fragment.lifecycleScope.launch(Dispatchers.Main) {
reloadLibraryUI();
}
}
fun onShown() {
if(didShowAlpha)
return;
didShowAlpha = true;
UIDialogs.appToast("Library is in alpha\nImprovements are coming to local media playback.")
}
companion object {
var didShowAlpha: Boolean = false;
}
}
}
@@ -0,0 +1,233 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.LibraryTypeHeaderView
import com.futo.platformplayer.views.LibraryTypeHeaderView.SelectedType
import com.futo.platformplayer.views.PillV2
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibrarySearchFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibrarySearchFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: LibrarySearchFragment;
val pillArtist: PillV2;
val pillAlbums: PillV2;
val pillSongs: PillV2;
val pills: List<PillV2>;
val textMetadata: TextView;
val recycler: RecyclerView;
val adapterArtists: AnyAdapterView<Artist, LibraryArtistsFragment.ArtistViewHolder>;
val adapterSongs: AnyAdapterView<IPlatformContent, TrackViewHolder>;
val adapterAlbums: AnyAdapterView<Album, LibraryAlbumsFragment.AlbumViewHolder>;
constructor(fragment: LibrarySearchFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library_search, this);
this.fragment = fragment;
pillArtist = findViewById(R.id.pill_artist);
pillAlbums = findViewById(R.id.pill_albums);
pillSongs = findViewById(R.id.pill_songs);
pills = listOf(pillArtist, pillAlbums, pillSongs);
textMetadata = findViewById(R.id.text_metadata);
pillArtist.onClick.subscribe {
pills.forEach { it.setIsEnabled(false) };
pillArtist.setIsEnabled(true);
loadArtists();
}
pillAlbums.onClick.subscribe {
pills.forEach { it.setIsEnabled(false) };
pillAlbums.setIsEnabled(true);
loadAlbums();
}
pillSongs.onClick.subscribe {
pills.forEach { it.setIsEnabled(false) };
pillSongs.setIsEnabled(true);
loadSongs();
}
recycler = findViewById(R.id.recycler);
adapterArtists = recycler.asAny<Artist, LibraryArtistsFragment.ArtistViewHolder>(RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryArtistFragment>(it);
}
});
adapterAlbums = recycler.asAny<Album, LibraryAlbumsFragment.AlbumViewHolder>(RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryAlbumFragment>(it);
}
});
adapterSongs = recycler.asAny<IPlatformContent, TrackViewHolder>(RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null && it is IPlatformVideo)
fragment.navigate<VideoDetailFragment>(it);
}
});
fragment.topBar?.let {
if(it is SearchTopBarFragment) {
it.onSearch.subscribe {
search(it);
}
}
}
pillArtist.setIsEnabled(true);
loadArtists();
}
fun loadArtists(){
recycler.adapter = adapterArtists.adapter.adapter;
fragment.topBar?.let {
if(it is SearchTopBarFragment)
search(it.getSearchText());
}
}
fun loadAlbums() {
recycler.adapter = adapterAlbums.adapter.adapter;
fragment.topBar?.let {
if(it is SearchTopBarFragment)
search(it.getSearchText());
}
}
fun loadSongs() {
recycler.adapter = adapterSongs.adapter.adapter;
fragment.topBar?.let {
if(it is SearchTopBarFragment)
search(it.getSearchText());
}
}
fun search(str: String) {
if(recycler.adapter == adapterArtists.adapter.adapter) {
val data = if(!str.isNullOrBlank())
StateLibrary.instance.searchArtists(str)
else listOf();
adapterArtists.setData(data);
textMetadata.text = "${data.size} artists";
}
else if(recycler.adapter == adapterAlbums.adapter.adapter) {
val data = if(!str.isNullOrBlank())
StateLibrary.instance.searchAlbums(str)
else listOf();
adapterAlbums.setData(data);
textMetadata.text = "${data.size} albums";
}
else if(recycler.adapter == adapterSongs.adapter.adapter) {
val data = if(!str.isNullOrBlank())
StateLibrary.instance.searchTracks(str)
else listOf();
adapterSongs.setData(data);
textMetadata.text = "${data.size} songs";
}
}
fun onShown() {
fragment.topBar?.let {
if(it is SearchTopBarFragment)
it.focus();
}
}
}
}
@@ -0,0 +1,170 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.core.view.allViews
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibraryVideosFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _toggleBuckets = StateLibrary.instance.getVideoBucketNames().map { it.name }.toMutableList();
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryVideosFragment().apply {}
}
class FragView : ContentFeedView<LibraryVideosFragment> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
private var _toggleBar: ToggleBar? = null;
constructor(fragment: LibraryVideosFragment, inflater: LayoutInflater) : super(fragment, inflater) {
initializeToolbarContent();
disableRefreshLayout();
}
fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
val buckets = StateLibrary.instance.getVideoBucketNames();
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
}
private val _filterLock = Object();
fun initializeToolbarContent() {
if(_toolbarContentView.allViews.any { it is ToggleBar })
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
_toggleBar = ToggleBar(context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
synchronized(_filterLock) {
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
buttonsPlugins =
(StateLibrary.instance.getVideoBucketNames()
.map { bucket ->
ToggleBar.Toggle(bucket.name, null, fragment._toggleBuckets.contains(bucket.name), { view, active ->
var dontSwap = false;
if (!active) {
if (fragment._toggleBuckets.contains(bucket.name))
fragment._toggleBuckets.remove(bucket.name);
} else {
if (!fragment._toggleBuckets.contains(bucket.name)) {
val enabledClients = StatePlatform.instance.getEnabledClients();
val availableAfterDisable = enabledClients.count { !fragment._toggleBuckets.contains(it.id) && it.id != bucket.name };
if(availableAfterDisable > 0)
fragment._toggleBuckets.add(bucket.name);
else {
UIDialogs.appToast("Select atleast 1 bucket");
dontSwap = true;
}
}
}
if(!dontSwap)
reloadForFilters();
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")
})
val buttons = (buttonsPlugins)
.sortedBy { it.name }.toTypedArray()
_toggleBar?.setToggles(*buttons);
}
_toolbarContentView.addView(_toggleBar, 0);
}
fun reloadForFilters() {
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
}
override fun updateSpanCount(){ }
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
}
@@ -0,0 +1,160 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.widget.ImageButton
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
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.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.text.matches
class LoginFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter ?: throw IllegalArgumentException("No parameter for login"));
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LoginFragment().apply {}
private var _callback: ((SourceAuth?) -> Unit)? = null;
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
if(_callback != null) _callback?.invoke(null);
_callback = callback;
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
}
}
class FragView: ConstraintLayout {
val fragment: LoginFragment;
private val _webView: WebView;
private val _textUrl: TextView;
private val _buttonClose: ImageButton;
constructor(fragment: LoginFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.activity_login, this);
this.fragment = fragment;
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
UIDialogs.toast("Login cancelled", false);
fragment.close(true);
}
_webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true);
}
fun onShown(parameter: Any) {
val config = parameter as? SourcePluginConfig;
val authConfig = if(config != null)
config.authentication ?: throw IllegalStateException("Plugin has no authentication support");
else if(parameter is SourcePluginAuthConfig)
parameter
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
_callback = null;
it.invoke(auth);
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
fragment.close(true);
}catch (ex: Throwable) {
Logger.e(TAG, "Failed to close login", ex);
}
}
};
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(context, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
}
_webView.settings.domStorageEnabled = true;
_webView.webViewClient = webViewClient;
_webView.loadUrl(authConfig.loginUrl);
}
companion object {
private val TAG = "LoginFragment";
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
}
}
}
@@ -0,0 +1,52 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.Spinner
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
class RecyclerFragment : MainFragment(){
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: View? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = RecyclerFragment.View(inflater.context);
view = newView;
return newView;
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = RecyclerFragment().apply {}
}
class View: ConstraintLayout {
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.fragview_filter_recycler, this);
}
}
}
@@ -0,0 +1,184 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.app.NotificationManager
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.assume
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
class SettingsFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_currentView = view;
view?.onShown(parameter);
}
override fun onHide() {
super.onHide();
onClosed.emit();
}
override fun onDestroyMainView() {
view = null;
_currentView = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = SettingsFragment().apply {}
val onClosed = Event0();
private var _currentView: FragView? = null;
val currentView: FragView?
get() = _currentView;
}
class FragView: ConstraintLayout {
val fragment: SettingsFragment;
private val _form: FieldForm;
private val _buttonBack: ImageButton;
private val _loaderView: LoaderView;
private val _devSets: LinearLayout;
private val _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
constructor(fragment: SettingsFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.activity_settings, this);
this.fragment = fragment;
val activity = fragment.activity;
findViewById<LinearLayout>(R.id.container_topbar).isVisible = false;
_form = findViewById(R.id.settings_form);
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loaderView = findViewById(R.id.loader);
overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, _ ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues();
Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(context, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update" && activity is MainActivity) {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(context, "Notifications aren't enabled");
activity.requestNotificationPermissions("Notifications need to be enabled for background updating to function")
}
}
}
};
_buttonBack.setOnClickListener {
//finish();
}
_buttonDev.setOnClickListener {
//startActivity(Intent(this, DeveloperActivity::class.java));
fragment.navigate<DeveloperFragment>(null, true);
}
//_lastActivity = this;
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(fragment.lifecycleScope, Settings.instance) {
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(context, fragment.getString(R.string.you_are_now_in_developer_mode));
}
};
/*
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}*/
};
}
fun onShown(str: Any? = null) {
updateDevMode();
if(str is String)
_form.setSearchQuery(str);
}
fun updateDevMode() {
if(SettingsDev.instance.developerMode)
_devSets.visibility = View.VISIBLE;
else
_devSets.visibility = View.GONE;
}
}
}
@@ -1,5 +1,8 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
@@ -32,9 +35,11 @@ import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.buttons.BigButtonGroup
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.sources.SourceHeaderView
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class SourceDetailFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -415,12 +420,40 @@ class SourceDetailFragment : MainFragment() {
}
val advancedButtons = BigButtonGroup(c, "Advanced",
BigButton(c, "Reset Settings", "Resets the settings to their default (deleting existing settings)", R.drawable.ic_refresh) {
_config?.let {
StatePlugins.instance.setPluginSettings(it.id, hashMapOf());
loadConfig(it)
}
},
BigButton(c, "Share Settings", "Shares the settings of this plugin as json, mostly used for bug reporting", R.drawable.ic_code) {
val structure = Json { this.prettyPrint = true; this.prettyPrintIndent = " " }
.encodeToString(_settings);
fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, structure);
type = "text/plain";
}, null));
/*
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Settings Json", structure)
clipboard.setPrimaryClip(clip)
UIDialogs.toast(context, "Copied", false);
*/
}.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
} ,
/*
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
}.apply {
this.alpha = 0.5f;
},
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
},*/
if(isEmbedded) BigButton(c, "Reinstall", "Reinstall the original version that was embedded with this version of Grayjay", R.drawable.ic_refresh) {
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
@@ -434,7 +467,29 @@ class SourceDetailFragment : MainFragment() {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
} else null
} else
BigButton(c, "Reinstall", "Reinstall the current version from the remote repository", R.drawable.ic_refresh) {
var newConfig: SourcePluginConfig? = null;
try {
newConfig = StatePlugins.instance.requestConfig(config?.sourceUrl ?: throw IllegalArgumentException("No config"));
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to fetch new plugin config", ex);
}
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>)?",
"This will revert the plugin back to the originally embedded version.\nVersion change: ${config.version}=>${newConfig?.version}", null,
0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Reinstall", {
val url = config.sourceUrl ?: return@Action;
StatePlugins.instance.installPlugin(context, fragment.lifecycleScope, url) {
reloadSource(config.id);
UIDialogs.toast(context, "Plugin reinstalled, may require refresh");
}
}, UIDialogs.ActionStyle.DANGEROUS));
}.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
)
_sourceAdvancedButtons.removeAllViews();
@@ -453,7 +508,7 @@ class SourceDetailFragment : MainFragment() {
config.authentication.loginWarning, null, 0,
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Login", {
LoginActivity.showLogin(StateApp.instance.context, config) {
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
try {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
@@ -467,7 +522,7 @@ class SourceDetailFragment : MainFragment() {
}, UIDialogs.ActionStyle.PRIMARY))
}
else
LoginActivity.showLogin(StateApp.instance.context, config) {
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
try {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
@@ -24,7 +24,6 @@ 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.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.StateCasting
@@ -401,9 +400,10 @@ class VideoDetailFragment() : MainFragment() {
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
maximizeVideoDetail();
/*
SettingsActivity.settingsActivityClosed.subscribe(this) {
updateOrientation()
}
} */
StatePlayer.instance.onRotationLockChanged.subscribe(this) {
updateOrientation()
@@ -547,7 +547,7 @@ class VideoDetailFragment() : MainFragment() {
super.onDestroyMainView();
Logger.v(TAG, "onDestroyMainView");
SettingsActivity.settingsActivityClosed.remove(this)
//SettingsActivity.settingsActivityClosed.remove(this)
StatePlayer.instance.onRotationLockChanged.remove(this)
_landscapeOrientationListener?.disableListener()
@@ -55,6 +55,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
@@ -77,6 +78,7 @@ 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.models.video.LocalVideoDetails
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
@@ -175,6 +177,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import userpackage.Protocol
import java.time.OffsetDateTime
import java.util.Locale
@@ -563,6 +566,18 @@ class VideoDetailView : ConstraintLayout {
if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener
}
if(video is LocalVideoDetails) {
video?.author?.let {
if(it.url.startsWith("content://media/external/audio/artists")) {
fragment.navigate<LibraryArtistFragment>(it.url);
fragment.lifecycleScope.launch {
delay(100);
fragment.minimizeVideoDetail();
};
}
}
return@setOnClickListener;
}
(video?.author ?: _searchVideo?.author)?.let {
fragment.navigate<ChannelFragment>(it);
@@ -625,6 +640,11 @@ class VideoDetailView : ConstraintLayout {
}
_player.onSourceChanged.subscribe(::onSourceChanged);
_player.onSourceEnded.subscribe {
if (_isCasting) {
Logger.i(TAG, "Ignoring onSourceEnded because casting is active")
return@subscribe
}
if (!fragment.isInPictureInPicture) {
_player.gestureControl.showControls(false);
}
@@ -704,6 +724,7 @@ class VideoDetailView : ConstraintLayout {
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
@@ -1035,7 +1056,7 @@ class VideoDetailView : ConstraintLayout {
_slideUpOverlay?.hide();
}
else null,
if(!isLimitedVersion && !(video?.isLive ?: false))
if(!isLimitedVersion && !(video?.isLive ?: false) && !(video is LocalVideoDetails))
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
@@ -1058,15 +1079,16 @@ class VideoDetailView : ConstraintLayout {
_slideUpOverlay?.hide();
}
else null,
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
if(!(video is LocalVideoDetails))
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
fragment.navigate<BrowserFragment>(url);
fragment.minimizeVideoDetail();
};
_slideUpOverlay?.hide();
},
if (StateSync.instance.hasAuthorizedDevice()) {
} else null,
if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) {
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
val devices = StateSync.instance.getAuthorizedSessions();
val videoToSend = video ?: return@RoundButton;
@@ -1089,10 +1111,11 @@ class VideoDetailView : ConstraintLayout {
})
}
}} else null,
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
if(!(video is LocalVideoDetails))
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo();
_slideUpOverlay?.hide();
}).filterNotNull();
} else null).filterNotNull();
if(!_buttonPinStore.getAllValues().any())
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
else {
@@ -1327,7 +1350,22 @@ class VideoDetailView : ConstraintLayout {
return;
//Loop workaround
if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) {
_player.seekTo(0);
Log.i(TAG, "Loop")
if (_isCasting) {
Log.i(TAG, "Loop casting")
StateCasting.instance.activeDevice?.seekTo(0.0)
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
delay(300)
StateCasting.instance.activeDevice?.resumePlayback()
} catch (e: Throwable) {
Log.e(TAG, "Failed to resume", e)
}
}
} else {
Log.i(TAG, "Loop player")
_player.seekTo(0);
}
return;
}
@@ -1354,6 +1392,8 @@ class VideoDetailView : ConstraintLayout {
_minimize_title.text = video.name;
_minimize_meta.text = video.author.name;
StatePlayer.instance.setCurrentlyPlaying(video);
Log.i(TAG, "setCurrentlyPlaying (setVideoOverview) ${video.url} (${video.name})")
val subTitleSegments : ArrayList<String> = ArrayList();
if(video.viewCount > 0)
@@ -1623,7 +1663,9 @@ class VideoDetailView : ConstraintLayout {
_buttonSubscribe.setSubscribeChannel(video.author.url);
setDescription(video.description.fixHtmlLinks());
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
_creatorThumbnail.setThumbnail(video.author.thumbnail, false,
video is LocalVideoDetails
);
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
@@ -1651,7 +1693,7 @@ class VideoDetailView : ConstraintLayout {
_rating.visibility = View.GONE;
if (StatePolycentric.instance.enabled) {
if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(
@@ -1711,7 +1753,9 @@ class VideoDetailView : ConstraintLayout {
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
fragment.lifecycleScope.launch(Dispatchers.Main) {
_rating.visibility = View.GONE;
}
}
}
}
@@ -1776,7 +1820,8 @@ class VideoDetailView : ConstraintLayout {
false,
(toResume.toFloat() / 1000.0f).toLong(),
null,
true
true,
StatePlayer.instance.playlistId
);
Logger.i(
TAG,
@@ -1788,6 +1833,7 @@ class VideoDetailView : ConstraintLayout {
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
Log.i(TAG, "setCurrentlyPlaying (nextVideo) ${video.url} (${video.name})")
StatePlayer.instance.setCurrentlyPlaying(video);
_liveChat?.stop();
@@ -1809,17 +1855,19 @@ class VideoDetailView : ConstraintLayout {
_player.updateNextPrevious();
updateMoreButtons();
if (videoDetail is TutorialFragment.TutorialVideo) {
if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) {
_buttonSubscribe.visibility = View.GONE
_buttonMore.visibility = View.GONE
_buttonPins.visibility = View.GONE
_buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
_buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
_layoutRating.visibility = View.GONE
_rating.visibility = View.GONE;
_layoutChangeBottomSection.visibility = View.GONE
} else {
_buttonSubscribe.visibility = View.VISIBLE
_buttonMore.visibility = View.VISIBLE
_buttonPins.visibility = View.VISIBLE
_layoutRating.visibility = View.VISIBLE
_rating.visibility = View.VISIBLE;
_layoutChangeBottomSection.visibility = View.VISIBLE
}
@@ -2286,6 +2334,8 @@ class VideoDetailView : ConstraintLayout {
checkAndRemoveWatchLater();
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
Log.i(TAG, "next queue item ${next?.url} (${next?.name})")
val autoplayVideo = _autoplayVideo
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
Logger.i(TAG, "Found autoplay video!")
@@ -2298,11 +2348,14 @@ class VideoDetailView : ConstraintLayout {
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
if(next != null) {
Logger.i(TAG, "Set video overview (next = ${next.url} (${next.name}))")
setVideoOverview(next, true, 0, true);
return true;
}
else
else {
Log.i(TAG, "setCurrentlyPlaying (nextVideo) null")
StatePlayer.instance.setCurrentlyPlaying(null);
}
return false;
}
@@ -2683,7 +2736,11 @@ class VideoDetailView : ConstraintLayout {
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
video?.let {
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
if(video is LocalVideoDetails) {
_commentsList.clearComments();
}
else
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
}
}
private fun fetchPolycentricComments() {
@@ -2970,6 +3027,7 @@ class VideoDetailView : ConstraintLayout {
}
onChannelClicked.subscribe {
Logger.i(TAG, "Opening channel url: ${it.url}");
if(it.url.isNotBlank()) {
fragment.minimizeVideoDetail()
fragment.navigate<ChannelFragment>(it)
@@ -3094,7 +3152,7 @@ class VideoDetailView : ConstraintLayout {
if (v !is TutorialFragment.TutorialVideo) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val history = getHistoryIndex(v) ?: return@launch;
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true);
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true, StatePlayer.instance.playlistId);
}
}
_lastPositionSaveTime = currentTime;
@@ -20,6 +20,7 @@ import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.assume
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists
@@ -194,22 +195,35 @@ abstract class VideoListEditorView : LinearLayout {
_textMetadata.text = parts.joinToString("");
}
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
if (videos != null && videos.isNotEmpty()) {
val video = videos.first();
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean, thumbnail: String? = null) {
if(thumbnail != null) {
_imagePlaylistThumbnail.let {
Glide.with(it)
.load(video.thumbnails.getHQThumbnail())
.load(thumbnail)
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(it);
};
} else {
_textMetadata.text = "0 " + context.getString(R.string.videos);
Glide.with(_imagePlaylistThumbnail)
.load(R.drawable.placeholder_video_thumbnail)
.into(_imagePlaylistThumbnail)
}
}
else {
if (videos != null && videos.isNotEmpty()) {
val video = videos.first();
_imagePlaylistThumbnail.let {
Glide.with(it)
.load(video.thumbnails.getHQThumbnail())
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(it);
};
} else {
Glide.with(_imagePlaylistThumbnail)
.load(R.drawable.placeholder_video_thumbnail)
.into(_imagePlaylistThumbnail)
}
}
if(videos == null || videos.isEmpty())
_textMetadata.text = "0 " + context.getString(R.string.videos);
_loadedVideos = videos;
_loadedVideosCanEdit = canEdit;
_videoListEditorView.setVideos(videos, canEdit);
@@ -0,0 +1,129 @@
package com.futo.platformplayer.fragment.mainactivity.topbar
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.views.casting.CastButton
import com.futo.polycentric.core.PolycentricProfile
class FilesTopBarFragment : TopFragment() {
private var _buttonBack: ImageButton? = null;
private var _buttonCast: CastButton? = null;
private var _textTitle: TextView? = null;
private var _menuItems: LinearLayout? = null;
private var _upHandle: (()->Unit)? = null;
override fun onShown(parameter: Any?) {
setTitle(parameter);
setMenuItems(listOf());
}
override fun onHide() {
}
fun setTitle(parameter: Any? = null) {
if(parameter is IPlatformChannel) {
_textTitle?.text = parameter.name;
} else if(parameter is PlatformAuthorLink) {
_textTitle?.text = parameter.name;
} else if (parameter is Playlist) {
_textTitle?.text = parameter.name;
} else if (parameter is String) {
_textTitle?.text = parameter;
} else if (parameter is IPlatformClient) {
_textTitle?.text = parameter.name;
} else if (parameter is PolycentricProfile) {
_textTitle?.text = parameter.systemState.username;
} else if(parameter is FileEntry) {
val treePrefix = "content://com.android.externalstorage.documents/tree/";
if(parameter.path.startsWith(treePrefix)) {
_textTitle?.text = parameter.path.substring(treePrefix.length - 1).replace("%3A", " ").replace("%2F", "/");
}
else if(parameter.path.isNullOrBlank())
_textTitle?.text = parameter.name;
else
_textTitle?.text = parameter.path;
}
else if(parameter is LibraryFilesFragment.FileStack) {
val treePrefix = "content://com.android.externalstorage.documents/tree/";
if(parameter.path.startsWith(treePrefix)) {
_textTitle?.text = parameter.path.substring(treePrefix.length - 1).replace("%3A", " ").replace("%2F", "/");
}
else
_textTitle?.text = parameter.path;
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_files_top_bar, container, false);
val buttonBack: ImageButton = view.findViewById(R.id.button_back);
_textTitle = view.findViewById(R.id.text_title);
_menuItems = view.findViewById(R.id.menu_buttons)
buttonBack.setOnClickListener {
if(_upHandle != null)
_upHandle?.invoke();
else
closeSegment();
};
_buttonBack = buttonBack;
return view;
}
fun setUpNavigate(handle: (()->Unit)? = null) {
_upHandle = handle;
_buttonBack?.setImageResource(if(handle == null) R.drawable.ic_back_nav else R.drawable.ic_arrow_up);
}
override fun onDestroyView() {
super.onDestroyView()
_buttonBack?.setOnClickListener(null);
_buttonBack = null;
_buttonCast?.cleanup();
_buttonCast = null;
_textTitle = null;
}
fun setMenuItems(items: List<Pair<Int, ()->Unit>>) {
_menuItems?.removeAllViews();
val dp4 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics).toInt();
val dp9 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 9f, resources.displayMetrics).toInt();
for(item in items) {
val compatImageItem = AppCompatImageView(requireContext());
compatImageItem.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT);
compatImageItem.setImageResource(item.first);
compatImageItem.setPadding(dp4, dp9, dp4, dp9);
compatImageItem.scaleType = ImageView.ScaleType.FIT_CENTER;
compatImageItem.setOnClickListener {
item.second.invoke();
};
_menuItems?.addView(compatImageItem);
}
}
companion object {
fun newInstance() = FilesTopBarFragment().apply { }
}
}
@@ -9,6 +9,8 @@ import android.widget.ImageView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
@@ -46,6 +48,8 @@ class GeneralTopBarFragment : TopFragment() {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
} else if (currentMain is LibraryFragment) {
navigate<LibrarySearchFragment>();
} else {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
}
@@ -18,6 +18,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
import com.futo.platformplayer.logging.Logger
@@ -112,7 +113,10 @@ class SearchTopBarFragment : TopFragment() {
}
fun clear() {
_editSearch?.text?.clear();
if (currentMain !is SuggestionsFragment) {
if(currentMain is LibrarySearchFragment) {
onSearch.emit("");
}
else if (currentMain !is SuggestionsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
} else {
onSearch.emit("");
@@ -190,6 +194,12 @@ class SearchTopBarFragment : TopFragment() {
_buttonFilter?.visibility = if (visible) View.VISIBLE else View.GONE;
}
fun getSearchText(): String {
return _editSearch?.let {
it.text.toString();
} ?: "";
}
private fun onDone() {
val editSearch = _editSearch
if (editSearch != null) {
@@ -29,7 +29,6 @@ class GlideHelper {
req.into(this);
}
fun RequestBuilder<Drawable>.crossfade(): RequestBuilder<Drawable> {
return this.transition(DrawableTransitionOptions.withCrossFade());
}
@@ -1,11 +1,14 @@
package com.futo.platformplayer.images;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
import java.io.InputStream;
import java.nio.ByteBuffer;
@GlideModule
@@ -14,5 +17,8 @@ public class GrayjayAppGlideModule extends AppGlideModule {
public void registerComponents(Context context, Glide glide, Registry registry) {
Log.i("GrayjayAppGlideModule", "registerComponents called");
registry.prepend(String.class, ByteBuffer.class, new PolycentricModelLoader.Factory());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
registry.prepend(String.class, InputStream.class, new MediaStoreThumbnailLoader.InputStreamFactory());
}
}
}
@@ -0,0 +1,74 @@
package com.futo.platformplayer.images
import android.content.ContentResolver
import android.graphics.Point
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.LocalUriFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import com.futo.platformplayer.states.StateApp
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
@RequiresApi(Build.VERSION_CODES.Q)
class MediaStoreThumbnailLoader private constructor() : ModelLoader<String, InputStream> {
override fun handles(model: String): Boolean = isMediaStoreAudioUri(model)
private fun isMediaStoreAudioUri(uri: String): Boolean {
try {
val parsed = Uri.parse(uri);
return ContentResolver.SCHEME_CONTENT == parsed.scheme
&& MediaStore.AUTHORITY == parsed.authority
&& "audio" in parsed.pathSegments
}
catch(ex: MalformedURLException) {
return false;
}
}
override fun buildLoadData(model: String, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
val diskCacheKey = ObjectKey(model)
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return null;
val fetcher = InputStreamFetcher(resolver, Uri.parse(model), width, height)
return ModelLoader.LoadData(diskCacheKey, fetcher)
}
class InputStreamFactory() : ModelLoaderFactory<String, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, InputStream> = MediaStoreThumbnailLoader()
override fun teardown() {
// Do nothing.
}
}
private class InputStreamFetcher(resolver: ContentResolver, uri: Uri, private val width: Int, private val height: Int) : LocalUriFetcher<InputStream>(resolver, uri) {
override fun getDataClass(): Class<InputStream> = InputStream::class.java
@Throws(FileNotFoundException::class)
override fun loadResource(uri: Uri, contentResolver: ContentResolver): InputStream {
val optimalSizeOptions = Bundle(1)
optimalSizeOptions.putParcelable(ContentResolver.EXTRA_SIZE, Point(width, height))
return contentResolver.openTypedAssetFile(uri, "image/*", optimalSizeOptions, null)
?.createInputStream()
?: throw FileNotFoundException("FileDescriptor is null for: $uri")
}
@Throws(IOException::class)
override fun close(data: InputStream) {
data.close()
}
}
}
@@ -14,15 +14,17 @@ import java.time.ZoneOffset
class HistoryVideo {
var video: SerializedPlatformVideo;
var position: Long;
var playlistId: String? = null
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var date: OffsetDateTime;
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime) {
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime, playlistId: String?) {
this.video = video;
this.position = position;
this.date = date;
this.playlistId = playlistId
}
@@ -59,7 +61,7 @@ class HistoryVideo {
viewCount = -1
);
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC), null);
}
}
}
@@ -12,5 +12,6 @@ data class Telemetry(
val brand: String,
val manufacturer: String,
val model: String,
val sdkVersion: Int
val sdkVersion: Int,
val plugins: List<String>? = null
) { }
@@ -29,14 +29,25 @@ class HLS {
val mediaRenditions = mutableListOf<MediaRendition>()
val sessionDataList = mutableListOf<SessionData>()
var independentSegments = false
var version: Int? = null
var mediaSequence: Long? = null
val unhandled = mutableListOf<String>()
masterPlaylistContent.lines().forEachIndexed { index, line ->
val lines = masterPlaylistContent.lines()
lines.forEachIndexed { index, line ->
when {
line.startsWith("#EXT-X-VERSION:") -> {
version = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
mediaSequence = line.substringAfter(":").toLongOrNull()
}
line.startsWith("#EXT-X-STREAM-INF") -> {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
val nextLine = lines.getOrNull(index + 1)
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
val url = resolveUrl(baseUrl, nextLine)
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
}
@@ -52,10 +63,14 @@ class HLS {
val sessionData = parseSessionData(line)
sessionDataList.add(sessionData)
}
else -> {
unhandled.add(line)
}
}
}
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments, version = version, mediaSequence = mediaSequence, unhandled = unhandled)
}
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
@@ -83,62 +98,189 @@ class HLS {
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
}
private fun parseByteRange(value: String): Pair<Long, Long> {
val trimmed = value.trim()
require(trimmed.isNotEmpty()) { "Empty BYTERANGE value" }
val parts = trimmed.split('@')
val length = parts[0].toLong()
require(length >= 0) { "Invalid BYTERANGE length '$value'" }
val start = if (parts.size > 1) {
val s = parts[1].toLong()
require(s >= 0) { "Invalid BYTERANGE offset '$value'" }
s
} else {
-1L
}
return length to start
}
private fun parseAttributes(content: String): Map<String, String> {
val index = content.indexOf(':')
if (index < 0 || index == content.length - 1) return emptyMap()
val attributes = mutableMapOf<String, String>()
val maybeAttributePairs = content.substring(index + 1).splitToSequence(',')
var currentPair = StringBuilder()
for (pair in maybeAttributePairs) {
currentPair.append(pair)
if (currentPair.count { it == '\"' } % 2 == 0) {
val full = currentPair.toString()
val key = full.substringBefore("=")
val value = full.substringAfter("=")
attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder()
} else {
currentPair.append(',')
}
}
return attributes
}
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString()
val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
}
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
val keyInfo =
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
val iv =
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
val decryptionInfo: DecryptionInfo? = key?.let { k ->
DecryptionInfo(k, iv)
}
val initSegment =
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
?.substringAfter("=")?.trim('"')
var version: Int? = null
var targetDuration: Int? = null
var mediaSequence: Long? = null
var discontinuitySequence: Int? = null
var programDateTime: ZonedDateTime? = null
var playlistType: String? = null
var streamInfo: StreamInfo? = null
var decryptionInfo: DecryptionInfo? = null
var mapUrl: String? = null
var mapBytesStart: Long = -1
var mapBytesLength: Long = -1
val segments = mutableListOf<Segment>()
if (initSegment != null) {
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
}
val unhandled = mutableListOf<String>()
var currentSegment: MediaSegment? = null
lines.forEach { line ->
for (rawLine in lines) {
val line = rawLine.trim()
if (line.isEmpty()) continue
when {
line.startsWith("#EXT-X-VERSION:") -> {
version = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-TARGETDURATION:") -> {
targetDuration = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
mediaSequence = line.substringAfter(":").toLongOrNull()
}
line.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") -> {
discontinuitySequence = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-PROGRAM-DATE-TIME:") -> {
programDateTime = ZonedDateTime.parse(
line.substringAfter(":"),
DateTimeFormatter.ISO_DATE_TIME
)
}
line.startsWith("#EXT-X-PLAYLIST-TYPE:") -> {
playlistType = line.substringAfter(":")
}
line.startsWith("#EXT-X-STREAM-INF:") -> {
streamInfo = parseStreamInfo(line)
}
line.startsWith("#EXT-X-KEY:") -> {
val attrs = parseAttributes(line)
val method = attrs["METHOD"]?.ifEmpty { "AES-128" } ?: "AES-128"
val keyUri = attrs["URI"]?.removeSurrounding("\"")
val keyUrl = keyUri?.let { resolveUrl(baseUrl, it) }
val ivRaw = attrs["IV"]
val iv = ivRaw
?.removePrefix("0x")
?.removePrefix("0X")
val keyFormat = attrs["KEYFORMAT"]
val keyFormatVersions = attrs["KEYFORMATVERSIONS"]
decryptionInfo = DecryptionInfo(method, keyUrl, iv, keyFormat, keyFormatVersions)
}
line.startsWith("#EXT-X-MAP:") -> {
val attrs = parseAttributes(line)
attrs["URI"]?.let { uri ->
mapUrl = resolveUrl(baseUrl, uri)
}
attrs["BYTERANGE"]?.let { br ->
val (len, start) = parseByteRange(br)
mapBytesLength = len
mapBytesStart = start
}
}
line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
?: throw Exception("Invalid segment duration format")
val durationText = line.substringAfter(":").substringBefore(",")
val duration = durationText.toDoubleOrNull()
?: throw IllegalArgumentException("Invalid segment duration: '$line'")
currentSegment = MediaSegment(duration = duration)
}
line == "#EXT-X-DISCONTINUITY" -> {
segments.add(DiscontinuitySegment())
}
line =="#EXT-X-ENDLIST" -> {
line == "#EXT-X-ENDLIST" -> {
segments.add(EndListSegment())
}
else -> {
currentSegment != null && line.startsWith("#EXT-X-BYTERANGE:") -> {
val br = line.substringAfter(":").trim()
val (len, start) = parseByteRange(br)
currentSegment!!.bytesLength = len
currentSegment!!.bytesStart = start
}
currentSegment != null && line.startsWith("#") -> {
currentSegment!!.unhandled.add(line)
}
!line.startsWith("#") -> {
currentSegment?.let {
it.uri = resolveUrl(sourceUrl, line)
it.uri = resolveUrl(baseUrl, line)
segments.add(it)
currentSegment = null
} ?: run {
unhandled.add(line)
}
currentSegment = null
}
else -> {
unhandled.add(line)
}
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
return VariantPlaylist(
version = version,
targetDuration = targetDuration,
mediaSequence = mediaSequence,
discontinuitySequence = discontinuitySequence,
programDateTime = programDateTime,
playlistType = playlistType,
streamInfo = streamInfo,
segments = segments,
decryptionInfo = decryptionInfo,
mapUrl = mapUrl,
mapBytesStart = mapBytesStart,
mapBytesLength = mapBytesLength,
unhandled = unhandled
)
}
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
@@ -232,26 +374,6 @@ class HLS {
return SessionData(dataId, value)
}
private fun parseAttributes(content: String): Map<String, String> {
val attributes = mutableMapOf<String, String>()
val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
var currentPair = StringBuilder()
for (pair in maybeAttributePairs) {
currentPair.append(pair)
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
val key = currentPair.toString().substringBefore("=")
val value = currentPair.toString().substringAfter("=")
attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder() // Reset for the next attribute
} else {
currentPair.append(',') // Continue building the current attribute pair
}
}
return attributes
}
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
private fun shouldQuote(key: String, value: String?): Boolean {
if (value == null)
@@ -345,11 +467,22 @@ class HLS {
val variantPlaylistsRefs: List<VariantPlaylistReference>,
val mediaRenditions: List<MediaRendition>,
val sessionDataList: List<SessionData>,
val independentSegments: Boolean
val independentSegments: Boolean,
val version: Int? = null,
val mediaSequence: Long? = null,
val unhandled: List<String> = emptyList()
) {
fun buildM3U8(): String {
val builder = StringBuilder()
builder.append("#EXTM3U\n")
version?.let {
builder.append("#EXT-X-VERSION:$it\n")
}
mediaSequence?.let {
builder.append("#EXT-X-MEDIA-SEQUENCE:$it\n")
}
if (independentSegments) {
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
}
@@ -404,9 +537,15 @@ class HLS {
}
data class DecryptionInfo(
val keyUrl: String,
val iv: String?
)
val method: String,
val keyUrl: String?,
val iv: String?,
val keyFormat: String?,
val keyFormatVersions: String?
) {
val isEncrypted: Boolean
get() = !method.equals("NONE", ignoreCase = true)
}
data class VariantPlaylist(
val version: Int?,
@@ -417,7 +556,11 @@ class HLS {
val playlistType: String?,
val streamInfo: StreamInfo?,
val segments: List<Segment>,
val decryptionInfo: DecryptionInfo? = null
val decryptionInfo: DecryptionInfo? = null,
val mapUrl: String? = null,
val mapBytesStart: Long = -1,
val mapBytesLength: Long = -1,
val unhandled: List<String> = emptyList()
) {
fun buildM3U8(): String = buildString {
append("#EXTM3U\n")
@@ -426,9 +569,50 @@ class HLS {
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
programDateTime?.let {
append(
"#EXT-X-PROGRAM-DATE-TIME:${
it.withZoneSameInstant(java.time.ZoneOffset.UTC)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
}\n"
)
}
streamInfo?.let { append(it.toM3U8Line()) }
decryptionInfo?.let { dec ->
val sb = StringBuilder()
sb.append("#EXT-X-KEY:METHOD=").append(dec.method)
if (!dec.method.equals("NONE", ignoreCase = true)) {
dec.keyUrl?.let { url ->
sb.append(",URI=\"").append(url).append("\"")
}
dec.iv?.let { iv ->
sb.append(",IV=0x").append(iv)
}
dec.keyFormat?.let { kf ->
sb.append(",KEYFORMAT=\"").append(kf).append("\"")
}
dec.keyFormatVersions?.let { kfv ->
sb.append(",KEYFORMATVERSIONS=\"").append(kfv).append("\"")
}
}
append(sb.append("\n").toString())
}
if (!mapUrl.isNullOrEmpty()) {
val sb = StringBuilder()
sb.append("#EXT-X-MAP:URI=\"").append(mapUrl).append("\"")
if (mapBytesLength > 0) {
if (mapBytesStart >= 0) {
sb.append(",BYTERANGE=\"").append(mapBytesLength)
.append("@").append(mapBytesStart).append("\"")
} else {
sb.append(",BYTERANGE=\"").append(mapBytesLength).append("\"")
}
}
append(sb.append("\n").toString())
}
segments.forEach { segment ->
append(segment.toM3U8Line())
}
@@ -439,13 +623,25 @@ class HLS {
abstract fun toM3U8Line(): String
}
data class MediaSegment (
data class MediaSegment(
val duration: Double,
var uri: String = ""
var uri: String = "",
var bytesStart: Long = -1,
var bytesLength: Long = -1,
val unhandled: MutableList<String> = mutableListOf()
) : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXTINF:${duration},\n")
append(uri + "\n")
if (bytesLength > 0) {
if (bytesStart >= 0) {
append("#EXT-X-BYTERANGE:${bytesLength}@${bytesStart}\n")
} else {
append("#EXT-X-BYTERANGE:${bytesLength}\n")
}
}
append(uri).append("\n")
}
}
@@ -20,6 +20,7 @@ import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.curlbind.Libcurl
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs.Action
@@ -28,8 +29,6 @@ import com.futo.platformplayer.UIDialogs.Companion.showDialog
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker
@@ -38,6 +37,7 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.logging.AndroidLogConsumer
import com.futo.platformplayer.logging.FileLogConsumer
@@ -53,6 +53,7 @@ import com.futo.polycentric.core.toBase64Url
import com.futo.platformplayer.polycentric.ModerationsManager
import kotlinx.coroutines.*
import java.io.File
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
@@ -67,6 +68,20 @@ class StateApp {
val sessionId = UUID.randomUUID().toString();
var airplaneMode: Boolean = false
get(){
return field;
}
private set(value) {
field = value;
}
val airplaneModeChanged = Event1<Boolean>();
fun setAirMode(value: Boolean) {
airplaneMode = value;
airplaneModeChanged.emit(airplaneMode);
}
var privateMode: Boolean = false
get(){
return field;
@@ -80,6 +95,9 @@ class StateApp {
privateModeChanged.emit(privateMode);
}
var hasMediaStoreAudioPermission: Boolean = false;
var hasMediaStoreVideoPermission: Boolean = false;
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
@@ -161,6 +179,12 @@ class StateApp {
?: throw IllegalStateException("Attempted to use a global context while MainActivity is no longer available");
return thisContext;
}
val activity: MainActivity? get() {
val context = contextOrNull;
if(context is MainActivity)
return context;
return null;
}
private var _mainId: String? = null;
@@ -173,6 +197,9 @@ class StateApp {
private var _lastMeteredState: Boolean = false;
private var _connectivityManager: ConnectivityManager? = null;
private var _lastNetworkState: NetworkState = NetworkState.UNKNOWN;
private var _lastConnectivityChange: OffsetDateTime? = null;
val lastConnectivityChange
get() = _lastConnectivityChange;
//Logging
private var _fileLogConsumer: FileLogConsumer? = null;
@@ -276,29 +303,52 @@ class StateApp {
};
}
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
{
if(activity is Context)
{
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
if(skipDialog) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}
else {
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
}
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
}
}
@@ -382,6 +432,16 @@ class StateApp {
Logger.i(TAG, "MainApp Starting");
initializeFiles(true);
_scope?.launch(Dispatchers.IO) {
try {
val caFile = AppCaUpdater.ensureCaBundle(context)
Libcurl.setDefaultCAPath(caFile.absolutePath)
Logger.i(TAG, "Libcurl initialized")
} catch (t: Throwable) {
Logger.e(TAG, "Failed to initialize Libcurl", t);
}
}
if(Settings.instance.other.polycentricLocalCache) {
Logger.i(TAG, "Initialize Polycentric Disk Cache")
_cacheDirectory?.let { ApiMethods.initCache(it) };
@@ -449,7 +509,7 @@ class StateApp {
StateSync.instance.start(context)
}
settingsActivityClosed.subscribe {
SettingsFragment.onClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context)
} else {
@@ -461,7 +521,7 @@ class StateApp {
scopeOrNull?.launch(Dispatchers.Main) {
try {
if (!it.isNullOrEmpty()) {
(SettingsActivity.getActivity() ?: contextOrNull)?.let { c ->
(StateApp.instance.activity ?: contextOrNull)?.let { c ->
val okButtonAction = Action(c.getString(R.string.ok), {}, ActionStyle.PRIMARY)
val copyButtonAction = Action(c.getString(R.string.copy), {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@@ -590,7 +650,9 @@ class StateApp {
scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
/*
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
UIDialogs.toast("Missing general directory");
@@ -607,6 +669,7 @@ class StateApp {
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
});
*/
}
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) {
@@ -860,8 +923,11 @@ class StateApp {
val beforeMeteredState = _lastMeteredState;
_lastNetworkState = getCurrentNetworkState();
_lastMeteredState = isCurrentMetered();
if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState)
if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState) {
Logger.i(TAG, "Network capabilities changed (State: ${_lastNetworkState}, Metered: ${_lastMeteredState})");
_lastConnectivityChange = OffsetDateTime.now();
}
} catch(ex: Throwable) {
Logger.w(TAG, "Failed to update network state", ex);
}
@@ -9,7 +9,6 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
@@ -157,8 +156,8 @@ class StateBackup {
}
catch (exSec: FileNotFoundException) {
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(SettingsActivity.getActivity() != null)
SettingsActivity.getActivity();
val activity = if(StateApp.instance.activity != null)
StateApp.instance.activity
else if(StateApp.instance.isMainActive)
StateApp.instance.contextOrNull;
else null;
@@ -226,7 +225,7 @@ class StateBackup {
StateApp.instance.contextOrNull?.let {
val uri = FileProvider.getUriForFile(it, it.resources.getString(R.string.authority), exportFile);
val activity = SettingsActivity.getActivity() ?: return@let;
val activity = StateApp.instance.activity ?: return@let;
activity.startActivity(
ShareCompat.IntentBuilder(activity)
.setType("application/zip")
@@ -366,7 +365,7 @@ class StateBackup {
}
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false);
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
@@ -543,7 +543,9 @@ class StateDownloads {
val file = export.export(context, { progress ->
val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress);
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
it.setProgress(progress);
}
lastNotifyTime = now;
}
}, null);
@@ -65,7 +65,7 @@ class StateHistory {
}
private var _lastHistoryBroadcast = "";
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false): Long {
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false, playlistId: String? = null): Long {
val pos = if(position < 0) 0 else position;
val historyVideo = index.obj;
@@ -86,6 +86,7 @@ class StateHistory {
historyVideo.position = pos;
historyVideo.date = date ?: OffsetDateTime.now();
historyVideo.playlistId = playlistId
_historyDBStore.update(index.id!!, historyVideo);
onHistoricVideoChanged.emit(liveObj, pos);
@@ -157,7 +158,7 @@ class StateHistory {
UIDialogs.toast("History item null?\nNo history tracking..");
}
else if(create) {
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now());
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now(), StatePlayer.instance.playlistId);
val id = _historyDBStore.insert(newHistItem);
result = _historyDBStore.getOrNull(id);
if(result == null)
@@ -0,0 +1,774 @@
package com.futo.platformplayer.states
import android.content.ContentUris
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists
import android.webkit.MimeTypeMap
import androidx.collection.emptyLongSet
import androidx.core.database.getStringOrNull
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.PlatformID
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.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.Album.Companion.TAG
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.toList
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
class StateLibrary {
private val _files = FragmentedStorage.get<StringArrayStorage>("libraryFiles")
fun getFileDirectories(): List<FileEntry> {
val context = StateApp.instance.contextOrNull ?: return listOf();
return _files.getAllValues().map {
if(it.startsWith("content://")) {
val uri = it.toUri();
val docFile = DocumentFile.fromTreeUri(context, uri) ?: return@map null;
//val access = context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission }
if(!docFile.isDirectory) {
_files.remove(it);
return@map null;
}
if(docFile == null)
return@map null;
return@map FileEntry.fromFile(docFile).apply { this.removable = true }
}
else
FileEntry.fromPath(it);
}.filterNotNull();
}
fun deleteFileDirectory(path: String) {
_files.remove(path);
_files.save();
}
fun addFileDirectory(onAdded: ((entry: FileEntry) -> Unit)? = null, skipDialog: Boolean = false): Boolean {
if(!StateApp.instance.isMainActive)
return false;
val mainActivity = StateApp.instance.contextOrNull as MainActivity? ?: return false;
StateApp.instance.requestDirectoryAccess(mainActivity, "Select Directory",
"Select a directory you would like to make accessible to Grayjay", null, {
if(it != null) {
mainActivity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
try {
val file = DocumentFile.fromTreeUri(mainActivity, it) ?: return@requestDirectoryAccess;
val dir = FileEntry.fromFile(file);
_files.add(dir.path);
_files.save();
onAdded?.invoke(dir);
}
catch(ex: Throwable) {
Logger.e(TAG, "Something went wrong converting requested directory", ex);
}
}
}, skipDialog);
return false;
}
fun searchTracks(str: String): List<IPlatformVideo> {
if(str.isNullOrBlank())
return listOf();
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA,
"LOWER(" + MediaStore.Audio.Media.DISPLAY_NAME + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"),
null) ?: return listOf();
return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast) {
list.add(StateLibrary.audioFromCursor(cursor));
cursor.moveToNext();
}
return@use list;
}
}
fun getAlbums(): List<Album> {
return Album.getAlbums();
}
fun getAlbum(str: String): Album? {
val idLong = str.toLongOrNull();
if(idLong != null)
return getAlbum(idLong);
return null;
}
fun searchAlbums(str: String): List<Album> {
if(str.isNullOrBlank())
return listOf();
return Album.getAlbums("LOWER(" + MediaStore.Audio.Albums.ALBUM + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"));
}
fun getAlbum(id: Long): Album? {
return Album.getAlbum(id);
}
fun getArtists(ordering: ArtistOrdering): List<Artist> {
return Artist.getArtists(ordering);
}
fun getArtist(str: String): Artist? {
val idLong = str.toLongOrNull();
if(idLong != null)
return getArtist(idLong);
return null;
}
fun searchArtists(str: String): List<Artist> {
if(str.isNullOrBlank())
return listOf();
return Artist.getArtists(ArtistOrdering.TrackCount, "LOWER(" + MediaStore.Audio.Artists.ARTIST + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"));
}
fun getArtist(id: Long): Artist? {
return Artist.getArtist(id);
}
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
query,
null,
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
//Ongoing usage of cursor..todo disposal
//return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
cursor.moveToNext();
}
return AdhocPager<IPlatformContent>({
val list = mutableListOf<IPlatformContent>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
cursor.moveToNext();
}
Logger.i(TAG, "Videos nextPage: ${list.size}")
return@AdhocPager list;
}, list);
//}
}
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
val videoPager = getVideos(buckets);
val items = mutableListOf<IPlatformVideo>();
while(videoPager.getResults().size > 0 && items.size < count) {
items.addAll(videoPager.getResults().filter { it is IPlatformVideo }.map { it as IPlatformVideo });
if(videoPager.hasMorePages())
videoPager.nextPage();
}
return items;
}
private var _cacheBucketNames: List<Bucket>? = null;
fun getVideoBucketNames(): List<Bucket> {
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
return listOf();
if(_cacheBucketNames != null)
return _cacheBucketNames ?: listOf();
try {
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
), null, null, null
) ?: return listOf();
return cur.use {
val buckets = mutableListOf<Bucket>();
val list = HashSet<Long>();
if (cur.moveToFirst()) {
var id: Long;
var bucket: String
do {
try {
id = cur.getLong(0);
bucket = cur.getStringOrNull(1) ?: continue;
if (!list.contains(id)) {
list.add(id);
buckets.add(Bucket(id, bucket));
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
}
} while (cur.moveToNext())
}
_cacheBucketNames = buckets.toList()
return@use _cacheBucketNames ?: listOf();
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Buckets loading failed, returning empty");
return listOf();
}
}
companion object {
val PROJECTION_VIDEO = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
);
val PROJECTION_MEDIA = arrayOf(
MediaStore.Audio.Media._ID, //0
MediaStore.Audio.Media.DISPLAY_NAME, //1
MediaStore.Audio.Media.ARTIST, //2
MediaStore.Audio.Media.ARTIST_ID, //3
MediaStore.Audio.Media.ALBUM_ID, //4
MediaStore.Audio.Media.DURATION, //5
MediaStore.Audio.Media.DATE_ADDED, //6
MediaStore.Audio.Media.MIME_TYPE, //7
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //8
);
fun getDocumentTrack(url: String): IPlatformContentDetails? {
if(!url.contains("com.android.externalstorage.documents"))
return null;
val docFile = DocumentFile.fromSingleUri(StateApp.instance.context, url.toUri()) ?: return null;
val contentUri = docFile.uri.toString();
val mimeType = MimeTypeMap.getFileExtensionFromUrl(contentUri);
if(docFile.name != null) {
if (StateApp.instance.hasMediaStoreAudioPermission && mimeType.startsWith("audio/")) {
val aud = findAudioByName(docFile.name!!);
if (aud != null)
return aud;
}
if (StateApp.instance.hasMediaStoreVideoPermission && mimeType.startsWith("video/")) {
val vid = findVideoByName(docFile.name!!);
if (vid != null)
return vid;
}
}
return LocalVideoDetails(
PlatformID("FILE", contentUri, null, 0, -1),
docFile.name ?: docFile.uri.toString(), Thumbnails(arrayOf(
Thumbnail(docFile.uri.toString(), 0)
)), PlatformAuthorLink.UNKNOWN, contentUri, 0, mimeType, null);
}
fun getAudioTrack(url: String): IPlatformContentDetails? {
val uri = Uri.parse(url);
val id = uri.lastPathSegment?.toLongOrNull();
if(id == null) {
return getDocumentTrack(url);
}
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()),
null) ?: return null;
return cursor.use {
cursor.moveToFirst();
if(cursor.isAfterLast)
return@use null;
return@use audioFromCursor(cursor);
}
}
fun findAudioByName(name: String): IPlatformContentDetails? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Audio contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
null) ?: return null;
return cursor.use {
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return@use audioFromCursor(cursor);
}
}
fun getVideoTrack(url: String): IPlatformContentDetails? {
val uri = Uri.parse(url);
val id = uri.lastPathSegment?.toLongOrNull();
if(id == null)
return getDocumentTrack(url);
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()),
null) ?: return null;
return cursor.use {
cursor.moveToFirst();
if(cursor.isAfterLast)
return@use null;
return@use videoFromCursor(cursor);
}
}
fun findVideoByName(name: String): IPlatformContentDetails? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
null) ?: return null;
return cursor.use {
cursor.moveToFirst();
if(cursor.isAfterLast)
return@use null;
return@use videoFromCursor(cursor);
}
}
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
val id = cursor.getString(0);
val displayName = cursor.getString(1);
val author = cursor.getString(2);
val authorId = cursor.getStringOrNull(3);
val albumId = cursor.getLong(4);
val duration = cursor.getLong(5).let { if(it > 0) it / 1000 else 0 };
val date = cursor.getLong(6);
val contentType = cursor.getString(7);
val category = cursor.getString(8);
val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null )
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, idLong).toString();
else
"";
val authorIdLong = authorId?.toLongOrNull();
val authorUrl = if(authorIdLong != null)
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, authorIdLong).toString();
else
"";
val albumContentUrl = if(albumId > 0)
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
else null;
val dateObj = if(date > 0)
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
else null;
val authorObj = if(!author.isNullOrBlank())
PlatformAuthorLink(
if(authorId != null) PlatformID("LOCAL", authorId) else PlatformID.NONE,
author,
authorUrl, null, null)
else PlatformAuthorLink.UNKNOWN;
return LocalVideoDetails(
PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf(
Thumbnail(albumContentUrl ?: contentUrl, 0)
)), authorObj, contentUrl, duration, contentType, dateObj);
}
fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails {
val id = cursor.getString(0);
val displayName = cursor.getString(1);
val author = null;//cursor.getString(2);
val date = cursor.getLong(2);
val contentType = cursor.getString(3);
val category = cursor.getString(4);
val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null )
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, idLong).toString();
else
"";
val dateObj = if(date > 0)
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
else null;
val authorObj = if(!author.isNullOrBlank())
PlatformAuthorLink(PlatformID.NONE, author, "", null, null)
else PlatformAuthorLink.UNKNOWN;
return LocalVideoDetails(
PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf(
Thumbnail(contentUrl, 0)
)), authorObj, contentUrl, -1, contentType, dateObj);
}
private var _instance : StateLibrary? = null;
val instance : StateLibrary
get(){
if(_instance == null)
_instance = StateLibrary();
return _instance!!;
};
fun finish() {
_instance?.let {
_instance = null;
}
}
}
}
class Bucket(val id: Long, val name: String);
enum class ArtistOrdering {
Alphabethic,
TrackCount,
AlbumCount
}
class Artist {
val id: String;
val name: String;
val countTracks: Int;
val countAlbums: Int;
val thumbnail: String?;
val contentUrl: String?;
constructor(name: String, countTracks: Int = -1, countAlbums: Int = -1, thumbnail: String? = null, id: String? = null, contentUrl: String? = null) {
this.id = id ?: ID_UNKNOWN;
this.name = name;
this.thumbnail = thumbnail;
this.countTracks = countTracks;
this.countAlbums = countAlbums;
this.contentUrl = contentUrl;
}
fun getAlbums(): List<Album> {
return Album.getArtistAlbums(id.toLongOrNull() ?: return listOf());
}
fun toPlaylist(tracks: List<IPlatformVideo>? = null): Playlist {
return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getAudioTracks().toList().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) })
}
fun getAudioTracks(): IPager<IPlatformContent> {
val idLong = id.toLongOrNull() ?: return EmptyPager();
return AdhocPager({ listOf() }, getTracksPager(idLong));
}
fun getThumbnailOrAlbum(): String? {
return thumbnail ?: tryGetArtistThumbnail(id.toLongOrNull());
}
companion object {
val ID_UNKNOWN = "UNKNOWN";
val PROJECTION: Array<String> = arrayOf(Artists._ID,
Artists.ARTIST,
Artists.NUMBER_OF_TRACKS,
Artists.NUMBER_OF_ALBUMS);
val thumbnailCache = ConcurrentHashMap<Long, String>();
fun tryGetArtistThumbnail(artistId: Long?): String? {
if(artistId == null)
return null;
if(thumbnailCache.containsKey(artistId))
return thumbnailCache.get(artistId);
else {
val album = Album.getArtistAlbumWithThumbnail(artistId);
thumbnailCache.put(artistId, album?.thumbnail ?: "");
return album?.thumbnail;
}
}
fun fromCursor(cursor: Cursor): Artist {
val id = cursor.getString(0);
val artist = cursor.getString(1);
val numTracks = cursor.getInt(2);
val numAlbums = cursor.getInt(3);
val idLong = id.toLongOrNull();
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
}
fun getArtist(id: Long): Artist? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Artist contentResolver not found");
return null
}
val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
Artist.PROJECTION,
"${MediaStore.Audio.Artists._ID} = ?",
arrayOf(id.toString()), null) ?: return null;
return cursor.use {
cursor.moveToFirst();
if(cursor.isAfterLast)
return@use null;
return@use Artist.fromCursor(cursor);
}
}
fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array<String>? = null): List<Artist> {
val ordering = when(ordering) {
ArtistOrdering.Alphabethic -> Artists.ARTIST + " ASC";
ArtistOrdering.AlbumCount -> Artists.NUMBER_OF_ALBUMS + " DESC";
ArtistOrdering.TrackCount -> Artists.NUMBER_OF_TRACKS + " DESC";
else -> null
}
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(Artists.EXTERNAL_CONTENT_URI, PROJECTION,
query,
args,
ordering) ?: return listOf();
return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<Artist>()
while(!cursor.isAfterLast) {
val artist = fromCursor(cursor);
cursor.moveToNext();
if(artist.name == "<unknown>")
continue; //TODO: Better way of detecting unknown?
list.add(artist);
}
return@use list;
}
}
fun getTracksPager(artistId: Long): List<IPlatformVideo> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
null) ?: return listOf();
return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast) {
list.add(StateLibrary.audioFromCursor(cursor));
cursor.moveToNext();
}
return@use list;
}
}
}
}
class Album {
val id: String;
val name: String;
val artist: String?;
val countTracks: Int;
val thumbnail: String?;
constructor(name: String, countTracks: Int = -1, artist: String? = null, id: String? = null, thumbnail: String? = null) {
this.id = id ?: ID_UNKNOWN;
this.name = name;
this.artist = artist;
this.countTracks = countTracks;
this.thumbnail = thumbnail;
}
fun getTracks(): List<IPlatformVideo> {
return getAlbumTracks(id.toLongOrNull() ?: return listOf())
}
fun toPlaylist(tracks: List<IPlatformVideo>? = null): Playlist {
return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getTracks().map { SerializedPlatformVideo.fromVideo(it) })
}
companion object {
val TAG = "StateLibrary";
val ID_UNKNOWN = "UNKNOWN";
val PROJECTION = arrayOf(MediaStore.Audio.Albums.ALBUM_ID,
MediaStore.Audio.Albums.ALBUM,
MediaStore.Audio.Albums.NUMBER_OF_SONGS,
MediaStore.Audio.Albums.ARTIST);
fun fromCursor(cursor: Cursor): Album {
val id = cursor.getString(0);
val album = cursor.getString(1);
val numTracks = cursor.getInt(2);
val artist = cursor.getString(3);
val idLong = id.toLongOrNull();
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
return Album(album, numTracks, artist, id, uri?.toString());
}
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()),
null) ?: return listOf();
return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast) {
list.add(StateLibrary.audioFromCursor(cursor));
cursor.moveToNext();
}
return@use list;
}
}
fun getAlbum(id: Long): Album? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null
}
val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
PROJECTION,
"${MediaStore.Audio.Albums.ALBUM_ID} = ?",
arrayOf(id.toString()), null) ?: return null;
return cursor.use {
cursor.moveToFirst();
if(cursor.isAfterLast)
return@use null;
return@use fromCursor(cursor);
}
}
fun getAlbums(query: String? = null, args: Array<String>? = null): List<Album> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args,
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<Album>()
while(!cursor.isAfterLast) {
list.add(fromCursor(cursor));
cursor.moveToNext();
}
return@use list;
}
}
fun getArtistAlbums(artistId: Long): List<Album> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<Album>()
while(!cursor.isAfterLast) {
list.add(fromCursor(cursor));
cursor.moveToNext();
}
return@use list;
}
}
fun getArtistAlbumWithThumbnail(artistId: Long): Album? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return null;
return cursor.use {
cursor.moveToFirst();
while(!cursor.isAfterLast) {
val album = fromCursor(cursor);
if(album.thumbnail != null)
return album
cursor.moveToNext();
}
return@use null;
}
}
}
}
class FileEntry(
val path: String,
val name: String,
val isDirectory: Boolean = false,
val thumbnail: String? = null,
var removable: Boolean = false
) {
fun getSubFiles(): List<FileEntry> {
if(isDirectory) {
if(path.startsWith("content://"))
return DocumentFile.fromTreeUri(StateApp.instance.context, path.toUri())?.listFiles()
?.map { fromFile(it) } ?: return listOf();
return File(path).listFiles()
.map { fromFile(it) }
}
return listOf();
}
companion object {
fun fromPath(path: String): FileEntry {
/*
val cursor = StateApp.instance.context.contentResolver.query(path.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return FileEntry(path, fileName, );
*/
val file = File(path);
return FileEntry(file.path, file.name, file.isDirectory);
}
fun fromFile(file: File): FileEntry {
return FileEntry(file.path, file.name, file.isDirectory);
}
fun fromFile(file: DocumentFile): FileEntry {
return FileEntry(file.uri.toString(), file.name ?: "", file.isDirectory);
}
}
}
@@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
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.local.LocalClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
@@ -75,6 +76,7 @@ class StatePlatform {
private val _cache : LruCache<String, CachedPlatformContent> = LruCache<String, CachedPlatformContent>(VIDEO_CACHE);
//Clients
private val _localClient = LocalClient();
private val _enabledClientsPersistent = FragmentedStorage.get<StringArrayStorage>("enabledClients");
private val _platformOrderPersistent = FragmentedStorage.get<StringArrayStorage>("platformOrder");
private val _clientsLock = Object();
@@ -117,6 +119,7 @@ class StatePlatform {
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_mainClientPool.getClientPooled(it).getContentDetails(url)
}
?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null)
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
}
else {
@@ -124,6 +127,7 @@ class StatePlatform {
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_privateClientPool.getClientPooled(it).getContentDetails(url)
}
?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null)
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
}
},
@@ -1,6 +1,7 @@
package com.futo.platformplayer.states
import android.content.Context
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
@@ -111,10 +112,20 @@ class StatePlayer {
val onPlayerOpened = Event0();
val onPlayerClosed = Event0();
var currentVideo: IPlatformVideoDetails? = null
var currentVideo: IPlatformVideo? = null
private set;
fun setCurrentlyPlaying(video: IPlatformVideoDetails?) {
private var _currentPlaylistId: String? = null
val playlistId: String? get() = if (_queueType == TYPE_PLAYLIST) _currentPlaylistId else null
init {
onQueueChanged.subscribe {
updateLastQueue()
}
}
fun setCurrentlyPlaying(video: IPlatformVideo?) {
Log.i(TAG, "setCurrentlyPlaying ${video?.url} (${video?.name})")
currentVideo = video;
}
@@ -125,6 +136,7 @@ class StatePlayer {
onPlayerOpened.emit();
}
fun setPlayerClosed() {
Log.i(TAG, "setCurrentlyPlaying (setPlayerClosed) null")
setCurrentlyPlaying(null);
isOpen = false;
clearQueue();
@@ -269,23 +281,6 @@ class StatePlayer {
}
onQueueChanged.emit(true);
}
fun setPlaylist(playlist: IPlatformPlaylistDetails, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
synchronized(_queue) {
_queue.clear();
setQueueType(TYPE_PLAYLIST);
_queueName = playlist.name;
_queue.addAll(playlist.contents.getResults());
queueFocused = focus;
queueShuffle = shuffle;
if (shuffle) {
createShuffledQueue();
}
_queuePosition = toPlayIndex;
}
playlist.id.value?.let { StatePlaylists.instance.didPlay(it); };
onQueueChanged.emit(true);
}
fun setPlaylist(playlist: Playlist, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
synchronized(_queue) {
_queue.clear();
@@ -299,6 +294,7 @@ class StatePlayer {
}
_queuePosition = toPlayIndex;
}
_currentPlaylistId = playlist.id
StatePlaylists.instance.didPlay(playlist.id);
onQueueChanged.emit(true);
@@ -384,6 +380,27 @@ class StatePlayer {
setQueuePosition(video);
}
}
fun updateLastQueue() {
val queueVideos = synchronized(_queue) {
if (!_queue.isEmpty()) {
return@synchronized _queue.map { SerializedPlatformVideo.fromVideo(it) }.toList()
}
return@synchronized null
}
if (queueVideos != null) {
Logger.i(TAG, "Update last queue: ${queueVideos.size} videos.")
val playlist = StatePlaylists.instance.getPlaylist(StatePlaylists.LAST_QUEUE_PLAYLIST_ID)?.apply {
videos.clear()
videos.addAll(queueVideos)
} ?: Playlist("Last Queue", queueVideos).apply {
id = StatePlaylists.LAST_QUEUE_PLAYLIST_ID
}
StatePlaylists.instance.createOrUpdatePlaylist(playlist)
}
}
fun setQueuePosition(video: IPlatformVideo) {
synchronized(_queue) {
if (getCurrentQueueItem() == video) {
@@ -200,10 +200,10 @@ class StatePlaylists {
}
fun getLastPlayedPlaylist() : Playlist? {
return playlistStore.queryItem { it.maxByOrNull { x -> x.datePlayed } };
return playlistStore.queryItem { it.filter { x -> x.id != StatePlaylists.LAST_QUEUE_PLAYLIST_ID }.maxByOrNull { x -> x.datePlayed } };
}
fun getLastUpdatedPlaylist() : Playlist? {
return playlistStore.queryItem { it.maxByOrNull { x -> x.dateUpdate } };
return playlistStore.queryItem { it.filter { x -> x.id != StatePlaylists.LAST_QUEUE_PLAYLIST_ID }.maxByOrNull { x -> x.dateUpdate } };
}
fun getPlaylists() : List<Playlist> {
@@ -394,6 +394,7 @@ class StatePlaylists {
companion object {
val TAG = "StatePlaylists";
val LAST_QUEUE_PLAYLIST_ID = "a70a3287-45dd-4227-832c-6ecde7fb1bf6"
private var _instance : StatePlaylists? = null;
private var _lockObject = Object()
val instance : StatePlaylists
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
import com.futo.platformplayer.logging.Logger
@@ -167,7 +168,7 @@ class StatePlugins {
if(config.authentication == null)
return false;
LoginActivity.showLogin(context, config) {
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
try {
StatePlugins.instance.setPluginAuth(config.id, it);
} catch (e: Throwable) {
@@ -300,6 +301,7 @@ class StatePlugins {
StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl);
else null;
//config.version = config.version - 1;
createPlugin(config, script, icon, true);
return true;
}
@@ -317,6 +319,15 @@ class StatePlugins {
installPlugins(context, scope, sourceUrls.drop(1), handler);
}
}
fun requestConfig(sourceUrl: String): SourcePluginConfig {
val configResp = ManagedHttpClient().get(sourceUrl);
if(!configResp.isOk)
throw IllegalStateException("Failed request with ${configResp.code}");
val configJson = configResp.body?.string();
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
return SourcePluginConfig.fromJson(configJson, sourceUrl);
}
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
scope.launch(Dispatchers.IO) {
val client = ManagedHttpClient();
@@ -329,6 +340,7 @@ class StatePlugins {
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
//config.version = config.version - 1;
}
catch(ex: SerializationException) {
Logger.e(TAG, "Failed decode config", ex);
@@ -642,6 +654,9 @@ class StatePlugins {
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
descriptor.updateAuth(auth);
_plugins.save(descriptor);
if(auth != null)
UIDialogs.appToast("Plugin ${descriptor?.config?.name} logged in");
}
@Serializable
@@ -463,7 +463,7 @@ class StateSync {
for(video in history){
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date, false, video.playlistId)
if(lastHistory < video.date)
lastHistory = video.date;
}
@@ -39,7 +39,8 @@ class StateTelemetry {
Build.BRAND,
Build.MANUFACTURER,
Build.MODEL,
Build.VERSION.SDK_INT
Build.VERSION.SDK_INT,
StatePlatform.instance.getEnabledClients().map { it.id }.toList()
);
val headers = hashMapOf(
@@ -0,0 +1,70 @@
package com.futo.platformplayer.views
import android.content.Context
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
class AlbumHeaderView: ConstraintLayout {
val textName: TextView;
val textMetadata: TextView;
val imageThumbnail: ImageView;
val imageThumbnailBackground: ImageView;
val buttonPlayAll: LinearLayout;
val buttonShuffle: LinearLayout;
val onPlayAll = Event0();
val onShuffle = Event0();
constructor(context: Context) : super(context) {
inflate(context, R.layout.view_album_header, this)
textName = findViewById(R.id.text_name);
textMetadata = findViewById(R.id.text_metadata);
imageThumbnail = findViewById(R.id.image_thumbnail);
imageThumbnailBackground = findViewById(R.id.image_thumbnail_background);
buttonPlayAll = findViewById(R.id.button_play_all);
buttonShuffle = findViewById(R.id.button_shuffle);
buttonPlayAll.setOnClickListener { onPlayAll.emit() };
buttonShuffle.setOnClickListener { onShuffle.emit() };
}
fun setThumbnail(thumbnail: String?) {
if (thumbnail != null)
Glide.with(imageThumbnail)
.load(thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnail)
else
Glide.with(imageThumbnail)
.load(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnail);
if (thumbnail != null)
Glide.with(imageThumbnailBackground)
.load(thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnailBackground)
else
Glide.with(imageThumbnailBackground)
.load(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnailBackground);
}
fun setName(str: String){
textName.text = str;
}
fun setMetadata(str: String) {
textMetadata.text = str;
}
}
@@ -72,9 +72,9 @@ class AnyInsertedAdapterView<I, T>(view: RecyclerView, adapter: BaseAnyAdapter<I
= this.asAnyWithViews(arrayListOf(view), arrayListOf(), orientation, reversed, onCreate);
inline fun<I, reified T : AnyAdapter.AnyViewHolder<I>> RecyclerView.asAnyWithViews(prepend: ArrayList<View> = arrayListOf(), append: ArrayList<View> = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView<I, T> {
for(view in prepend)
(view.parent as ViewGroup).removeView(view);
(view.parent as ViewGroup?)?.removeView(view);
for(view in append)
(view.parent as ViewGroup).removeView(view);
(view.parent as ViewGroup?)?.removeView(view);
return AnyInsertedAdapterView(this, AnyInsertedAdapter.create(prepend, append, onCreate), orientation, reversed);
}
inline fun<I, reified T : AnyAdapter.AnyViewHolder<I>> RecyclerView.asAnyWithViews(list: ArrayList<I>, prepend: ArrayList<View> = arrayListOf(), append: ArrayList<View> = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView<I, T> {
@@ -0,0 +1,60 @@
package com.futo.platformplayer.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.GONE
import androidx.constraintlayout.widget.ConstraintLayout.inflate
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.AnyAdapter.AnyViewHolder
import com.google.android.material.imageview.ShapeableImageView
class LibrarySection: ConstraintLayout {
val textName: TextView;
val imageNavigate: ImageView;
val recycler: RecyclerView;
val noContent: NoResultsView;
constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) {
inflate(context, R.layout.view_library_section, this);
textName = findViewById(R.id.text_label)
imageNavigate = findViewById(R.id.image_nav)
recycler = findViewById(R.id.recycler_collection);
noContent = findViewById(R.id.container_no_content);
}
fun setNavIcon(resId: Int) {
imageNavigate.setImageResource(resId);
}
fun setContentEmptyMessage(icon: Int, msg: String) {
}
inline fun <T, reified V: AnyViewHolder<T>> getAnyAdapter(noinline onCreate: ((V)->Unit)? = null, orientation: Int = RecyclerView.HORIZONTAL): AnyAdapterView<T, V> {
return recycler.asAny<T, V>(orientation, false, onCreate);
}
inline fun setSection(title: String, crossinline onOpen: (()->Unit)) {
textName.text = title;
imageNavigate.setOnClickListener { onOpen.invoke() };
}
fun setEmpty(title: String, txt: String, iconId: Int) {
noContent.isVisible = true;
recycler.isVisible = false;
noContent.setText(title, txt, iconId);
}
fun clearEmpty() {
noContent.isVisible = false;
recycler.isVisible = true;
}
}
@@ -0,0 +1,67 @@
package com.futo.platformplayer.views
import android.content.Context
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
class LibraryTypeHeaderView: ConstraintLayout {
var selected: SelectedType = SelectedType.Artists;
val pillArtist: PillV2;
val pillAlbums: PillV2;
val textMetadata: TextView;
val pills: List<PillV2>
val onSelectedChanged = Event1<SelectedType>();
constructor(context: Context) : super(context) {
inflate(context, R.layout.view_library_type_header, this)
textMetadata = findViewById(R.id.text_metadata);
pillArtist = findViewById(R.id.pill_artist);
pillAlbums = findViewById(R.id.pill_albums);
pillArtist.onClick.subscribe {
setSelectedType(SelectedType.Artists, true);
}
pillAlbums.onClick.subscribe {
setSelectedType(SelectedType.Albums, true);
}
pills = listOf(pillArtist, pillAlbums);
setSelectedType(SelectedType.Artists, false);
}
fun setMetadata(str: String) {
textMetadata.text = str;
}
fun setSelectedType(selected: SelectedType, notify: Boolean = false){
this.selected = selected;
pills.forEach { it.setIsEnabled(false) };
when(selected) {
SelectedType.Artists -> {
pillArtist.setIsEnabled(true);
}
SelectedType.Albums -> {
pillAlbums.setIsEnabled(true);
}
}
if(notify)
onSelectedChanged.emit(selected);
}
enum class SelectedType {
Artists,
Albums
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
@@ -15,6 +16,13 @@ class NoResultsView: ConstraintLayout {
val icon: ImageView;
val containerExtraViews: LinearLayout;
constructor(context: Context, attributes: AttributeSet? = null) : super(context, attributes){
inflate(context, R.layout.view_no_results, this);
textTitle = findViewById(R.id.text_title)
textCentered = findViewById(R.id.text_centered);
icon = findViewById(R.id.icon);
containerExtraViews = findViewById(R.id.container_extra_views);
}
constructor(context: Context, title: String, text: String, iconId: Int, extraViews: List<View>) : super(context) {
inflate(context, R.layout.view_no_results, this);
@@ -22,13 +30,21 @@ class NoResultsView: ConstraintLayout {
textCentered = findViewById(R.id.text_centered);
icon = findViewById(R.id.icon);
containerExtraViews = findViewById(R.id.container_extra_views);
setText(title, text, iconId, extraViews);
}
fun setText(title: String, text: String, iconId: Int = -1, extraViews: List<View>? = null) {
textTitle.text = title;
textCentered.text = text;
icon.setImageResource(iconId);
if(iconId < 0)
icon.visibility = GONE;
else
icon.setImageResource(iconId);
for(view in extraViews)
containerExtraViews.addView(view);
if(extraViews != null)
for(view in extraViews)
containerExtraViews.addView(view);
}
}
@@ -0,0 +1,73 @@
package com.futo.platformplayer.views
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.views.others.ToggleTagView
class PillV2: FrameLayout {
val root: FrameLayout;
val text: TextView;
var isToggled: Boolean = false;
val onClick = Event1<Boolean>();
constructor(context: Context, name: String, isActive: Boolean = false, action: (PillV2, Boolean)->Unit, actionLong: ((PillV2, Boolean)->Unit)? = null): super(context) {
inflate(context, R.layout.view_tag_v2, this);
root = findViewById(R.id.root);
text = findViewById(R.id.text_tag);
text.text = name;
setIsEnabled(isActive);
setOnClickListener {
setIsEnabled(!isToggled);
onClick.emit(isToggled);
action(this, isToggled);
}
if(actionLong != null)
setOnLongClickListener {
actionLong(this, this.isToggled);
return@setOnLongClickListener true;
}
}
constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) {
inflate(context, R.layout.view_tag_v2, this);
root = findViewById(R.id.root);
text = findViewById(R.id.text_tag);
val attrArr = context.obtainStyledAttributes(attr, R.styleable.PillV2, 0, 0);
val attrEnabled = attrArr.getBoolean(R.styleable.PillV2_pillV2Enabled, false);
val attrText = attrArr.getText(R.styleable.PillV2_pillV2Text) ?: "";
text.text = attrText;
setIsEnabled(attrEnabled);
setOnClickListener {
setIsEnabled(!isToggled);
onClick.emit(isToggled);
}
}
fun setText(text: String) {
this.text.text = text;
}
fun setIsEnabled(enabled: Boolean = true) {
if(enabled)
root.setBackgroundResource(R.drawable.background_2e_round_4dp)
else
root.setBackgroundResource(R.drawable.background_black_2e_round_4dp);
this.isToggled = enabled;
}
}
@@ -3,9 +3,7 @@ package com.futo.platformplayer.views.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -46,6 +44,7 @@ class CommentViewHolder : ViewHolder {
private val _imageLikeIcon: ImageView;
private val _textLikes: TextView;
private val _imageDislikeIcon: ImageView;
private val _buttonCopy: PillButton;
private val _textDislikes: TextView;
private val _buttonReplies: PillButton;
private val _layoutRating: LinearLayout;
@@ -69,6 +68,7 @@ class CommentViewHolder : ViewHolder {
_textMetadata = itemView.findViewById(R.id.text_metadata);
_textBody = itemView.findViewById(R.id.text_body);
_imageLikeIcon = itemView.findViewById(R.id.image_like_icon);
_buttonCopy = itemView.findViewById(R.id.image_copy);
_textLikes = itemView.findViewById(R.id.text_likes);
_imageDislikeIcon = itemView.findViewById(R.id.image_dislike_icon);
_textDislikes = itemView.findViewById(R.id.text_dislikes);
@@ -103,7 +103,8 @@ class CommentViewHolder : ViewHolder {
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
};
_layoutComment.setOnLongClickListener {
_buttonCopy.setTransparant()
_buttonCopy.onClick.subscribe {
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = comment?.message.orEmpty()
val clip = ClipData.newPlainText("Comment", text)
@@ -0,0 +1,84 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.updateLayoutParams
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.views.adapters.AnyAdapter
class AlbumTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Album>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_album_tile,
_viewGroup, false)) {
val onClick = Event1<Album?>();
protected var _root: ConstraintLayout;
protected var _album: Album? = null;
protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
init {
_root = _view.findViewById(R.id.root);
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_view.setOnClickListener { onClick.emit(_album) };
}
fun setWidth(dp: Int) {
_root.updateLayoutParams {
this.width = (dp - 12).dp(_viewGroup.context.resources);
this.height = (dp + 48).dp(_viewGroup.context.resources);
}
_imageThumbnail.updateLayoutParams {
this.width = (dp - 12).dp(_viewGroup.context.resources);
this.height = (dp - 12).dp(_viewGroup.context.resources);
}
}
fun setAutoSize(totalWidth: Float) {
val viewWidth = 98;
val dpWidth = totalWidth;
val columns = Math.max(((dpWidth) / viewWidth).toInt(), 1);
val remainder = dpWidth - columns * viewWidth;
val targetSize = viewWidth + (remainder / columns).toInt();
setWidth(targetSize);
}
override fun bind(album: Album) {
_album = album;
_imageThumbnail?.let {
if (album.thumbnail != null)
Glide.with(it)
.load(album.thumbnail)
.placeholder(R.drawable.unknown_music)
.into(it)
else
Glide.with(it).load(R.drawable.unknown_music).into(it);
};
_textName.text = album.name;
_textMetadata.text = album.artist ?: "";
}
companion object {
fun getAutoSizeColumns(totalWidth: Float): Int {
val viewWidth = 98;
val dpWidth = totalWidth;
val columns = Math.max(((dpWidth) / viewWidth).toInt(), 1);
return columns;
}
}
}
@@ -0,0 +1,54 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.views.adapters.AnyAdapter
class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Artist>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_artist_tile,
_viewGroup, false)) {
val onClick = Event1<Artist?>();
protected var _artist: Artist? = null;
protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
init {
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_view.setOnClickListener { onClick.emit(_artist) };
}
override fun bind(artist: Artist) {
_artist = artist;
_imageThumbnail?.let {
val thumbnail = artist.getThumbnailOrAlbum();
if (thumbnail != null)
Glide.with(it)
.load(thumbnail)
.placeholder(R.drawable.ic_artist)
.into(it)
else
Glide.with(it).load(R.drawable.ic_artist).into(it);
};
_textName.text = artist.name;
_textMetadata.text = ""// "${artist.countTracks} tracks";
}
}
@@ -0,0 +1,62 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.views.adapters.AnyAdapter
class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<FileEntry>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_file,
_viewGroup, false)) {
val onClick = Event1<FileEntry?>();
val onDelete = Event1<FileEntry?>();
protected var _file: FileEntry? = null;
protected val _imageThumbnail: ImageView
protected val _buttonDelete: ImageButton;
protected val _textName: TextView
//protected val _textMetadata: TextView
init {
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
//_textMetadata = _view.findViewById(R.id.text_metadata);
_buttonDelete = _view.findViewById(R.id.button_delete);
_view.setOnClickListener { onClick.emit(_file) };
_buttonDelete.setOnClickListener { onDelete.emit(_file) }
}
override fun bind(file: FileEntry) {
_file = file;
_imageThumbnail?.let {
if(file.isDirectory)
it.setImageResource(R.drawable.ic_folder);
else {
Glide.with(it)
.load(file.thumbnail)
.placeholder(R.drawable.ic_song)
.into(it)
}
};
_buttonDelete.isVisible = file.removable;
_textName.text = file.name;
//if(file.isDirectory)
// _textMetadata.text = "Directory";
//else
// _textMetadata.text = "";
}
}
@@ -0,0 +1,63 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.google.android.material.imageview.ShapeableImageView
class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<IPlatformVideo>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_video_thumbnail_tile,
_viewGroup, false)) {
val onClick = Event1<IPlatformVideo?>();
protected var _content: IPlatformVideo? = null;
protected val _imageThumbnail: ShapeableImageView
protected val _textDuration: TextView;
protected val _textName: TextView
protected val _textMetadata: TextView
init {
_imageThumbnail = _view.findViewById(R.id.image_video_thumbnail);
_textDuration = _view.findViewById(R.id.thumbnail_duration);
_textName = _view.findViewById(R.id.text_video_name);
_textMetadata = _view.findViewById(R.id.text_video_metadata);
_view.setOnClickListener { onClick.emit(_content) };
}
override fun bind(content: IPlatformVideo) {
_content = content;
_imageThumbnail?.let {
if (content.thumbnails.getHQThumbnail() != null)
Glide.with(it)
.load(content.thumbnails.getHQThumbnail())
.placeholder(R.drawable.unknown_music)
.into(it)
else
Glide.with(it).load(R.drawable.unknown_music).into(it);
};
_textName.text = content.name;
_textMetadata.text = content.datetime?.toHumanNowDiffString();
_textDuration.text = content.duration.toHumanTime(false) + " ago";
_textDuration.isVisible = content.duration > 0;
}
}
@@ -0,0 +1,63 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.adapters.AnyAdapter
class TrackViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<IPlatformContent>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_track,
_viewGroup, false)) {
val onClick = Event1<IPlatformContent>();
val onOptions = Event1<IPlatformContent>();
protected var _content: IPlatformContent? = null;
//protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
protected val _imageSettings: ImageView;
init {
//_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_imageSettings = _view.findViewById(R.id.button_options);
_view.setOnClickListener { _content?.let { onClick.emit(it) } };
_imageSettings.setOnClickListener { _content?.let { onOptions.emit(it) } };
}
override fun bind(content: IPlatformContent) {
_content = content;
/*
_imageThumbnail?.let {
if (artist.thumbnail != null)
Glide.with(it)
.load(artist.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(it)
else
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
};
*/
_textName.text = content.name;
val metaComps = listOf(
if(content is IPlatformVideo) "${content.duration.toHumanDuration(false)}" else null
).filterNotNull();
_textMetadata.text = metaComps.joinToString(", ");
}
}
@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import kotlin.math.abs
class GestureControlView : LinearLayout {
@@ -114,6 +115,7 @@ class GestureControlView : LinearLayout {
val onZoom = Event1<Float>();
val onSoundAdjusted = Event1<Float>();
val onToggleFullscreen = Event0();
val onTogglePlayPause = Event0();
val onSpeedHoldStart = Event0()
val onSpeedHoldEnd = Event0()
@@ -269,8 +271,19 @@ class GestureControlView : LinearLayout {
return false;
}
val rewinding = (ev.x / width) < 0.5;
startFastForward(rewinding);
val centerArea = if (height != 0) 0.33f + (0.20f - 0.33f) * (((width.toFloat() / height.toFloat()) - 1f) / ((20f / 9f) - 1f)).coerceIn(0f, 1f) else 0.2f
val rewindArea = (1 - centerArea) / 2
val forwardArea = rewindArea
assert(abs(centerArea + rewindArea + forwardArea - 1) < 0.01)
val xfrac = ev.x / width
if (xfrac <= rewindArea) {
startFastForward(true)
} else if (xfrac >= 1 - forwardArea) {
startFastForward(false)
} else {
onTogglePlayPause.emit()
}
return true;
}
@@ -0,0 +1,47 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.collection.emptyLongSet
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.views.pills.PillButton
class ButtonsContainer : LinearLayout {
val container_buttons: LinearLayout
var currentButtons: List<Button> = listOf();
constructor(context: Context, buttons: List<Button>) : super(context) {
inflate(context, R.layout.view_buttons, this)
container_buttons = findViewById(R.id.container_buttons);
setButtons(buttons);
}
fun setButtons(buttons: List<Button>) {
this.currentButtons = buttons;
container_buttons.removeAllViews();
for(button in buttons) {
container_buttons.addView(StandardButton(context, button.name) {
button?.handler?.invoke();
}.apply {
this.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
this.weight = 1f;
};
if(button.background != null)
this.withBackground(button.background);
})
}
}
class Button(
val name: String,
val background: Int?,
val handler: (()->Unit),
);
}
@@ -0,0 +1,34 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
class StandardButton : LinearLayout {
private val _root: LinearLayout;
private val _text: TextView;
constructor(context: Context, text: String, onClick: ()->Unit) : super(context) {
inflate(context, R.layout.view_button_standard, this);
_root = findViewById(R.id.root);
_text = findViewById(R.id.text_button);
_text.text = text;
_root.setOnClickListener {
onClick.invoke();
}
}
fun withPrimaryBackground(): StandardButton {
_root.setBackgroundResource(R.drawable.background_button_primary)
return this;
}
fun withAccentBackground(): StandardButton {
_root.setBackgroundResource(R.drawable.background_button_accent)
return this;
}
fun withBackground(id: Int): StandardButton {
_root.setBackgroundResource(id);
return this;
}
}
@@ -13,6 +13,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.OptIn
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar
@@ -43,6 +44,7 @@ class CastView : ConstraintLayout {
private val _buttonSettings: ImageButton;
private val _buttonLoop: ImageButton;
private val _buttonPlay: ImageButton;
private val _buttonAutoplay: ImageButton;
private val _buttonPrevious: ImageButton;
private val _buttonNext: ImageButton;
private val _buttonPause: ImageButton;
@@ -78,6 +80,7 @@ class CastView : ConstraintLayout {
_buttonMinimize = findViewById(R.id.button_minimize);
_buttonSettings = findViewById(R.id.button_settings);
_buttonLoop = findViewById(R.id.button_loop);
_buttonAutoplay = findViewById(R.id.button_autoplay);
_buttonPlay = findViewById(R.id.button_play);
_buttonPrevious = findViewById(R.id.button_previous);
_buttonNext = findViewById(R.id.button_next);
@@ -119,6 +122,15 @@ class CastView : ConstraintLayout {
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
}
}
_gestureControlView.onTogglePlayPause.subscribe {
StateCasting.instance.activeDevice?.let { d ->
if (d.isPlaying) {
d.pausePlayback()
} else {
d.resumePlayback()
}
}
}
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
@@ -169,6 +181,17 @@ class CastView : ConstraintLayout {
updateNextPrevious();
_buttonPrevious.setOnClickListener { onPrevious.emit() };
_buttonNext.setOnClickListener { onNext.emit() };
_buttonAutoplay.setOnClickListener {
StatePlayer.instance.autoplay = !StatePlayer.instance.autoplay;
updateAutoplayButton()
}
updateAutoplayButton()
}
private fun updateAutoplayButton() {
_buttonAutoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
_buttonAutoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
}
private fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
@@ -54,9 +54,14 @@ class CreatorThumbnail : ConstraintLayout {
setNewActivity(false);
}
fun setThumbnail(url: String?, animate: Boolean) {
fun setThumbnail(url: String?, animate: Boolean, isArtist: Boolean = false) {
if (url == null) {
clear();
if(isArtist) {
_imageChannelThumbnail.setImageResource(R.drawable.ic_artist);
_imageChannelThumbnail.visibility = View.VISIBLE;
}
else
clear();
return;
}
@@ -78,18 +83,21 @@ class CreatorThumbnail : ConstraintLayout {
} else {
setHarborAvailable(false, animate, null);
}
var placeholder = R.drawable.placeholder_channel_thumbnail;
if(url.startsWith("content://") || isArtist)
placeholder = R.drawable.ic_artist;
if (animate) {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.placeholder(placeholder)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.crossfade()
.into(_imageChannelThumbnail)
} else {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.placeholder(placeholder)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(_imageChannelThumbnail);
}
@@ -7,22 +7,27 @@ import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.compose.ui.graphics.Color
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.dp
import com.futo.platformplayer.views.LoaderView
class PillButton : LinearLayout {
val root: LinearLayout;
val icon: ImageView;
val text: TextView;
val loaderView: LoaderView;
val onClick = Event0();
private var _isLoading = false;
constructor(context : Context, attrs : AttributeSet) : super(context, attrs) {
constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
icon = findViewById(R.id.pill_icon);
text = findViewById(R.id.pill_text);
loaderView = findViewById(R.id.loader)
root = findViewById<LinearLayout>(R.id.root);
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0);
val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1);
@@ -34,6 +39,13 @@ class PillButton : LinearLayout {
val attrText = attrArr.getText(R.styleable.PillButton_pillText) ?: "";
text.text = attrText;
if(text.text.isNullOrBlank()) {
val dp6 = 6.dp(resources);
val dp7 = 7.dp(resources);
val dp12 = 12.dp(resources);
root.setPadding(dp7, dp6, dp7, dp7)
}
findViewById<LinearLayout>(R.id.root).setOnClickListener {
if (_isLoading) {
return@setOnClickListener
@@ -43,6 +55,10 @@ class PillButton : LinearLayout {
};
}
fun setTransparant() {
root.setBackgroundColor(0);
}
fun setLoading(loading: Boolean) {
if (loading == _isLoading) {
return
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
@@ -224,6 +225,12 @@ class CommentsList : ConstraintLayout {
_commentsPager = pager;
onCommentsLoaded.emit(_comments.size);
}
fun clearComments() {
_comments.clear();
_adapterComments.notifyDataSetChanged();
_commentsPager = EmptyPager();
onCommentsLoaded.emit(0);
}
fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) {
cancel();
@@ -285,6 +285,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
player.play()
}
}
gestureControl.onTogglePlayPause.subscribe {
exoPlayer?.player?.let { player ->
if (player.playWhenReady) {
player.pause()
} else {
player.play()
}
}
}
gestureControl.onSpeedHoldEnd.subscribe {
exoPlayer?.player?.let { player ->
if (!_speedHoldWasPlaying) player.pause()
@@ -29,6 +29,8 @@ import androidx.media3.exoplayer.dash.manifest.DashManifestParser
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker
import androidx.media3.exoplayer.source.BehindLiveWindowException
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
@@ -77,6 +79,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
@@ -920,7 +923,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class)
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
val sourceVideo = _lastVideoMediaSource;
val sourceAudio = _lastAudioMediaSource;
val sourceSubs = _lastSubtitleMediaSource;
@@ -1008,7 +1011,19 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@Suppress("DEPRECATION")
protected open fun onPlayerError(error: PlaybackException) {
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss");
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}");
if(error is BehindLiveWindowException) {
Logger.e(TAG, "BehindLiveWindowException, " + error.message);
reloadMediaSource(true, true);
return;
}
if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) {
Logger.e(TAG, "PlaylistStuckException");
reloadMediaSource(true, true);
UIDialogs.toast("Live playback error, reloading..");
return;
}
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
Binary file not shown.

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