diff --git a/.gitattributes b/.gitattributes
index 173a6f10..24600b6d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/app/build.gradle b/app/build.gradle
index 57a3a761..0e8c9e2e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -146,6 +146,7 @@ android {
}
sourceSets {
main {
+ jniLibs.srcDirs = ['src/main/jniLibs']
assets {
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 826ceb29..d271724b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,6 +16,9 @@
+
+
+
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()
+ }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt
index 3166613e..1da4b6fe 100644
--- a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt
+++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt
@@ -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 Deferred.awaitCancelConverted(): T {
}
throw ex;
}
+}
+
+fun IPager.toList(): List {
+ val list = this.getResults().toMutableList();
+
+ while(this.hasMorePages()) {
+ this.nextPage();
+ list.addAll(this.getResults());
+ }
+
+ return list.toList();
}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt
index 8315fa15..90ac51bb 100644
--- a/app/src/main/java/com/futo/platformplayer/Settings.kt
+++ b/app/src/main/java/com/futo/platformplayer/Settings.kt
@@ -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");
}
}
@@ -760,7 +759,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 +776,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 +844,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 +859,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 +896,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 +914,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 +954,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 +963,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 +980,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 +998,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 +1018,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 +1120,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 +1131,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),
)
diff --git a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt
index d79c5b3b..13074c31 100644
--- a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt
+++ b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt
@@ -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()
@@ -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");
}
diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
index 409adbf5..75681154 100644
--- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
+++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
@@ -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(), mainContext.getString(R.string.background_update))
+ }
}, UIDialogs.ActionStyle.PRIMARY)
);
}
diff --git a/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt
deleted file mode 100644
index b8dbb261..00000000
--- a/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt
+++ /dev/null
@@ -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;
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
index dc2caa63..2679d338 100644
--- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
+++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
@@ -33,6 +33,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
+import com.curlbind.Libcurl
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.RootInsetsController
@@ -52,17 +53,27 @@ 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.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,16 @@ 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 _fragBrowser: BrowserFragment;
@@ -220,6 +243,17 @@ 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 +309,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 +353,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
_fragTopBarImport = ImportTopBarFragment.newInstance();
_fragTopBarAdd = AddTopBarFragment.newInstance();
+ _fragTopBarFiles = FilesTopBarFragment.newInstance();
//BotBars
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
@@ -350,6 +386,16 @@ 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();
_fragBrowser = BrowserFragment.newInstance();
@@ -481,6 +527,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;
@@ -1256,6 +1312,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 +1337,16 @@ 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;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
diff --git a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt
deleted file mode 100644
index f7513f6e..00000000
--- a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt
+++ /dev/null
@@ -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()?.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 = mutableMapOfUnit>();
- private var requestCode: Int? = -1;
- private val resultLauncher: ActivityResultLauncher = 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;
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt
index 3f6e5b82..1d118f95 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt
@@ -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
+ = 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>?): IPager {
+ return EmptyPager(); //TODO
+ }
+
+ override fun getSearchChannelContentsCapabilities(): ResultCapabilities
+ = ResultCapabilities();
+ override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map>?): IPager {
+ return EmptyPager(); //TODO
+ }
+
+ override fun searchChannels(query: String): IPager {
+ return EmptyPager(); //TODO
+ }
+
+ override fun searchChannelsAsContent(query: String): IPager {
+ 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>?): IPager {
+ return EmptyPager();
+ }
+
+ override fun getChannelPlaylists(channelUrl: String): IPager {
+ return EmptyPager();
+ }
+
+ override fun getPeekChannelTypes(): List = listOf();
+
+ override fun peekChannelContents(channelUrl: String, type: String?): List
+ = listOf();
+
+ override fun getShorts(): IPager = EmptyPager();
+
+ override fun searchSuggestions(query: String): Array = arrayOf();
+
+ override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String?
+ = null;
+
+ override fun getContentChapters(url: String): List
+ = listOf();
+
+ override fun getPlaybackTracker(url: String): IPlaybackTracker?
+ = null;
+
+ override fun getContentRecommendations(url: String): IPager?
+ = null;
+
+ override fun getComments(url: String): IPager
+ = EmptyPager();
+
+ override fun getSubComments(comment: IPlatformComment): IPager
+ = EmptyPager();
+
+ override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?
+ = null;
+
+ override fun getLiveEvents(url: String): IPager?
+ = null;
+
+ override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager
+ = throw NotImplementedError();
+
+ override fun isPlaylistUrl(url: String): Boolean = false;
+
+ override fun getPlaylist(url: String): IPlatformPlaylistDetails
+ = throw NotImplementedError();
+ override fun getUserPlaylists(): Array = throw NotImplementedError();
+ override fun getUserSubscriptions(): Array = throw NotImplementedError();
+ override fun getUserHistory(): IPager = throw NotImplementedError();
+ override fun isClaimTypeSupported(claimType: Int): Boolean = false;
}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt
index 40613271..41834cb0 100644
--- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt
+++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt
@@ -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}");
diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/Libcurl.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/Libcurl.kt
new file mode 100644
index 00000000..ed0b90bb
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/engine/packages/Libcurl.kt
@@ -0,0 +1,217 @@
+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 = emptyMap(),
+ var body: ByteArray? = null,
+ var impersonateTarget: String = "chrome136",
+ var useBuiltInHeaders: Boolean = true,
+ var timeoutMs: Int = 30_000,
+ var cookieJarPath: String? = null,
+ var sendCookies: Boolean = true,
+ var persistCookies: Boolean = true,
+ )
+
+ @Keep
+ data class Response(
+ val status: Int,
+ val effectiveUrl: String,
+ val bodyBytes: ByteArray,
+ val headers: Map>
+ )
+
+ 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(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))
+ }
+
+ if (req.sendCookies || req.persistCookies) {
+ val jar = (req.cookieJarPath ?: defaultCookieJarPath())
+ if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar))
+ if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar))
+ }
+
+ 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): Map> {
+ val map = linkedMapOf>()
+ 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
+}
diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt
index aee754b0..2ea1cc5a 100644
--- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt
+++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt
@@ -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;
diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttpImp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttpImp.kt
new file mode 100644
index 00000000..89c2b1f6
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttpImp.kt
@@ -0,0 +1,787 @@
+package com.futo.platformplayer.engine.packages
+
+import com.caoccao.javet.annotations.V8Convert
+import com.caoccao.javet.annotations.V8Function
+import com.caoccao.javet.annotations.V8Property
+import com.caoccao.javet.enums.V8ConversionMode
+import com.caoccao.javet.enums.V8ProxyMode
+import com.caoccao.javet.interop.V8Runtime
+import com.caoccao.javet.values.V8Value
+import com.caoccao.javet.values.primitive.V8ValueString
+import com.caoccao.javet.values.reference.V8ValueTypedArray
+import com.curlbind.Libcurl
+import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
+import com.futo.platformplayer.engine.IV8PluginConfig
+import com.futo.platformplayer.engine.V8Plugin
+import com.futo.platformplayer.engine.internal.IV8Convertable
+import com.futo.platformplayer.engine.internal.V8BindObject
+import com.futo.platformplayer.logging.Logger
+import java.net.SocketTimeoutException
+import java.nio.charset.Charset
+import java.util.UUID
+import java.util.concurrent.ForkJoinPool
+import java.util.concurrent.ForkJoinTask
+import kotlin.math.min
+
+class PackageHttpImp : V8Package {
+ @Transient
+ internal val _config: IV8PluginConfig
+
+ @Transient
+ private val _packageClient: PackageHttpClient
+
+ @Transient
+ private val _packageClientAuth: PackageHttpClient
+
+ override val name: String get() = "HttpImp"
+ override val variableName: String get() = "httpimp"
+
+ private var _batchPoolLock: Any = Any()
+ private var _batchPool: ForkJoinPool? = null
+
+ private val _clients = mutableMapOf()
+
+ constructor(plugin: V8Plugin, config: IV8PluginConfig) : super(plugin) {
+ _config = config
+ _packageClient = PackageHttpClient(this, withAuth = false)
+ _packageClientAuth = PackageHttpClient(this, withAuth = true)
+ }
+
+ fun cleanup() {
+ Logger.w(TAG, "PackageHttpImp Cleaning up")
+ }
+
+ private fun autoParallelPool(
+ data: List,
+ parallelism: Int,
+ handle: (T) -> R
+ ): List> {
+ synchronized(_batchPoolLock) {
+ val threadsToUse = if (parallelism <= 0) data.size else min(parallelism, data.size)
+ if (_batchPool == null) {
+ _batchPool = ForkJoinPool(threadsToUse)
+ }
+ var pool = _batchPool ?: return listOf()
+ if (pool.poolSize < threadsToUse) {
+ pool.shutdown()
+ _batchPool = ForkJoinPool(threadsToUse)
+ pool = _batchPool ?: return listOf()
+ }
+
+ val resultTasks = mutableListOf>>()
+ for (item in data) {
+ resultTasks.add(
+ pool.submit> {
+ try {
+ Pair(handle(item), null)
+ } catch (ex: Throwable) {
+ Pair(null, ex)
+ }
+ }
+ )
+ }
+ return resultTasks.map { it.join() }
+ }
+ }
+
+ @V8Function
+ fun newClient(withAuth: Boolean): PackageHttpClient {
+ val client = PackageHttpClient(this, withAuth)
+ client.clientId()?.let { _clients[it] = client }
+ return client
+ }
+
+ @V8Function
+ fun getDefaultClient(withAuth: Boolean): PackageHttpClient {
+ return if (withAuth) _packageClientAuth else _packageClient
+ }
+
+ fun getClient(id: String?): PackageHttpClient {
+ if (id == null) throw IllegalArgumentException("Http client $id doesn't exist")
+ if (_packageClient.clientId() == id) return _packageClient
+ if (_packageClientAuth.clientId() == id) return _packageClientAuth
+ return _clients[id] ?: throw IllegalArgumentException("Http client $id doesn't exist")
+ }
+
+ @V8Function
+ fun batch(): BatchBuilder {
+ return BatchBuilder(this)
+ }
+
+ @V8Function
+ fun request(
+ method: String,
+ url: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false,
+ bytesResult: Boolean = false
+ ): IBridgeHttpResponse {
+ val client = if (useAuth) _packageClientAuth else _packageClient
+ return client.requestInternal(
+ method,
+ url,
+ headers,
+ if (bytesResult) ReturnType.BYTES else ReturnType.STRING
+ )
+ }
+
+ @V8Function
+ fun requestWithBody(
+ method: String,
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false,
+ bytesResult: Boolean = false
+ ): IBridgeHttpResponse {
+ val client = if (useAuth) _packageClientAuth else _packageClient
+ return client.requestWithBodyInternal(
+ method,
+ url,
+ body,
+ headers,
+ if (bytesResult) ReturnType.BYTES else ReturnType.STRING
+ )
+ }
+
+ @V8Function
+ fun GET(
+ url: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false,
+ useByteResponse: Boolean = false
+ ): IBridgeHttpResponse {
+ val client = if (useAuth) _packageClientAuth else _packageClient
+ return client.GETInternal(
+ url,
+ headers,
+ if (useByteResponse) ReturnType.BYTES else ReturnType.STRING
+ )
+ }
+
+ @V8Function
+ fun POST(
+ url: String,
+ body: Any,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false,
+ useByteResponse: Boolean = false
+ ): IBridgeHttpResponse {
+ val client = if (useAuth) _packageClientAuth else _packageClient
+
+ return when (body) {
+ is V8ValueString ->
+ client.POSTInternal(url, body.value, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
+ is String ->
+ client.POSTInternal(url, body, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
+ is V8ValueTypedArray ->
+ client.POSTInternal(url, body.toBytes(), headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
+ is ByteArray ->
+ client.POSTInternal(url, body, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
+ is ArrayList<*> ->
+ client.POSTInternal(
+ url,
+ body.map { (it as Double).toInt().toByte() }.toByteArray(),
+ headers,
+ if (useByteResponse) ReturnType.BYTES else ReturnType.STRING
+ )
+ else -> throw NotImplementedError("Body type ${body?.javaClass?.name} not implemented for POST")
+ }
+ }
+
+ private fun logExceptions(handle: () -> T): T {
+ try {
+ return handle()
+ } catch (ex: Exception) {
+ Logger.e("Plugin[${_config.name}]", ex.message, ex)
+ throw ex
+ }
+ }
+
+ interface IBridgeHttpResponse {
+ val url: String
+ val code: Int
+ val headers: Map>?
+ }
+
+ @kotlinx.serialization.Serializable
+ class BridgeHttpStringResponse(
+ override val url: String,
+ override val code: Int,
+ val body: String?,
+ override val headers: Map>? = null
+ ) : IV8Convertable, IBridgeHttpResponse {
+ val isOk = code in 200..299
+
+ override fun toV8(runtime: V8Runtime): V8Value? {
+ val obj = runtime.createV8ValueObject()
+ obj.set("url", url)
+ obj.set("code", code)
+ obj.set("body", body)
+ obj.set("headers", headers)
+ obj.set("isOk", isOk)
+ return obj
+ }
+ }
+
+ @kotlinx.serialization.Serializable
+ class BridgeHttpBytesResponse(
+ override val url: String,
+ override val code: Int,
+ val body: ByteArray? = null,
+ override val headers: Map>? = null
+ ) : IV8Convertable, IBridgeHttpResponse {
+ val isOk: Boolean = code in 200..299
+
+ override fun toV8(runtime: V8Runtime): V8Value? {
+ val obj = runtime.createV8ValueObject()
+ obj.set("url", url)
+ obj.set("code", code)
+ if (body != null) {
+ obj.set("body", body)
+ }
+ obj.set("headers", headers)
+ obj.set("isOk", isOk)
+ return obj
+ }
+ }
+
+ @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
+ class BatchBuilder(
+ @Transient private val _package: PackageHttpImp,
+ existingRequests: MutableList> = mutableListOf()
+ ) : V8BindObject() {
+ @Transient
+ private val _reqs = existingRequests
+
+ @V8Function
+ fun request(
+ method: String,
+ url: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false
+ ): BatchBuilder {
+ return clientRequest(_package.getDefaultClient(useAuth).clientId(), method, url, headers)
+ }
+
+ @V8Function
+ fun requestWithBody(
+ method: String,
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false
+ ): BatchBuilder {
+ return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers)
+ }
+
+ @V8Function
+ fun GET(
+ url: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false
+ ): BatchBuilder =
+ clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers)
+
+ @V8Function
+ fun POST(
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap(),
+ useAuth: Boolean = false
+ ): BatchBuilder =
+ clientPOST(_package.getDefaultClient(useAuth).clientId(), url, body, headers)
+
+ @V8Function
+ fun DUMMY(): BatchBuilder {
+ _reqs.add(
+ Pair(
+ _package.getDefaultClient(false),
+ RequestDescriptor("DUMMY", "", mutableMapOf())
+ )
+ )
+ return BatchBuilder(_package, _reqs)
+ }
+
+ @V8Function
+ fun clientRequest(
+ clientId: String?,
+ method: String,
+ url: String,
+ headers: MutableMap = HashMap()
+ ): BatchBuilder {
+ _reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers)))
+ return BatchBuilder(_package, _reqs)
+ }
+
+ @V8Function
+ fun clientRequestWithBody(
+ clientId: String?,
+ method: String,
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap()
+ ): BatchBuilder {
+ _reqs.add(
+ Pair(
+ _package.getClient(clientId),
+ RequestDescriptor(method, url, headers, body)
+ )
+ )
+ return BatchBuilder(_package, _reqs)
+ }
+
+ @V8Function
+ fun clientGET(
+ clientId: String?,
+ url: String,
+ headers: MutableMap = HashMap()
+ ): BatchBuilder =
+ clientRequest(clientId, "GET", url, headers)
+
+ @V8Function
+ fun clientPOST(
+ clientId: String?,
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap()
+ ): BatchBuilder =
+ clientRequestWithBody(clientId, "POST", url, body, headers)
+
+ @V8Function
+ fun execute(): List {
+ return _package.autoParallelPool(_reqs, -1) {
+ if (it.second.method == "DUMMY") {
+ return@autoParallelPool null
+ }
+ if (it.second.body != null) {
+ it.first.requestWithBodyInternal(
+ it.second.method,
+ it.second.url,
+ it.second.body!!,
+ it.second.headers,
+ it.second.respType
+ )
+ } else {
+ it.first.requestInternal(
+ it.second.method,
+ it.second.url,
+ it.second.headers,
+ it.second.respType
+ )
+ }
+ }.map {
+ if (it.second != null) throw it.second!!
+ it.first
+ }.toList()
+ }
+ }
+
+ @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
+ class PackageHttpClient : V8BindObject {
+ @Transient
+ private val _package: PackageHttpImp
+
+ @Transient
+ private val _withAuth: Boolean
+
+ val parentConfig: IV8PluginConfig
+ get() = _package._config
+
+ @Transient
+ private val _defaultHeaders = mutableMapOf()
+
+ @Transient
+ private val _clientId: String = UUID.randomUUID().toString()
+
+ @Volatile
+ private var timeoutMs: Int = 30_000
+
+ @Volatile
+ private var sendCookies: Boolean = true
+
+ @Volatile
+ private var persistCookies: Boolean = true
+
+ @Volatile
+ private var cookieJarPath: String? = null
+
+ @Volatile
+ private var impersonateTarget: String = "chrome136"
+
+ @Volatile
+ private var useBuiltInHeaders: Boolean = true
+
+ @V8Property
+ fun clientId(): String? = _clientId
+
+ constructor(pack: PackageHttpImp, withAuth: Boolean) : super() {
+ _package = pack
+ _withAuth = withAuth
+ }
+
+ private fun ensureCookieJarPath(): String {
+ val existing = cookieJarPath
+ if (existing != null) return existing
+
+ val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
+ val safeName = parentConfig.name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+ val fileName =
+ if (_withAuth) "imphttp.$safeName.auth.cookies.txt" else "imphttp.$safeName.cookies.txt"
+ val path = if (tmp.endsWith("/")) tmp + fileName else "$tmp/$fileName"
+ cookieJarPath = path
+ return path
+ }
+
+ @V8Function
+ fun setDefaultHeaders(defaultHeaders: Map) {
+ synchronized(_defaultHeaders) {
+ for (pair in defaultHeaders) {
+ _defaultHeaders[pair.key] = pair.value
+ }
+ }
+ }
+
+ @V8Function
+ fun setDoApplyCookies(apply: Boolean) {
+ sendCookies = apply
+ }
+
+ @V8Function
+ fun setDoUpdateCookies(update: Boolean) {
+ persistCookies = update
+ }
+
+ @V8Function
+ fun setDoAllowNewCookies(allow: Boolean) {
+ persistCookies = allow
+ }
+
+ @V8Function
+ fun setTimeout(timeoutMs: Int) {
+ this.timeoutMs = timeoutMs
+ }
+
+ @V8Function
+ fun request(
+ method: String,
+ url: String,
+ headers: MutableMap = HashMap(),
+ useBytes: Boolean = false
+ ): IBridgeHttpResponse =
+ requestInternal(method, url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
+
+ fun requestInternal(
+ method: String,
+ url: String,
+ headers: MutableMap = HashMap(),
+ returnType: ReturnType
+ ): IBridgeHttpResponse {
+ applyDefaultHeaders(headers)
+ return logExceptions {
+ catchHttp {
+ val resp = performCurl(method, url, headers, null)
+ responseToBridge(resp, returnType)
+ }
+ }
+ }
+
+ @V8Function
+ fun requestWithBody(
+ method: String,
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap(),
+ useBytes: Boolean = false
+ ): IBridgeHttpResponse =
+ requestWithBodyInternal(
+ method,
+ url,
+ body,
+ headers,
+ if (useBytes) ReturnType.BYTES else ReturnType.STRING
+ )
+
+ fun requestWithBodyInternal(
+ method: String,
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap(),
+ returnType: ReturnType
+ ): IBridgeHttpResponse {
+ applyDefaultHeaders(headers)
+ return logExceptions {
+ catchHttp {
+ val resp = performCurl(method, url, headers, body.toByteArray())
+ responseToBridge(resp, returnType)
+ }
+ }
+ }
+
+ @V8Function
+ fun GET(
+ url: String,
+ headers: MutableMap = HashMap(),
+ useBytes: Boolean = false
+ ): IBridgeHttpResponse =
+ GETInternal(url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
+
+ fun GETInternal(
+ url: String,
+ headers: MutableMap = HashMap(),
+ returnType: ReturnType = ReturnType.STRING
+ ): IBridgeHttpResponse {
+ applyDefaultHeaders(headers)
+ return logExceptions {
+ catchHttp {
+ val resp = performCurl("GET", url, headers, null)
+ responseToBridge(resp, returnType)
+ }
+ }
+ }
+
+ @V8Function
+ fun POST(
+ url: String,
+ body: Any,
+ headers: MutableMap = HashMap(),
+ useBytes: Boolean = false
+ ): IBridgeHttpResponse {
+ return when (body) {
+ is V8ValueString ->
+ POSTInternal(url, body.value, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
+ is String ->
+ POSTInternal(url, body, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
+ is V8ValueTypedArray ->
+ POSTInternal(url, body.toBytes(), headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
+ is ByteArray ->
+ POSTInternal(url, body, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
+ is ArrayList<*> ->
+ POSTInternal(
+ url,
+ body.map { (it as Double).toInt().toByte() }.toByteArray(),
+ headers,
+ if (useBytes) ReturnType.BYTES else ReturnType.STRING
+ )
+ else -> throw NotImplementedError("Body type ${body?.javaClass?.name} not implemented for POST")
+ }
+ }
+
+ fun POSTInternal(
+ url: String,
+ body: String,
+ headers: MutableMap = HashMap(),
+ returnType: ReturnType = ReturnType.STRING
+ ): IBridgeHttpResponse {
+ applyDefaultHeaders(headers)
+ return logExceptions {
+ catchHttp {
+ val resp = performCurl("POST", url, headers, body.toByteArray())
+ responseToBridge(resp, returnType)
+ }
+ }
+ }
+
+ fun POSTInternal(
+ url: String,
+ body: ByteArray,
+ headers: MutableMap = HashMap(),
+ returnType: ReturnType = ReturnType.STRING
+ ): IBridgeHttpResponse {
+ applyDefaultHeaders(headers)
+ return logExceptions {
+ catchHttp {
+ val resp = performCurl("POST", url, headers, body)
+ responseToBridge(resp, returnType)
+ }
+ }
+ }
+
+ private fun performCurl(
+ method: String,
+ url: String,
+ headers: Map,
+ bodyBytes: ByteArray?
+ ): Libcurl.Response {
+ val jar = ensureCookieJarPath()
+
+ val req = Libcurl.Request(
+ url = url,
+ method = method,
+ headers = headers,
+ body = bodyBytes,
+ impersonateTarget = impersonateTarget,
+ useBuiltInHeaders = useBuiltInHeaders,
+ timeoutMs = timeoutMs,
+ cookieJarPath = jar,
+ sendCookies = sendCookies,
+ persistCookies = persistCookies
+ )
+ return Libcurl.perform(req)
+ }
+
+ private fun responseToBridge(
+ resp: Libcurl.Response,
+ returnType: ReturnType
+ ): IBridgeHttpResponse {
+ val sanitizedHeaders = sanitizeResponseHeaders(resp.headers, shouldWhitelistHeaders())
+ return when (returnType) {
+ ReturnType.STRING -> {
+ val bodyStr = decodeBody(resp)
+ BridgeHttpStringResponse(resp.effectiveUrl, resp.status, bodyStr, sanitizedHeaders)
+ }
+ ReturnType.BYTES -> {
+ BridgeHttpBytesResponse(resp.effectiveUrl, resp.status, resp.bodyBytes, sanitizedHeaders)
+ }
+ }
+ }
+
+ private fun decodeBody(resp: Libcurl.Response): String {
+ if (resp.bodyBytes.isEmpty()) return ""
+
+ val contentTypeHeader = resp.headers.entries.firstOrNull {
+ it.key.equals("content-type", ignoreCase = true)
+ }?.value?.firstOrNull()
+
+ val charset: Charset = contentTypeHeader
+ ?.let { parseCharset(it) }
+ ?: Charsets.UTF_8
+
+ return String(resp.bodyBytes, charset)
+ }
+
+ private fun parseCharset(contentType: String): Charset? {
+ val parts = contentType.split(";")
+ for (part in parts) {
+ val trimmed = part.trim()
+ val lower = trimmed.lowercase()
+ if (lower.startsWith("charset=")) {
+ val value = trimmed.substringAfter("=", "").trim().trim('"', '\'')
+ return try {
+ Charset.forName(value)
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+ return null
+ }
+
+ private fun shouldWhitelistHeaders(): Boolean {
+ val cfg = parentConfig
+ return !(cfg is SourcePluginConfig && cfg.allowAllHttpHeaderAccess)
+ }
+
+ private fun applyDefaultHeaders(headerMap: MutableMap) {
+ synchronized(_defaultHeaders) {
+ for (toApply in _defaultHeaders) {
+ if (!headerMap.containsKey(toApply.key)) {
+ headerMap[toApply.key] = toApply.value
+ }
+ }
+ }
+ }
+
+ private fun sanitizeResponseHeaders(
+ headers: Map>?,
+ onlyWhitelisted: Boolean = false
+ ): Map> {
+ val result = mutableMapOf>()
+ if (onlyWhitelisted) {
+ headers?.forEach { (header, values) ->
+ val lowerCaseHeader = header.lowercase()
+ if (WHITELISTED_RESPONSE_HEADERS.contains(lowerCaseHeader)) {
+ result[lowerCaseHeader] = values
+ }
+ }
+ } else {
+ headers?.forEach { (header, values) ->
+ val lowerCaseHeader = header.lowercase()
+ if (lowerCaseHeader == "set-cookie" &&
+ !values.any { it.lowercase().contains("httponly") }
+ ) {
+ result[lowerCaseHeader] = values
+ } else {
+ result[lowerCaseHeader] = values
+ }
+ }
+ }
+ return result
+ }
+
+ private fun logRequest(
+ method: String,
+ url: String,
+ headers: Map = HashMap(),
+ body: String?
+ ) {
+ Logger.v(TAG) {
+ val sb = StringBuilder()
+ sb.appendLine("HTTP request (libcurl)")
+ sb.appendLine("$method $url")
+ for (pair in headers) {
+ sb.appendLine("${pair.key}: ${pair.value}")
+ }
+ if (body != null) {
+ sb.appendLine()
+ sb.appendLine(body)
+ }
+ sb.toString()
+ }
+ }
+
+ fun logExceptions(handle: () -> T): T {
+ try {
+ return handle()
+ } catch (ex: Exception) {
+ Logger.e("Plugin[${_package._config.name}]", ex.message, ex)
+ throw ex
+ }
+ }
+
+ private fun catchHttp(handle: () -> IBridgeHttpResponse): IBridgeHttpResponse {
+ return try {
+ handle()
+ } catch (ex: SocketTimeoutException) {
+ BridgeHttpStringResponse("", 408, null)
+ }
+ }
+ }
+
+ data class RequestDescriptor(
+ val method: String,
+ val url: String,
+ val headers: MutableMap,
+ val body: String? = null,
+ val contentType: String? = null,
+ val respType: ReturnType = ReturnType.STRING
+ )
+
+ private fun catchHttp(handle: () -> BridgeHttpStringResponse): BridgeHttpStringResponse {
+ return try {
+ handle()
+ } catch (ex: SocketTimeoutException) {
+ BridgeHttpStringResponse("", 408, null)
+ }
+ }
+
+ enum class ReturnType(val value: Int) {
+ STRING(0),
+ BYTES(1);
+ }
+
+ companion object {
+ private const val TAG = "PackageHttpImp"
+ private val WHITELISTED_RESPONSE_HEADERS = listOf(
+ "content-type",
+ "date",
+ "content-length",
+ "last-modified",
+ "etag",
+ "cache-control",
+ "content-encoding",
+ "content-disposition",
+ "connection"
+ )
+ }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
index f6e57c26..70951c30 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
@@ -20,7 +20,6 @@ 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.dp
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
@@ -143,6 +142,10 @@ class MenuBottomBarFragment : MainActivityFragment() {
moreOverlay.visibility = VISIBLE
val animations = arrayListOf()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
+ _bottomButtons.find { it.definition.id == 99 }?.let {
+ animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.4f, 1.0f)
+ .setDuration(duration));
+ }
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
@@ -158,7 +161,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.start()
} else {
val animations = arrayListOf()
- animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f).setDuration(duration))
+ animations
+ .add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
+ .setDuration(duration))
+ _bottomButtons.find { it.definition.id == 99 }?.let {
+ animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.4f)
+ .setDuration(duration));
+ }
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
@@ -260,7 +269,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
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?) {
@@ -354,7 +363,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.4f;
+ }
_textButton = findViewById(R.id.text_button);
_textButton.text = resources.getString(def.string);
@@ -365,8 +381,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.4f;
+ }
}
}
}
@@ -389,6 +413,7 @@ 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(withHistory = false) }),
+ ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate(withHistory = false) }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }),
@@ -399,6 +424,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(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(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
+ it.navigate();
+ /*
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment().preventPictureInPicture();
@@ -406,7 +433,7 @@ 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",
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt
index cb7261e5..75fdc0a2 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt
@@ -64,7 +64,7 @@ abstract class ContentFeedView : FeedView 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);
}
}
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DeveloperFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DeveloperFragment.kt
new file mode 100644
index 00000000..095a09be
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DeveloperFragment.kt
@@ -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(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() {
+
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt
index a9ea33b2..2a19fc6e 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt
@@ -39,6 +39,7 @@ import java.time.OffsetDateTime
import kotlin.math.max
abstract class FeedView : LinearLayout where TPager : IPager, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
+ protected val _feedRoot: FrameLayout;
protected val _recyclerResults: RecyclerView;
protected val _overlayContainer: FrameLayout;
protected val _swipeRefresh: SwipeRefreshLayout;
@@ -67,7 +68,7 @@ abstract class FeedView : L
private var _sortByOptions: List? = null;
private var _activeTags: List? = null;
- private var _nextPageHandler: TaskHandler>;
+ private var _nextPageHandler: TaskHandler>>;
val recyclerData: RecyclerData, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>;
val fragment: TFragment;
@@ -80,6 +81,7 @@ abstract class FeedView : 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);
@@ -135,23 +137,27 @@ abstract class FeedView : L
_toolbarContentView = findViewById(R.id.container_toolbar_content);
- _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, {
+ _nextPageHandler = TaskHandler>>({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 {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -390,6 +396,9 @@ abstract class FeedView : L
protected fun finishRefreshLayoutLoader() {
_swipeRefresh.isRefreshing = false;
}
+ protected fun disableRefreshLayout() {
+ _swipeRefresh.isEnabled = false;
+ }
fun clearResults(){
setPager(EmptyPager() as TPager);
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt
new file mode 100644
index 00000000..34962c83
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt
@@ -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, TrackViewHolder> {
+ override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
+
+ private val _header: AlbumHeaderView;
+
+ private var _album: Album? = null;
+ private var _tracks: List? = 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 {
+ 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): InsertedViewAdapterWithLoader {
+ 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";
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt
new file mode 100644
index 00000000..5d4e282a
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt
@@ -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, 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();
+ 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({ listOf(); }, initialAlbums));
+ }
+
+ override fun reload() {
+ super.reload();
+ finishRefreshLayoutLoader();
+ }
+
+ override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader {
+ 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(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(
+ LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album,
+ _viewGroup, false)) {
+
+ val onClick = Event1();
+
+ 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 ?: "";
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt
new file mode 100644
index 00000000..878cc8a5
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt
@@ -0,0 +1,617 @@
+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.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.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(c) }
+ adapter.onContentClicked.subscribe { v, _ ->
+ when (v) {
+ is IPlatformVideo -> {
+ StatePlayer.instance.clearQueue()
+ fragment.navigate(v).maximizeVideoDetail()
+ }
+
+ is IPlatformPlaylist -> {
+ fragment.navigate(v)
+ }
+
+ is IPlatformPost -> {
+ fragment.navigate(v)
+ }
+ }
+ }
+ adapter.onShortClicked.subscribe { v, _, pagerPair ->
+ when (v) {
+ is IPlatformVideo -> {
+ StatePlayer.instance.clearQueue()
+ fragment.navigate(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(url)
+ }
+ adapter.onContentUrlClicked.subscribe { url, contentType ->
+ when (contentType) {
+ ContentType.MEDIA -> {
+ StatePlayer.instance.clearQueue()
+ fragment.navigate(url).maximizeVideoDetail()
+ }
+
+ ContentType.URL -> fragment.navigate(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(R.drawable.ic_playlist_add) {
+ })
+
+ _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(
+ SuggestionsFragmentData(
+ "", SearchType.VIDEO
+ )
+ )
+ })
+ _fragment.topBar?.assume()?.setMenuItems(buttons)
+ }
+ }
+
+ _buttonSubscribe.visibility = GONE;
+ _buttonSubscriptionSettings.visibility =
+ if (_buttonSubscribe.isSubscribed) View.VISIBLE else 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()
+
+ 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()
+ val onUrlClicked = Event1()
+ val onContentClicked = Event2()
+ val onShortClicked = Event3, ArrayList>?>()
+ val onChannelClicked = Event1()
+ val onAddToClicked = Event1()
+ val onAddToQueueClicked = Event1()
+ val onAddToWatchLaterClicked = Event1()
+ val onLongPress = Event1()
+
+ 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, 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): List {
+ return results.filter { it is IPlatformVideo }.map { it as IPlatformVideo };
+ }
+
+ override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader {
+ 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 index = playlist.videos.indexOf(c);
+ 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, 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): InsertedViewAdapterWithLoader {
+ 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(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";
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt
new file mode 100644
index 00000000..198cd012
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt
@@ -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, 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();
+ 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({ listOf(); }, intialArtists));
+ }
+ finally {
+ setLoading(false);
+ }
+ }
+
+ override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader {
+ 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(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(
+ LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_artist,
+ _viewGroup, false)) {
+
+ val onClick = Event1();
+
+ 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(", ");
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt
new file mode 100644
index 00000000..a50c047d
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt
@@ -0,0 +1,234 @@
+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.TextView
+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.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.states.FileEntry
+import com.futo.platformplayer.states.StateLibrary
+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
+
+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, FileViewHolder> {
+ override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
+
+ val navStack = mutableListOf()
+ var buttonUp: BigButton? = null;
+ var buttonAdd: BigButton? = null;
+
+ private var root: FileEntry? = null;
+
+ constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
+ }
+
+ fun onShown(parameter: Any? = null) {
+ this.root = if(parameter is FileEntry) parameter else null;
+ loadTop();
+ }
+ fun loadTop() {
+ var initialDirectories = listOf();
+ 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();
+ }
+ navStack.clear();
+ val entry = FileStack("", 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({ listOf(); }, stack.files));
+ setLoading(false);
+
+ 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): InsertedViewAdapterWithLoader {
+ /*
+ 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(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
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt
new file mode 100644
index 00000000..a28f32c9
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt
@@ -0,0 +1,297 @@
+package com.futo.platformplayer.fragment.mainactivity.main
+
+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 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", {
+ permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
+ }, UIDialogs.ActionStyle.PRIMARY),
+ UIDialogs.Action("Cancel", {
+
+ }, UIDialogs.ActionStyle.NONE));
+ }
+ else -> {
+ permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
+ }
+ }
+ }
+ 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", {
+ permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
+ }, UIDialogs.ActionStyle.PRIMARY),
+ UIDialogs.Action("Cancel", {
+
+ }, UIDialogs.ActionStyle.NONE));
+ }
+ else -> {
+ permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
+ }
+ }
+ }
+
+ val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
+ setPermissionResultAudio(isGranted);
+ });
+ val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
+ setPermissionResultVideo(isGranted);
+ });
+
+ 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 buttonFiles: BigButton;
+
+ val recycler: RecyclerView;
+
+ val adapterFiles: AnyInsertedAdapterView;
+
+ //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(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(R.id.button_files);
+ //metaInfo = findViewById(R.id.meta_info);
+
+ this.allowMusic = allowMusic ?: false;
+ this.allowVideo = allowVideo ?: false;
+
+ sectionArtists.setSection("Artists", {
+ if(this.allowMusic)
+ fragment.navigate();
+ else
+ fragment.requestPermissionMusic();
+ });
+ val adapterArtists = sectionArtists.getAnyAdapter({
+ it.onClick.subscribe {
+ if(it != null)
+ fragment.navigate(it);
+ }
+ });
+ val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
+ adapterArtists.setData(artists);
+
+ sectionAlbums.setSection("Albums", {
+ if(this.allowMusic)
+ fragment.navigate();
+ else
+ fragment.requestPermissionMusic();
+ });
+ val adapterAlbums = sectionAlbums.getAnyAdapter({
+ it.onClick.subscribe {
+ if(it != null)
+ fragment.navigate(it);
+ }
+ });
+ val albums = StateLibrary.instance.getAlbums();
+ adapterAlbums.setData(albums);
+
+
+ sectionVideos.setSection("Videos", {
+ if(this.allowVideo)
+ fragment.navigate();
+ else
+ fragment.requestPermissionVideo();
+ });
+ val adapterVideos = sectionVideos.getAnyAdapter({
+ it.onClick.subscribe {
+ if(it != null)
+ fragment.navigate(it);
+ }
+ });
+ val videos = StateLibrary.instance.getRecentVideos(null, 20);
+ adapterVideos.setData(videos);
+
+ adapterFiles = recycler.asAnyWithViews(
+ arrayListOf(
+ sectionArtists,
+ sectionAlbums,
+ sectionVideos,
+ sectionFiles
+ ),
+ 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(it);
+ }
+ it.onDelete.subscribe {
+ if(it != null) {
+ StateLibrary.instance.deleteFileDirectory(it.path);
+ reloadFiles();
+ }
+ }
+ }
+ );
+ reloadFiles();
+
+
+ /*
+ buttonFiles.onClick.subscribe {
+ fragment.navigate()
+ } */
+ //buttonFiles.setButtonEnabled(false);
+ setMusicPermissions(allowMusic ?: false);
+ setVideoPermissions(allowVideo ?: false);
+ }
+
+ fun reloadFiles() {
+ val files = StateLibrary.instance.getFileDirectories();
+ adapterFiles.setData(files);
+ }
+
+
+ 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");
+ }
+ 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");
+ }
+
+ fun onShown() {
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibrarySearchFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibrarySearchFragment.kt
new file mode 100644
index 00000000..51fefc3d
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibrarySearchFragment.kt
@@ -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;
+
+ val textMetadata: TextView;
+
+ val recycler: RecyclerView;
+
+ val adapterArtists: AnyAdapterView;
+ val adapterSongs: AnyAdapterView;
+ val adapterAlbums: AnyAdapterView;
+
+
+
+ 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(RecyclerView.VERTICAL, false, {
+ it.onClick.subscribe {
+ if(it != null)
+ fragment.navigate(it);
+ }
+ });
+ adapterAlbums = recycler.asAny(RecyclerView.VERTICAL, false, {
+ it.onClick.subscribe {
+ if(it != null)
+ fragment.navigate(it);
+ }
+ });
+ adapterSongs = recycler.asAny(RecyclerView.VERTICAL, false, {
+ it.onClick.subscribe {
+ if(it != null && it is IPlatformVideo)
+ fragment.navigate(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();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt
new file mode 100644
index 00000000..43ca3700
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt
@@ -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 {
+ 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 = 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";
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt
new file mode 100644
index 00000000..78f19f4b
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt
@@ -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);
+ }
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SettingsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SettingsFragment.kt
new file mode 100644
index 00000000..4dd03fc4
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SettingsFragment.kt
@@ -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(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(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()?.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;
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt
index 0452395f..f4fa5118 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt
@@ -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,11 +420,39 @@ 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) {
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
index af228bf7..cbcbce73 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
@@ -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()
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt
index 10deee40..ae890d94 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt
@@ -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?, canEdit: Boolean) {
- if (videos != null && videos.isNotEmpty()) {
- val video = videos.first();
+ protected fun setVideos(videos: List?, 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);
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/FilesTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/FilesTopBarFragment.kt
new file mode 100644
index 00000000..49e76e9e
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/FilesTopBarFragment.kt
@@ -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: ListUnit>>) {
+ _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 { }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt
index 597fc9c7..43f9dc26 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt
@@ -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(SuggestionsFragmentData("", SearchType.CREATOR));
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
navigate(SuggestionsFragmentData("", SearchType.PLAYLIST));
+ } else if (currentMain is LibraryFragment) {
+ navigate();
} else {
navigate(SuggestionsFragmentData("", SearchType.VIDEO));
}
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt
index 15952d0a..6de5394c 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt
@@ -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(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) {
diff --git a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt
index 682ad9be..4b98cdeb 100644
--- a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt
+++ b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt
@@ -29,7 +29,6 @@ class GlideHelper {
req.into(this);
}
-
fun RequestBuilder.crossfade(): RequestBuilder {
return this.transition(DrawableTransitionOptions.withCrossFade());
}
diff --git a/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java b/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java
index 60d962ac..e5e75fef 100644
--- a/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java
+++ b/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java
@@ -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());
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt b/app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt
new file mode 100644
index 00000000..2af75364
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt
@@ -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 {
+
+ 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? {
+ 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 {
+
+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MediaStoreThumbnailLoader()
+
+ override fun teardown() {
+ // Do nothing.
+ }
+ }
+
+ private class InputStreamFetcher(resolver: ContentResolver, uri: Uri, private val width: Int, private val height: Int) : LocalUriFetcher(resolver, uri) {
+
+ override fun getDataClass(): Class = 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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
index 61a902bc..659077d2 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
@@ -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
@@ -80,6 +81,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 +165,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 +183,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 +289,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 +418,16 @@ class StateApp {
Logger.i(TAG, "MainApp Starting");
initializeFiles(true);
+ _scope?.launch(Dispatchers.IO) {
+ try {
+ val caFile = AppCaUpdater.ensureCaBundle(context)
+ Libcurl.setDefaultCAPath(caFile.absolutePath)
+ } catch (t: Throwable) {
+ val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
+ if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
+ }
+ }
+
if(Settings.instance.other.polycentricLocalCache) {
Logger.i(TAG, "Initialize Polycentric Disk Cache")
_cacheDirectory?.let { ApiMethods.initCache(it) };
@@ -449,7 +495,7 @@ class StateApp {
StateSync.instance.start(context)
}
- settingsActivityClosed.subscribe {
+ SettingsFragment.onClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context)
} else {
@@ -461,7 +507,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 +636,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 +655,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 +909,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);
}
diff --git a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt
index 7cf3d976..11aae89e 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt
@@ -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")
diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt
new file mode 100644
index 00000000..3a7b1e4a
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt
@@ -0,0 +1,676 @@
+package com.futo.platformplayer.states
+
+import android.content.ContentUris
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.provider.MediaStore
+import android.provider.MediaStore.Audio.Artists
+import android.webkit.MimeTypeMap
+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
+
+
+class StateLibrary {
+
+ private val _files = FragmentedStorage.get("libraryFiles")
+
+
+ fun getFileDirectories(): List {
+ 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 {
+ 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();
+ cursor.moveToFirst();
+ val list = mutableListOf()
+ while(!cursor.isAfterLast) {
+ list.add(StateLibrary.audioFromCursor(cursor));
+ cursor.moveToNext();
+ }
+ return list;
+ }
+
+ fun getAlbums(): List {
+ 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 {
+ 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 {
+ 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 {
+ 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? = null): IPager {
+ 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();
+ cursor.moveToFirst();
+ val list = mutableListOf()
+ while(!cursor.isAfterLast && list.size < 10) {
+ list.add(videoFromCursor(cursor));
+ cursor.moveToNext();
+ }
+
+ return AdhocPager({
+ val list = mutableListOf()
+ while(!cursor.isAfterLast && list.size < 10) {
+ list.add(videoFromCursor(cursor));
+ cursor.moveToNext();
+ }
+ return@AdhocPager list;
+ }, list);
+ }
+ fun getRecentVideos(buckets: List? = null, count: Int = 20): List {
+ val videoPager = getVideos(buckets);
+ val items = mutableListOf();
+ 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? = null;
+ fun getVideoBucketNames(): List {
+ if(_cacheBucketNames != null)
+ return _cacheBucketNames ?: listOf();
+ 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();
+
+ val buckets = mutableListOf();
+ val list = HashSet();
+ if (cur.moveToFirst()) {
+ var id: Long;
+ var bucket: String
+ do {
+ id = cur.getLong(0);
+ bucket = cur.getString(1)
+ if(!list.contains(id)) {
+ list.add(id);
+ buckets.add(Bucket(id, bucket));
+ }
+ } while (cur.moveToNext())
+ }
+ _cacheBucketNames = buckets.toList()
+ return _cacheBucketNames ?: listOf();
+ }
+
+
+ companion object {
+ val PROJECTION_VIDEO = arrayOf(
+ MediaStore.Video.Media._ID,
+ MediaStore.Video.Media.DISPLAY_NAME,
+ MediaStore.Video.Media.AUTHOR,
+ 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.ALBUM_ID, //3
+ MediaStore.Audio.Media.DURATION, //4
+ MediaStore.Audio.Media.DATE_ADDED, //5
+ MediaStore.Audio.Media.MIME_TYPE, //6
+ MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7
+ );
+
+ 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;
+ cursor.moveToFirst();
+ if(cursor.isAfterLast)
+ return null;
+ return 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;
+ cursor.moveToFirst();
+ if(cursor.isAfterLast)
+ return null;
+ return 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;
+ cursor.moveToFirst();
+ if(cursor.isAfterLast)
+ return null;
+ return 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;
+ cursor.moveToFirst();
+ if(cursor.isAfterLast)
+ return null;
+ return videoFromCursor(cursor);
+ }
+
+ fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
+ val id = cursor.getString(0);
+ val displayName = cursor.getString(1);
+ val author = cursor.getString(2);
+ val albumId = cursor.getLong(3);
+ val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 };
+ val date = cursor.getLong(5);
+ val contentType = cursor.getString(6);
+ val category = cursor.getString(7);
+
+ val idLong = id.toLongOrNull();
+ val contentUrl = if(idLong != null )
+ ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, idLong).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(PlatformID.NONE, author, "", 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 = cursor.getString(2);
+ val date = cursor.getLong(3);
+ val contentType = cursor.getString(4);
+ val category = cursor.getString(5);
+
+ 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 {
+ return Album.getArtistAlbums(id.toLongOrNull() ?: return listOf());
+ }
+
+ fun toPlaylist(tracks: List? = 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 {
+ val idLong = id.toLongOrNull() ?: return EmptyPager();
+ return AdhocPager({ listOf() }, getTracksPager(idLong));
+ }
+
+ companion object {
+ val ID_UNKNOWN = "UNKNOWN";
+ val PROJECTION: Array = arrayOf(Artists._ID,
+ Artists.ARTIST,
+ Artists.NUMBER_OF_TRACKS,
+ Artists.NUMBER_OF_ALBUMS);
+
+ 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;
+ cursor.moveToFirst();
+ if(cursor.isAfterLast)
+ return null;
+ return Artist.fromCursor(cursor);
+ }
+ fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array? = null): List {
+ 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();
+ cursor.moveToFirst();
+ val list = mutableListOf()
+ while(!cursor.isAfterLast) {
+ list.add(fromCursor(cursor));
+ cursor.moveToNext();
+ }
+ return list;
+ }
+
+ fun getTracksPager(artistId: Long): List {
+ 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();
+ cursor.moveToFirst();
+ val list = mutableListOf()
+ while(!cursor.isAfterLast) {
+ list.add(StateLibrary.audioFromCursor(cursor));
+ cursor.moveToNext();
+ }
+ return 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 {
+ return getAlbumTracks(id.toLongOrNull() ?: return listOf())
+ }
+
+ fun toPlaylist(tracks: List? = 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 {
+ 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();
+ cursor.moveToFirst();
+ val list = mutableListOf()
+ while(!cursor.isAfterLast) {
+ list.add(StateLibrary.audioFromCursor(cursor));
+ cursor.moveToNext();
+ }
+ return 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;
+ cursor.moveToFirst();
+ if(cursor.isAfterLast)
+ return null;
+ return fromCursor(cursor);
+ }
+ fun getAlbums(query: String? = null, args: Array? = null): List