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 { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return listOf(); + } + val cursor = resolver?.query( + MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args, + MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(fromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } + fun getArtistAlbums(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.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()), + MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(fromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } + } +} + + +class FileEntry( + val path: String, + val name: String, + val isDirectory: Boolean = false, + val thumbnail: String? = null, + + var removable: Boolean = false +) { + + fun getSubFiles(): List { + if(isDirectory) { + if(path.startsWith("content://")) + return DocumentFile.fromTreeUri(StateApp.instance.context, path.toUri())?.listFiles() + ?.map { fromFile(it) } ?: return listOf(); + return File(path).listFiles() + .map { fromFile(it) } + } + return listOf(); + } + + companion object { + fun fromPath(path: String): FileEntry { + /* + val cursor = StateApp.instance.context.contentResolver.query(path.toUri(), null, null, null, null); + cursor?.moveToFirst(); + val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + cursor?.close(); + return FileEntry(path, fileName, ); + */ + val file = File(path); + return FileEntry(file.path, file.name, file.isDirectory); + } + fun fromFile(file: File): FileEntry { + return FileEntry(file.path, file.name, file.isDirectory); + } + fun fromFile(file: DocumentFile): FileEntry { + return FileEntry(file.uri.toString(), file.name ?: "", file.isDirectory); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 8f5fc3ba..99213aea 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.local.LocalClient import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager @@ -75,6 +76,7 @@ class StatePlatform { private val _cache : LruCache = LruCache(VIDEO_CACHE); //Clients + private val _localClient = LocalClient(); private val _enabledClientsPersistent = FragmentedStorage.get("enabledClients"); private val _platformOrderPersistent = FragmentedStorage.get("platformOrder"); private val _clientsLock = Object(); @@ -117,6 +119,7 @@ class StatePlatform { _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _mainClientPool.getClientPooled(it).getContentDetails(url) } + ?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null) ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } else { @@ -124,6 +127,7 @@ class StatePlatform { _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _privateClientPool.getClientPooled(it).getContentDetails(url) } + ?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null) ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } }, diff --git a/app/src/main/java/com/futo/platformplayer/views/AlbumHeaderView.kt b/app/src/main/java/com/futo/platformplayer/views/AlbumHeaderView.kt new file mode 100644 index 00000000..b2eadfef --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/AlbumHeaderView.kt @@ -0,0 +1,70 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + +class AlbumHeaderView: ConstraintLayout { + + + val textName: TextView; + val textMetadata: TextView; + + val imageThumbnail: ImageView; + val imageThumbnailBackground: ImageView; + + val buttonPlayAll: LinearLayout; + val buttonShuffle: LinearLayout; + + val onPlayAll = Event0(); + val onShuffle = Event0(); + + constructor(context: Context) : super(context) { + inflate(context, R.layout.view_album_header, this) + + textName = findViewById(R.id.text_name); + textMetadata = findViewById(R.id.text_metadata); + imageThumbnail = findViewById(R.id.image_thumbnail); + imageThumbnailBackground = findViewById(R.id.image_thumbnail_background); + buttonPlayAll = findViewById(R.id.button_play_all); + buttonShuffle = findViewById(R.id.button_shuffle); + + buttonPlayAll.setOnClickListener { onPlayAll.emit() }; + buttonShuffle.setOnClickListener { onShuffle.emit() }; + } + + fun setThumbnail(thumbnail: String?) { + if (thumbnail != null) + Glide.with(imageThumbnail) + .load(thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(imageThumbnail) + else + Glide.with(imageThumbnail) + .load(R.drawable.placeholder_channel_thumbnail) + .into(imageThumbnail); + if (thumbnail != null) + Glide.with(imageThumbnailBackground) + .load(thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(imageThumbnailBackground) + else + Glide.with(imageThumbnailBackground) + .load(R.drawable.placeholder_channel_thumbnail) + .into(imageThumbnailBackground); + + } + + fun setName(str: String){ + textName.text = str; + } + fun setMetadata(str: String) { + textMetadata.text = str; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt b/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt index db295aa3..2d8af452 100644 --- a/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt @@ -72,9 +72,9 @@ class AnyInsertedAdapterView(view: RecyclerView, adapter: BaseAnyAdapter> RecyclerView.asAnyWithViews(prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView { for(view in prepend) - (view.parent as ViewGroup).removeView(view); + (view.parent as ViewGroup?)?.removeView(view); for(view in append) - (view.parent as ViewGroup).removeView(view); + (view.parent as ViewGroup?)?.removeView(view); return AnyInsertedAdapterView(this, AnyInsertedAdapter.create(prepend, append, onCreate), orientation, reversed); } inline fun> RecyclerView.asAnyWithViews(list: ArrayList, prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView { diff --git a/app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt b/app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt new file mode 100644 index 00000000..176175be --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.GONE +import androidx.constraintlayout.widget.ConstraintLayout.inflate +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.AnyAdapter.AnyViewHolder +import com.google.android.material.imageview.ShapeableImageView + +class LibrarySection: ConstraintLayout { + val textName: TextView; + val imageNavigate: ImageView; + val recycler: RecyclerView; + + + constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) { + inflate(context, R.layout.view_library_section, this); + textName = findViewById(R.id.text_label) + imageNavigate = findViewById(R.id.image_nav) + recycler = findViewById(R.id.recycler_collection); + + } + + fun setNavIcon(resId: Int) { + imageNavigate.setImageResource(resId); + } + + fun setContentEmptyMessage(icon: Int, msg: String) { + + } + inline fun > getAnyAdapter(noinline onCreate: ((V)->Unit)? = null, orientation: Int = RecyclerView.HORIZONTAL): AnyAdapterView { + return recycler.asAny(orientation, false, onCreate); + } + + inline fun setSection(title: String, crossinline onOpen: (()->Unit)) { + textName.text = title; + imageNavigate.setOnClickListener { onOpen.invoke() }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/LibraryTypeHeaderView.kt b/app/src/main/java/com/futo/platformplayer/views/LibraryTypeHeaderView.kt new file mode 100644 index 00000000..cd11f447 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/LibraryTypeHeaderView.kt @@ -0,0 +1,67 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class LibraryTypeHeaderView: ConstraintLayout { + + var selected: SelectedType = SelectedType.Artists; + + val pillArtist: PillV2; + val pillAlbums: PillV2; + val textMetadata: TextView; + val pills: List + + val onSelectedChanged = Event1(); + + constructor(context: Context) : super(context) { + inflate(context, R.layout.view_library_type_header, this) + + textMetadata = findViewById(R.id.text_metadata); + pillArtist = findViewById(R.id.pill_artist); + pillAlbums = findViewById(R.id.pill_albums); + + pillArtist.onClick.subscribe { + setSelectedType(SelectedType.Artists, true); + } + pillAlbums.onClick.subscribe { + setSelectedType(SelectedType.Albums, true); + } + + pills = listOf(pillArtist, pillAlbums); + + setSelectedType(SelectedType.Artists, false); + } + + fun setMetadata(str: String) { + textMetadata.text = str; + } + + fun setSelectedType(selected: SelectedType, notify: Boolean = false){ + this.selected = selected; + + pills.forEach { it.setIsEnabled(false) }; + + when(selected) { + SelectedType.Artists -> { + pillArtist.setIsEnabled(true); + } + SelectedType.Albums -> { + pillAlbums.setIsEnabled(true); + } + } + + if(notify) + onSelectedChanged.emit(selected); + } + + + + enum class SelectedType { + Artists, + Albums + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/PillV2.kt b/app/src/main/java/com/futo/platformplayer/views/PillV2.kt new file mode 100644 index 00000000..ab55ba15 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/PillV2.kt @@ -0,0 +1,73 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.views.others.ToggleTagView + +class PillV2: FrameLayout { + + val root: FrameLayout; + val text: TextView; + + var isToggled: Boolean = false; + + val onClick = Event1(); + + constructor(context: Context, name: String, isActive: Boolean = false, action: (PillV2, Boolean)->Unit, actionLong: ((PillV2, Boolean)->Unit)? = null): super(context) { + inflate(context, R.layout.view_tag_v2, this); + root = findViewById(R.id.root); + text = findViewById(R.id.text_tag); + + text.text = name; + setIsEnabled(isActive); + + setOnClickListener { + setIsEnabled(!isToggled); + onClick.emit(isToggled); + action(this, isToggled); + } + if(actionLong != null) + setOnLongClickListener { + actionLong(this, this.isToggled); + return@setOnLongClickListener true; + } + } + + constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) { + inflate(context, R.layout.view_tag_v2, this); + root = findViewById(R.id.root); + text = findViewById(R.id.text_tag); + + val attrArr = context.obtainStyledAttributes(attr, R.styleable.PillV2, 0, 0); + val attrEnabled = attrArr.getBoolean(R.styleable.PillV2_pillV2Enabled, false); + val attrText = attrArr.getText(R.styleable.PillV2_pillV2Text) ?: ""; + text.text = attrText; + setIsEnabled(attrEnabled); + + setOnClickListener { + setIsEnabled(!isToggled); + onClick.emit(isToggled); + } + } + + + + fun setText(text: String) { + this.text.text = text; + } + + fun setIsEnabled(enabled: Boolean = true) { + if(enabled) + root.setBackgroundResource(R.drawable.background_2e_round_4dp) + else + root.setBackgroundResource(R.drawable.background_black_2e_round_4dp); + this.isToggled = enabled; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt new file mode 100644 index 00000000..ad812307 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt @@ -0,0 +1,84 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updateLayoutParams +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.dp +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class AlbumTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_album_tile, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _root: ConstraintLayout; + protected var _album: Album? = null; + protected val _imageThumbnail: ImageView + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _root = _view.findViewById(R.id.root); + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + + _view.setOnClickListener { onClick.emit(_album) }; + } + + fun setWidth(dp: Int) { + _root.updateLayoutParams { + this.width = (dp - 12).dp(_viewGroup.context.resources); + this.height = (dp + 48).dp(_viewGroup.context.resources); + } + _imageThumbnail.updateLayoutParams { + this.width = (dp - 12).dp(_viewGroup.context.resources); + this.height = (dp - 12).dp(_viewGroup.context.resources); + } + } + + fun setAutoSize(totalWidth: Float) { + val viewWidth = 98; + val dpWidth = totalWidth; + val columns = Math.max(((dpWidth) / viewWidth).toInt(), 1); + val remainder = dpWidth - columns * viewWidth; + val targetSize = viewWidth + (remainder / columns).toInt(); + setWidth(targetSize); + } + + override fun bind(album: Album) { + _album = album; + _imageThumbnail?.let { + if (album.thumbnail != null) + Glide.with(it) + .load(album.thumbnail) + .placeholder(R.drawable.unknown_music) + .into(it) + else + Glide.with(it).load(R.drawable.unknown_music).into(it); + }; + + _textName.text = album.name; + _textMetadata.text = album.artist ?: ""; + } + + companion object { + fun getAutoSizeColumns(totalWidth: Float): Int { + val viewWidth = 98; + val dpWidth = totalWidth; + val columns = Math.max(((dpWidth) / viewWidth).toInt(), 1); + return columns; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt new file mode 100644 index 00000000..ab4f4664 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt @@ -0,0 +1,53 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_artist_tile, + _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 { onClick.emit(_artist) }; + } + + + override fun bind(artist: Artist) { + _artist = artist; + _imageThumbnail?.let { + if (artist.thumbnail != null) + Glide.with(it) + .load(artist.thumbnail) + .placeholder(R.drawable.unknown_music) + .into(it) + else + Glide.with(it).load(R.drawable.unknown_music).into(it); + }; + + _textName.text = artist.name; + _textMetadata.text = ""// "${artist.countTracks} tracks"; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt new file mode 100644 index 00000000..04b94d0c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.FileEntry +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_file, + _viewGroup, false)) { + + val onClick = Event1(); + val onDelete = Event1(); + + protected var _file: FileEntry? = null; + protected val _imageThumbnail: ImageView + protected val _buttonDelete: ImageButton; + protected val _textName: TextView + //protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + //_textMetadata = _view.findViewById(R.id.text_metadata); + _buttonDelete = _view.findViewById(R.id.button_delete); + + _view.setOnClickListener { onClick.emit(_file) }; + _buttonDelete.setOnClickListener { onDelete.emit(_file) } + } + + + override fun bind(file: FileEntry) { + _file = file; + _imageThumbnail?.let { + if(file.isDirectory) + it.setImageResource(R.drawable.ic_library); + else { + Glide.with(it) + .load(file.thumbnail) + .placeholder(R.drawable.ic_music) + .into(it) + } + }; + _buttonDelete.isVisible = file.removable; + + _textName.text = file.name; + //if(file.isDirectory) + // _textMetadata.text = "Directory"; + //else + // _textMetadata.text = ""; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt new file mode 100644 index 00000000..1adee422 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt @@ -0,0 +1,63 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.google.android.material.imageview.ShapeableImageView + + +class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_video_thumbnail_tile, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _content: IPlatformVideo? = null; + protected val _imageThumbnail: ShapeableImageView + protected val _textDuration: TextView; + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_video_thumbnail); + _textDuration = _view.findViewById(R.id.thumbnail_duration); + _textName = _view.findViewById(R.id.text_video_name); + _textMetadata = _view.findViewById(R.id.text_video_metadata); + + _view.setOnClickListener { onClick.emit(_content) }; + } + + + override fun bind(content: IPlatformVideo) { + _content = content; + _imageThumbnail?.let { + if (content.thumbnails.getHQThumbnail() != null) + Glide.with(it) + .load(content.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.unknown_music) + .into(it) + else + Glide.with(it).load(R.drawable.unknown_music).into(it); + }; + + _textName.text = content.name; + _textMetadata.text = content.datetime?.toHumanNowDiffString(); + _textDuration.text = content.duration.toHumanTime(false) + " ago"; + _textDuration.isVisible = content.duration > 0; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/TrackViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/TrackViewHolder.kt new file mode 100644 index 00000000..6b2e39c5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/TrackViewHolder.kt @@ -0,0 +1,63 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.toHumanDuration +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class TrackViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_track, + _viewGroup, false)) { + + val onClick = Event1(); + val onOptions = Event1(); + + protected var _content: IPlatformContent? = null; + //protected val _imageThumbnail: ImageView + protected val _textName: TextView + protected val _textMetadata: TextView + + protected val _imageSettings: ImageView; + + init { + //_imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + _imageSettings = _view.findViewById(R.id.button_options); + + _view.setOnClickListener { _content?.let { onClick.emit(it) } }; + _imageSettings.setOnClickListener { _content?.let { onOptions.emit(it) } }; + } + + override fun bind(content: IPlatformContent) { + _content = content; + /* + _imageThumbnail?.let { + if (artist.thumbnail != null) + Glide.with(it) + .load(artist.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(it) + else + Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it); + }; + */ + + _textName.text = content.name; + + val metaComps = listOf( + if(content is IPlatformVideo) "${content.duration.toHumanDuration(false)}" else null + ).filterNotNull(); + + _textMetadata.text = metaComps.joinToString(", "); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index fae32226..846471f1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -29,6 +29,8 @@ import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker +import androidx.media3.exoplayer.source.BehindLiveWindowException import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource @@ -77,6 +79,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -1008,7 +1011,19 @@ abstract class FutoVideoPlayerBase : RelativeLayout { @Suppress("DEPRECATION") protected open fun onPlayerError(error: PlaybackException) { - Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss"); + Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}"); + + if(error is BehindLiveWindowException) { + Logger.e(TAG, "BehindLiveWindowException, " + error.message); + reloadMediaSource(true, true); + return; + } + if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) { + Logger.e(TAG, "PlaylistStuckException"); + reloadMediaSource(true, true); + UIDialogs.toast("Live playback error, reloading.."); + return; + } when (error.errorCode) { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { diff --git a/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate-jni.so b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate-jni.so new file mode 100755 index 00000000..cc0c132f Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate.so b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate.so new file mode 100755 index 00000000..33c137b7 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate-jni.so b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate-jni.so new file mode 100755 index 00000000..809a6115 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate.so b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate.so new file mode 100755 index 00000000..ce0c9aeb Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate.so differ diff --git a/app/src/main/jniLibs/x86/libcurl-impersonate-jni.so b/app/src/main/jniLibs/x86/libcurl-impersonate-jni.so new file mode 100755 index 00000000..c4243995 Binary files /dev/null and b/app/src/main/jniLibs/x86/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/x86/libcurl-impersonate.so b/app/src/main/jniLibs/x86/libcurl-impersonate.so new file mode 100755 index 00000000..bb29b738 Binary files /dev/null and b/app/src/main/jniLibs/x86/libcurl-impersonate.so differ diff --git a/app/src/main/jniLibs/x86_64/libcurl-impersonate-jni.so b/app/src/main/jniLibs/x86_64/libcurl-impersonate-jni.so new file mode 100755 index 00000000..c41a9fd7 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/x86_64/libcurl-impersonate.so b/app/src/main/jniLibs/x86_64/libcurl-impersonate.so new file mode 100755 index 00000000..0e42e82b Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libcurl-impersonate.so differ diff --git a/app/src/main/res/drawable/background_2e_round_4dp.xml b/app/src/main/res/drawable/background_2e_round_4dp.xml new file mode 100644 index 00000000..d43dbcc9 --- /dev/null +++ b/app/src/main/res/drawable/background_2e_round_4dp.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_black_2e_round_4dp.xml b/app/src/main/res/drawable/background_black_2e_round_4dp.xml new file mode 100644 index 00000000..57dd83db --- /dev/null +++ b/app/src/main/res/drawable/background_black_2e_round_4dp.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_gradient_dark.xml b/app/src/main/res/drawable/bottom_gradient_dark.xml new file mode 100644 index 00000000..8bbcdbdc --- /dev/null +++ b/app/src/main/res/drawable/bottom_gradient_dark.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_album.xml b/app/src/main/res/drawable/ic_album.xml new file mode 100644 index 00000000..49d3d0e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_album.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 00000000..f2195ec2 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_library.xml b/app/src/main/res/drawable/ic_library.xml new file mode 100644 index 00000000..d6a5f6e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_library.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_library.xml b/app/src/main/res/drawable/ic_video_library.xml new file mode 100644 index 00000000..e41ff300 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videocam.xml b/app/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 00000000..785a1fd9 --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/unknown_music.png b/app/src/main/res/drawable/unknown_music.png new file mode 100644 index 00000000..f3e12684 Binary files /dev/null and b/app/src/main/res/drawable/unknown_music.png differ diff --git a/app/src/main/res/layout/activity_dev.xml b/app/src/main/res/layout/activity_dev.xml index 6567d67f..af9a4a2a 100644 --- a/app/src/main/res/layout/activity_dev.xml +++ b/app/src/main/res/layout/activity_dev.xml @@ -11,6 +11,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index f1eeed7e..d71385b3 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -1,5 +1,6 @@ diff --git a/app/src/main/res/layout/fragment_files_top_bar.xml b/app/src/main/res/layout/fragment_files_top_bar.xml new file mode 100644 index 00000000..6fb516bf --- /dev/null +++ b/app/src/main/res/layout/fragment_files_top_bar.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_filter_recycler.xml b/app/src/main/res/layout/fragview_filter_recycler.xml new file mode 100644 index 00000000..639232f4 --- /dev/null +++ b/app/src/main/res/layout/fragview_filter_recycler.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_library.xml b/app/src/main/res/layout/fragview_library.xml new file mode 100644 index 00000000..b7be6263 --- /dev/null +++ b/app/src/main/res/layout/fragview_library.xml @@ -0,0 +1,99 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_library_search.xml b/app/src/main/res/layout/fragview_library_search.xml new file mode 100644 index 00000000..a79e1c27 --- /dev/null +++ b/app/src/main/res/layout/fragview_library_search.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_album.xml b/app/src/main/res/layout/list_album.xml new file mode 100644 index 00000000..fbdc8ed9 --- /dev/null +++ b/app/src/main/res/layout/list_album.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_album_tile.xml b/app/src/main/res/layout/list_album_tile.xml new file mode 100644 index 00000000..9f25a20c --- /dev/null +++ b/app/src/main/res/layout/list_album_tile.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_artist.xml b/app/src/main/res/layout/list_artist.xml new file mode 100644 index 00000000..68c742af --- /dev/null +++ b/app/src/main/res/layout/list_artist.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_artist_tile.xml b/app/src/main/res/layout/list_artist_tile.xml new file mode 100644 index 00000000..93092193 --- /dev/null +++ b/app/src/main/res/layout/list_artist_tile.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_file.xml b/app/src/main/res/layout/list_file.xml new file mode 100644 index 00000000..2107fa1d --- /dev/null +++ b/app/src/main/res/layout/list_file.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_playlists.xml b/app/src/main/res/layout/list_playlists.xml index a180d134..e94852e7 100644 --- a/app/src/main/res/layout/list_playlists.xml +++ b/app/src/main/res/layout/list_playlists.xml @@ -35,7 +35,7 @@ android:maxLines="2" app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintRight_toLeftOf="@id/button_trash" + app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toTopOf="@id/text_metadata" android:layout_marginStart="10dp" /> @@ -51,7 +51,7 @@ android:maxLines="1" app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail" - app:layout_constraintRight_toLeftOf="@id/button_trash" + app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:layout_marginStart="10dp" /> @@ -68,6 +68,7 @@ app:layout_constraintBottom_toBottomOf="parent" android:layout_marginEnd="10dp"/> --> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_video_thumbnail_tile.xml b/app/src/main/res/layout/list_video_thumbnail_tile.xml new file mode 100644 index 00000000..dae0f7b5 --- /dev/null +++ b/app/src/main/res/layout/list_video_thumbnail_tile.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_album_header.xml b/app/src/main/res/layout/view_album_header.xml new file mode 100644 index 00000000..da94d3fb --- /dev/null +++ b/app/src/main/res/layout/view_album_header.xml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_library_section.xml b/app/src/main/res/layout/view_library_section.xml new file mode 100644 index 00000000..b2c26dd3 --- /dev/null +++ b/app/src/main/res/layout/view_library_section.xml @@ -0,0 +1,56 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_library_type_header.xml b/app/src/main/res/layout/view_library_type_header.xml new file mode 100644 index 00000000..58d1c69a --- /dev/null +++ b/app/src/main/res/layout/view_library_type_header.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_tag_v2.xml b/app/src/main/res/layout/view_tag_v2.xml new file mode 100644 index 00000000..7a5928de --- /dev/null +++ b/app/src/main/res/layout/view_tag_v2.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/pill_v2_attrs.xml b/app/src/main/res/values/pill_v2_attrs.xml new file mode 100644 index 00000000..ecf860a7 --- /dev/null +++ b/app/src/main/res/values/pill_v2_attrs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 644958db..eebf9db5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,7 @@ Share View all Creators + Library Enabled Keep screen on Keep screen on while casting diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index dda08d7b..952c8613 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -23,6 +23,18 @@ rounded 16dp + + +