From eb6ce74dcafbe9f1e70f7ca4726630321bc99b77 Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Tue, 24 Sep 2024 23:40:12 +0100 Subject: [PATCH] 6.7.3 commit --- README.md | 2 +- app/build.gradle | 155 +++--- .../playback/service/PlaybackService.kt | 4 +- .../preferences/fragments/AboutFragment.kt | 29 +- .../handler/EpisodeMultiSelectHandler.kt | 38 +- .../ui/actions/swipeactions/SwipeActions.kt | 15 +- .../mdiq/podcini/ui/activity/MainActivity.kt | 12 +- .../podcini/ui/adapter/EpisodesAdapter.kt | 1 - .../ui/adapter/SimpleIconListAdapter.kt | 30 -- .../ac/mdiq/podcini/ui/compose/Composables.kt | 38 ++ .../ac/mdiq/podcini/ui/compose/Episodes.kt | 323 +++++++++++ .../ui/fragment/AudioPlayerFragment.kt | 2 + .../podcini/ui/fragment/DownloadsCFragment.kt | 508 ++++++++++++++++++ .../podcini/ui/fragment/DownloadsFragment.kt | 2 +- .../ui/fragment/FeedEpisodesFragment.kt | 16 +- .../podcini/ui/fragment/FeedInfoFragment.kt | 4 +- .../podcini/ui/fragment/NavDrawerFragment.kt | 27 +- .../ui/fragment/OnlineSearchFragment.kt | 2 +- .../main/res/layout/downloads_fragment.xml | 38 ++ app/src/main/res/values/arrays.xml | 3 + app/src/main/res/values/strings.xml | 1 + changelog.md | 8 + gradle/libs.versions.toml | 144 +++++ 23 files changed, 1235 insertions(+), 167 deletions(-) delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SimpleIconListAdapter.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsCFragment.kt create mode 100644 app/src/main/res/layout/downloads_fragment.xml diff --git a/README.md b/README.md index 7a016684..d39e018d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin [F-Droid](https://f-droid.org/packages/ac.mdiq.podcini.R/) [Amazon](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13) -#### Podcini.R version 6.5 as a major step forward brings YouTube contents in the app. Channels can be searched, received from share, subscribed. Since 6.5, podcasts, playlists as well as single media from Youtube and YT Music can be shared to Podcini. For more see the Youtube section below or the changelogs +#### Podcini.R version 6.5 as a major step forward brings YouTube contents in the app. Channels can be searched, received from share, subscribed. Since 6.6, podcasts, playlists as well as single media from Youtube and YT Music can be shared to Podcini. For more see the Youtube section below or the changelogs That means finally: [Nessun dorma](https://www.youtube.com/watch?v=cWc7vYjgnTs) #### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions. #### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions. diff --git a/app/build.gradle b/app/build.gradle index 49903c48..f972a350 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020255 - versionName "6.7.2" + versionCode 3020256 + versionName "6.7.3" applicationId "ac.mdiq.podcini.R" def commit = "" @@ -170,110 +170,115 @@ android { } dependencies { - /** Desugaring for using VistaGuide **/ - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.2' - implementation 'com.github.XilinJia.vistaguide:VistaGuide:lv0.24.2.6' + implementation libs.androidx.material3.android - def composeBom = platform('androidx.compose:compose-bom:2024.09.02') + /** Desugaring for using VistaGuide **/ + coreLibraryDesugaring libs.desugar.jdk.libs.nio + implementation libs.vistaguide + + def composeBom = libs.androidx.compose.bom implementation composeBom androidTestImplementation composeBom - implementation 'androidx.compose.material:material:1.7.2' - implementation 'androidx.compose.ui:ui-tooling-preview:1.7.2' - debugImplementation 'androidx.compose.ui:ui-tooling:1.7.2' + implementation libs.androidx.material + implementation libs.androidx.ui.tooling.preview + debugImplementation libs.androidx.ui.tooling + implementation libs.androidx.constraintlayout.compose - implementation 'androidx.activity:activity-compose:1.9.2' - implementation 'androidx.window:window:1.3.0' + implementation libs.androidx.activity.compose + implementation libs.androidx.window - implementation "androidx.core:core-ktx:1.13.1" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' + implementation libs.androidx.core.ktx + implementation libs.kotlinx.coroutines.android - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6" - implementation "androidx.annotation:annotation:1.8.2" - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' + implementation libs.androidx.lifecycle.runtime.ktx + implementation libs.androidx.annotation + implementation libs.androidx.appcompat + implementation libs.androidx.coordinatorlayout + //noinspection UseTomlInstead implementation "androidx.fragment:fragment-ktx:1.8.3" - implementation 'androidx.gridlayout:gridlayout:1.0.0' - implementation "androidx.media3:media3-exoplayer:1.4.1" - implementation "androidx.media3:media3-ui:1.4.1" - implementation "androidx.media3:media3-datasource-okhttp:1.4.1" - implementation "androidx.media3:media3-common:1.4.1" - implementation "androidx.media3:media3-session:1.4.1" - implementation "androidx.palette:palette-ktx:1.0.0" - implementation "androidx.preference:preference-ktx:1.2.1" - implementation "androidx.recyclerview:recyclerview:1.3.2" - implementation "androidx.viewpager2:viewpager2:1.1.0" - implementation "androidx.work:work-runtime:2.9.1" - implementation "androidx.core:core-splashscreen:1.0.1" - implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.webkit:webkit:1.12.0' + implementation libs.androidx.gridlayout + implementation libs.androidx.media3.exoplayer + implementation libs.androidx.media3.ui + implementation libs.androidx.media3.media3.datasource.okhttp + implementation libs.media3.common + implementation libs.media3.session + implementation libs.androidx.palette.ktx + implementation libs.androidx.preference.ktx + implementation libs.androidx.recyclerview + implementation libs.androidx.viewpager2 + implementation libs.androidx.work.runtime + implementation libs.androidx.core.splashscreen + implementation libs.androidx.documentfile + implementation libs.androidx.webkit - implementation "com.google.android.material:material:1.12.0" + implementation libs.material - implementation 'io.realm.kotlin:library-base:2.1.0' + implementation libs.library.base - implementation 'org.apache.commons:commons-lang3:3.15.0' - implementation 'commons-io:commons-io:2.16.1' - implementation 'org.jsoup:jsoup:1.18.1' + implementation libs.commons.lang3 + implementation libs.commons.io + implementation libs.jsoup - implementation 'io.coil-kt:coil:2.7.0' + implementation libs.coil + implementation libs.coil.compose - implementation "com.squareup.okhttp3:okhttp:4.12.0" - implementation "com.squareup.okhttp3:okhttp-urlconnection:4.12.0" - implementation 'com.squareup.okio:okio:3.9.0' + implementation libs.okhttp + implementation libs.okhttp3.okhttp.urlconnection + implementation libs.okio - implementation "io.reactivex.rxjava2:rxjava:2.2.21" - implementation "io.reactivex.rxjava3:rxjava:3.1.8" - implementation "io.reactivex.rxjava3:rxandroid:3.0.2" + implementation libs.rxjava + implementation libs.rxjava3.rxjava + implementation libs.rxandroid // 5.5.0-b01 is newer than 5.5.0-compose01 - implementation 'com.mikepenz:iconics-core:5.5.0-b01' - implementation 'com.mikepenz:iconics-views:5.5.0-b01' - implementation 'com.mikepenz:google-material-typeface:4.0.0.3-kotlin@aar' - implementation 'com.mikepenz:google-material-typeface-outlined:4.0.0.2-kotlin@aar' - implementation 'com.mikepenz:fontawesome-typeface:5.13.3.0-kotlin@aar' + implementation libs.iconics.core + implementation libs.iconics.views + implementation libs.google.material.typeface + implementation libs.google.material.typeface.outlined + implementation libs.fontawesome.typeface - implementation 'com.leinardi.android:speed-dial:3.3.0' - implementation 'com.github.ByteHamster:SearchPreference:v2.5.0' - implementation 'com.github.skydoves:balloon:1.6.6' - implementation 'com.github.xabaras:RecyclerViewSwipeDecorator:1.3' - implementation "com.annimon:stream:1.2.2" + implementation libs.speed.dial + implementation libs.searchpreference + implementation libs.balloon + implementation libs.recyclerviewswipedecorator + implementation libs.stream - implementation 'com.github.mfietz:fyydlin:v0.5.0' + implementation libs.fyydlin - implementation "net.dankito.readability4j:readability4j:1.0.8" + implementation libs.readability4j // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14' // Non-free dependencies: - playImplementation 'com.google.android.play:core-ktx:1.8.1' - compileOnly "com.google.android.wearable:wearable:2.9.0" + playImplementation libs.core.ktx + compileOnly libs.wearable // this one can not be updated? TODO: need to get an alternative - androidTestImplementation 'com.nanohttpd:nanohttpd:2.1.1' + androidTestImplementation libs.nanohttpd - androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" - androidTestImplementation "androidx.test.espresso:espresso-contrib:3.6.1" - androidTestImplementation "androidx.test.espresso:espresso-intents:3.6.1" - androidTestImplementation "androidx.test:runner:1.6.2" - androidTestImplementation "androidx.test:rules:1.6.1" - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'org.awaitility:awaitility:4.2.1' + androidTestImplementation libs.androidx.espresso.core + androidTestImplementation libs.androidx.espresso.contrib + androidTestImplementation libs.androidx.espresso.intents + androidTestImplementation libs.androidx.runner + androidTestImplementation libs.androidx.rules + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.awaitility // Non-free dependencies: - testImplementation "androidx.test:core:1.6.1" - testImplementation 'org.awaitility:awaitility:4.2.1' - testImplementation "junit:junit:4.13.2" - testImplementation 'org.mockito:mockito-inline:5.2.0' - testImplementation 'org.robolectric:robolectric:4.13' - testImplementation 'javax.inject:javax.inject:1' + testImplementation libs.androidx.core + testImplementation libs.awaitility + testImplementation libs.junit + testImplementation libs.mockito.inline + testImplementation libs.robolectric + testImplementation libs.javax.inject - playImplementation 'com.google.android.gms:play-services-base:18.5.0' - freeImplementation 'org.conscrypt:conscrypt-android:2.5.2' + playImplementation libs.play.services.base + freeImplementation libs.conscrypt.android - playApi 'androidx.mediarouter:mediarouter:1.7.0' + playApi libs.androidx.mediarouter // playApi "com.google.android.support:wearable:2.9.0" - playApi 'com.google.android.gms:play-services-cast-framework:21.5.0' + playApi libs.play.services.cast.framework } apply plugin: "io.realm.kotlin" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index 2af57abd..04525353 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -57,6 +57,7 @@ import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter +import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.PlayerDetailsFragment.Companion.media3Controller import ac.mdiq.podcini.ui.utils.NotificationUtils import ac.mdiq.podcini.ui.widget.WidgetUpdater import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState @@ -66,7 +67,6 @@ import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.config.ClientConfig -import ac.mdiq.podcini.util.showStackTrace import ac.mdiq.vista.extractor.MediaFormat import ac.mdiq.vista.extractor.stream.AudioStream import ac.mdiq.vista.extractor.stream.DeliveryMethod @@ -165,7 +165,6 @@ class PlaybackService : MediaLibraryService() { private var autoSkippedFeedMediaId: String? = null internal var normalSpeed = 1.0f -// private val mBinder: IBinder = LocalBinder() private var clickCount = 0 private val clickHandler = Handler(Looper.getMainLooper()) @@ -1169,6 +1168,7 @@ class PlaybackService : MediaLibraryService() { var position = position val duration_: Int if (fromMediaPlayer) { +// position = (media3Controller?.currentPosition ?: 0).toInt() // testing the controller position = curPosition duration_ = this.curDuration playable = curMedia diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt index c6b6bb9c..303c2411 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt @@ -12,20 +12,22 @@ import com.google.android.material.snackbar.Snackbar import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.PagerFragmentBinding +import ac.mdiq.podcini.databinding.SimpleIconListItemBinding import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.adapter.SimpleIconListAdapter import ac.mdiq.podcini.util.IntentUtils.openInBrowser import android.R.color import android.content.DialogInterface import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter import android.widget.ListView import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.ListFragment import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter +import coil.load import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator @@ -98,7 +100,7 @@ class AboutFragment : PreferenceFragmentCompat() { "", lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent)) } withContext(Dispatchers.Main) { - listAdapter = SimpleIconListAdapter(requireContext(), licenses) + listAdapter = ContributorsPagerFragment.SimpleIconListAdapter(requireContext(), licenses) } }.invokeOnCompletion { throwable -> if (throwable!= null) { @@ -108,7 +110,7 @@ class AboutFragment : PreferenceFragmentCompat() { } private class LicenseItem(title: String, subtitle: String, imageUrl: String, val licenseUrl: String, val licenseTextFile: String) - : SimpleIconListAdapter.ListItem(title, subtitle, imageUrl) + : ContributorsPagerFragment.SimpleIconListAdapter.ListItem(title, subtitle, imageUrl) override fun onListItemClick(l: ListView, v: View, position: Int, id: Long) { super.onListItemClick(l, v, position, id) @@ -263,6 +265,27 @@ class AboutFragment : PreferenceFragmentCompat() { } } + /** + * Displays a list of items that have a subtitle and an icon. + */ + class SimpleIconListAdapter(private val context: Context, private val listItems: List) + : ArrayAdapter(context, R.layout.simple_icon_list_item, listItems) { + + override fun getView(position: Int, view: View?, parent: ViewGroup): View { + var view = view + if (view == null) view = View.inflate(context, R.layout.simple_icon_list_item, null) + + val item: ListItem = listItems[position] + val binding = SimpleIconListItemBinding.bind(view!!) + binding.title.text = item.title + binding.subtitle.text = item.subtitle + binding.icon.load(item.imageUrl) + return view + } + + open class ListItem(val title: String, val subtitle: String, val imageUrl: String) + } + companion object { private const val POS_DEVELOPERS = 0 private const val POS_TRANSLATORS = 1 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt index e9ab4968..3fea6ed2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt @@ -39,8 +39,6 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val fun handleAction(items: List) { when (actionId) { R.id.toggle_favorite_batch -> toggleFavorite(items) -// R.id.add_to_favorite_batch -> markFavorite(items, true) -// R.id.remove_favorite_batch -> markFavorite(items, false) R.id.add_to_queue_batch -> queueChecked(items) R.id.put_in_queue_batch -> PutToQueueDialog(activity, items).show() R.id.remove_from_queue_batch -> removeFromQueueChecked(items) @@ -48,14 +46,6 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *items.toTypedArray()) // showMessage(R.plurals.marked_read_batch_label, items.size) } -// R.id.mark_read_batch -> { -// setPlayState(Episode.PLAYED, false, *items.toTypedArray()) -// showMessage(R.plurals.marked_read_batch_label, items.size) -// } -// R.id.mark_unread_batch -> { -// setPlayState(Episode.UNPLAYED, false, *items.toTypedArray()) -// showMessage(R.plurals.marked_unread_batch_label, items.size) -// } R.id.download_batch -> downloadChecked(items) R.id.delete_batch -> deleteChecked(items) else -> Log.e(TAG, "Unrecognized speed dial action item. Do nothing. id=$actionId") @@ -64,18 +54,18 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val private fun queueChecked(items: List) { // Check if an episode actually contains any media files before adding it to queue - val toQueue = mutableListOf() - for (episode in items) { - if (episode.media != null) toQueue.add(episode.id) - } +// val toQueue = mutableListOf() +// for (episode in items) { +// if (episode.media != null) toQueue.add(episode.id) +// } Queues.addToQueue(true, *items.toTypedArray()) - showMessage(R.plurals.added_to_queue_batch_label, toQueue.size) + showMessage(R.plurals.added_to_queue_batch_label, items.size) } private fun removeFromQueueChecked(items: List) { - val checkedIds = getSelectedIds(items) +// val checkedIds = getSelectedIds(items) removeFromQueue(*items.toTypedArray()) - showMessage(R.plurals.removed_from_queue_batch_label, checkedIds.size) + showMessage(R.plurals.removed_from_queue_batch_label, items.size) } private fun toggleFavorite(items: List) { @@ -109,13 +99,13 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val } } - private fun getSelectedIds(items: List): List { - val checkedIds = mutableListOf() - for (i in items.indices) { - checkedIds.add(items[i].id) - } - return checkedIds - } +// private fun getSelectedIds(items: List): List { +// val checkedIds = mutableListOf() +// for (i in items.indices) { +// checkedIds.add(items[i].id) +// } +// return checkedIds +// } class PutToQueueDialog(activity: Activity, val items: List) { private val activityRef: WeakReference = WeakReference(activity) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt index ebe46775..31b2d000 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt @@ -318,7 +318,8 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v return context.getString(R.string.delete_episode_label) } - @UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + @UnstableApi + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { if (!item.isDownloaded && item.feed?.isLocalFeed != true) return deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item)) } @@ -345,7 +346,8 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v return context.getString(R.string.add_to_favorite_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + @OptIn(UnstableApi::class) + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { setFavorite(item, !item.isFavorite) } @@ -371,7 +373,8 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v return context.getString(R.string.no_action_label) } - @UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {} + @UnstableApi + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {} override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { return false @@ -397,7 +400,8 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v return context.getString(R.string.remove_history_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + @OptIn(UnstableApi::class) + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { val playbackCompletionDate: Date? = item.media?.playbackCompletionDate deleteFromHistory(item) @@ -433,7 +437,8 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v return context.getString(R.string.remove_from_queue_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + @OptIn(UnstableApi::class) + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { val position: Int = curQueue.episodes.indexOf(item) removeFromQueue(item) if (willRemove(filter, item)) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index ef78c73a..c9729ff0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -27,6 +27,7 @@ import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.dialog.RatingDialog import ac.mdiq.podcini.ui.fragment.* +import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.PlayerDetailsFragment.Companion.media3Controller import ac.mdiq.podcini.ui.statistics.StatisticsFragment import ac.mdiq.podcini.ui.utils.LockableBottomSheetBehavior import ac.mdiq.podcini.ui.utils.ThemeUtils.getDrawableFromAttr @@ -417,6 +418,7 @@ class MainActivity : CastEnabledActivity() { QueuesFragment.TAG -> fragment = QueuesFragment() AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment() DownloadsFragment.TAG -> fragment = DownloadsFragment() + DownloadsCFragment.TAG -> fragment = DownloadsCFragment() HistoryFragment.TAG -> fragment = HistoryFragment() OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment() SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment() @@ -493,11 +495,9 @@ class MainActivity : CastEnabledActivity() { private fun setNavDrawerSize() { // Tablet layout does not have a drawer if (drawerLayout == null) return - val screenPercent = resources.getInteger(R.integer.nav_drawer_screen_size_percent) * 0.01f val width = (screenWidth * screenPercent).toInt() val maxWidth = resources.getDimension(R.dimen.nav_drawer_max_screen_size).toInt() - navDrawer.layoutParams.width = min(width.toDouble(), maxWidth.toDouble()).toInt() Logd(TAG, "setNavDrawerSize: ${navDrawer.layoutParams.width}") } @@ -514,7 +514,7 @@ class MainActivity : CastEnabledActivity() { val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() controllerFuture.addListener({ -// mediaController = controllerFuture.get() + media3Controller = controllerFuture.get() // Logd(TAG, "controllerFuture.addListener: $mediaController") }, MoreExecutors.directExecutor()) } @@ -651,12 +651,12 @@ class MainActivity : CastEnabledActivity() { handleNavIntent() } - fun showSnackbarAbovePlayer(text: CharSequence?, duration: Int): Snackbar { + fun showSnackbarAbovePlayer(text: CharSequence, duration: Int): Snackbar { val s: Snackbar if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) { - s = Snackbar.make(mainView, text!!, duration) + s = Snackbar.make(mainView, text, duration) if (audioPlayerView.visibility == View.VISIBLE) s.setAnchorView(audioPlayerView) - } else s = Snackbar.make(binding.root, text!!, duration) + } else s = Snackbar.make(binding.root, text, duration) s.show() return s diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt index 6f6ef7c0..1be036a3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt @@ -87,7 +87,6 @@ import kotlin.math.max */ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallback: ((Int, Episode) -> Unit)? = null) : SelectableAdapter(mainActivity) { - private val TAG: String = this::class.simpleName ?: "Anonymous" val mainActivityRef: WeakReference = WeakReference(mainActivity) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SimpleIconListAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SimpleIconListAdapter.kt deleted file mode 100644 index 56787e10..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SimpleIconListAdapter.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ac.mdiq.podcini.ui.adapter - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.SimpleIconListItemBinding -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import coil.load - -/** - * Displays a list of items that have a subtitle and an icon. - */ -class SimpleIconListAdapter(private val context: Context, private val listItems: List) - : ArrayAdapter(context, R.layout.simple_icon_list_item, listItems) { - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - var view = view - if (view == null) view = View.inflate(context, R.layout.simple_icon_list_item, null) - - val item: ListItem = listItems[position] - val binding = SimpleIconListItemBinding.bind(view!!) - binding.title.text = item.title - binding.subtitle.text = item.subtitle - binding.icon.load(item.imageUrl) - return view - } - - open class ListItem(val title: String, val subtitle: String, val imageUrl: String) -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index 4b7ceba6..961e403e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -1,8 +1,14 @@ package ac.mdiq.podcini.ui.compose +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterialApi::class) @Composable @@ -45,3 +51,35 @@ fun Spinner( } } } + +@Composable +fun SpeedDial( + modifier: Modifier = Modifier, + mainButtonIcon: @Composable () -> Unit, + fabButtons: List<@Composable () -> Unit>, + onMainButtonClick: () -> Unit, + onFabButtonClick: (Int) -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + Column( + modifier = modifier, + verticalArrangement = Arrangement.Bottom + ) { + if (isExpanded) { + fabButtons.forEachIndexed { index, button -> + FloatingActionButton( + onClick = { onFabButtonClick(index) }, + modifier = Modifier.padding(bottom = 4.dp) + ) { + button() + } + } + } + FloatingActionButton( + onClick = { onMainButtonClick(); isExpanded = !isExpanded }, +// modifier = Modifier.padding(bottom = 16.dp) + ) { + mainButtonIcon() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt new file mode 100644 index 00000000..3d66d6c2 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt @@ -0,0 +1,323 @@ +package ac.mdiq.podcini.ui.compose + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.download.service.DownloadServiceInterface +import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.base.InTheatre.curQueue +import ac.mdiq.podcini.storage.database.Episodes +import ac.mdiq.podcini.storage.database.Episodes.setPlayState +import ac.mdiq.podcini.storage.database.Queues +import ac.mdiq.podcini.storage.database.Queues.removeFromQueue +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.MediaType +import ac.mdiq.podcini.storage.utils.DurationConverter +import ac.mdiq.podcini.storage.utils.ImageResourceUtils +import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler.PutToQueueDialog +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.adapter.EpisodesAdapter.EpisodeInfoFragment +import ac.mdiq.podcini.ui.fragment.FeedInfoFragment +import ac.mdiq.podcini.ui.utils.LocalDeleteModal +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev +import android.text.format.Formatter +import android.util.TypedValue +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import coil.compose.AsyncImage +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun InforBar(text: MutableState, leftActionConfig: () -> Unit, rightActionConfig: () -> Unit) { +// val textState by remember { mutableStateOf(text) } + val textColor = MaterialTheme.colors.onSurface + Logd("InforBar", "textState: ${text.value}") + Row { + Image(painter = painterResource(R.drawable.ic_questionmark), contentDescription = "left_action_icon", + Modifier.width(24.dp).height(24.dp).clickable(onClick = leftActionConfig)) + Image(painter = painterResource(R.drawable.baseline_arrow_left_alt_24), contentDescription = "left_arrow", Modifier.width(24.dp).height(24.dp)) + Spacer(modifier = Modifier.weight(1f)) + Text(text.value, color = textColor, style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.weight(1f)) + Image(painter = painterResource(R.drawable.baseline_arrow_right_alt_24), contentDescription = "right_arrow", Modifier.width(24.dp).height(24.dp)) + Image(painter = painterResource(R.drawable.ic_questionmark), contentDescription = "right_action_icon", + Modifier.width(24.dp).height(24.dp).clickable(onClick = rightActionConfig)) + } +} + +@Composable +fun EpisodeSpeedDialOptions(activity: MainActivity, selected: List): List<@Composable () -> Unit> { + return listOf<@Composable () -> Unit>( + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_delete: ${selected.size}") + LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected) + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") + Text(stringResource(id = R.string.delete_episode_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_download: ${selected.size}") + for (episode in selected) { + if (episode.media != null && episode.feed != null && !episode.feed!!.isLocalFeed) DownloadServiceInterface.get()?.download(activity, episode) + } + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") + Text(stringResource(id = R.string.download_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_mark_played: ${selected.size}") + setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray()) + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "") + Text(stringResource(id = R.string.toggle_played_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_playlist_remove: ${selected.size}") + removeFromQueue(*selected.toTypedArray()) + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "") + Text(stringResource(id = R.string.remove_from_queue_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_playlist_play: ${selected.size}") + Queues.addToQueue(true, *selected.toTypedArray()) + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") + Text(stringResource(id = R.string.add_to_queue_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_playlist_play: ${selected.size}") + PutToQueueDialog(activity, selected).show() + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") + Text(stringResource(id = R.string.put_in_queue_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + Logd("EpisodeSpeedDialActions", "ic_star: ${selected.size}") + for (item in selected) { + Episodes.setFavorite(item, null) + } + } + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "") + Text(stringResource(id = R.string.toggle_favorite_label)) + } }, + ) +} + +@Composable +fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList, leftAction: (Episode) -> Unit, rightAction: (Episode) -> Unit) { + var selectMode by remember { mutableStateOf(false) } + var longPressedItem by remember { mutableStateOf(null) } + var longPressedPosition by remember { mutableStateOf(0) } + val selectedIds by remember { mutableStateOf(mutableSetOf()) } + val selected = remember { mutableListOf()} + val coroutineScope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn(modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(episodes) { index, episode -> + val offsetX = remember { Animatable(0f) } + val velocityTracker = remember { VelocityTracker() } + Box( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { velocityTracker.resetTracking() }, + onHorizontalDrag = { change, dragAmount -> + velocityTracker.addPosition(change.uptimeMillis, change.position) + coroutineScope.launch { offsetX.snapTo(offsetX.value + dragAmount) } + }, + onDragEnd = { + coroutineScope.launch { + val velocity = velocityTracker.calculateVelocity().x + if (velocity > 1000f || velocity < -1000f) { + if (velocity > 0) { + Logd("EpisodeLazyColumn","Fling to the right with velocity: $velocity") + rightAction(episode) + } else { + Logd("EpisodeLazyColumn","Fling to the left with velocity: $velocity") + leftAction(episode) + } + } + offsetX.animateTo( + targetValue = 0f, // Back to the initial position + animationSpec = tween(500) // Adjust animation duration as needed + ) + } + } + ) + } + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + ) { + var isSelected by remember { mutableStateOf(false) } + isSelected = selectMode && episode.id in selectedIds + EpisodeRow(episode, mutableStateOf(isSelected), + onClick = { + Logd("EpisodeLazyColumn", "clicked: ${episode.title}") + if (selectMode) { + isSelected = !isSelected + if (isSelected) { + selectedIds.add(episode.id) + selected.add(episode) + } else { + selectedIds.remove(episode.id) + selected.remove(episode) + } + } else activity.loadChildFragment(EpisodeInfoFragment.newInstance(episode)) + }, + onLongClick = { + selectMode = !selectMode + if (selectMode) { + isSelected = true + selectedIds.add(episode.id) + selected.add(episode) + } else { + isSelected = false + selectedIds.clear() + } + Logd("EpisodeLazyColumn", "long clicked: ${episode.title}") + longPressedItem = episode + longPressedPosition = index + }, + iconOnClick = { + Logd("EpisodeLazyColumn", "icon clicked!") + if (selectMode) { + isSelected = !isSelected + if (isSelected) { + selectedIds.add(episode.id) + selected.add(episode) + } else { + selectedIds.remove(episode.id) + selected.remove(episode) + } + } else activity.loadChildFragment(FeedInfoFragment.newInstance(episode.feed!!)) + }) + } + } + } + if (selectMode) SpeedDial( + modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp), + mainButtonIcon = { Icon(Icons.Filled.Edit, "Edit") }, + fabButtons = EpisodeSpeedDialOptions(activity, selected), + onMainButtonClick = { }, + onFabButtonClick = { index -> + Logd("EpisodeLazyColumn", "onFabButtonClick: $index }") + } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EpisodeRow(episode: Episode, isSelected: MutableState, onClick: () -> Unit, onLongClick: () -> Unit, iconOnClick: () -> Unit = {}) { + val textColor = MaterialTheme.colors.onSurface + Row (Modifier.background(if (isSelected.value) MaterialTheme.colors.secondary else MaterialTheme.colors.surface)) { + if (false) { + val typedValue = TypedValue() + LocalContext.current.theme.resolveAttribute(R.attr.dragview_background, typedValue, true) + Image(painter = painterResource(typedValue.resourceId), + contentDescription = "drag handle", + modifier = Modifier.width(16.dp).align(Alignment.CenterVertically)) + } + ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) { + var playedState by remember { mutableStateOf(false) } + playedState = episode.isPlayed() + Logd("EpisodeRow", "playedState: $playedState") + val (image1, image2) = createRefs() + val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(episode) + AsyncImage(model = imgLoc, contentDescription = "imgvCover", + Modifier.width(56.dp) + .height(56.dp) + .clickable(onClick = iconOnClick) + .constrainAs(image1) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + }) + val alpha = if (playedState) 1.0f else 0f + if (playedState) Image(painter = painterResource(R.drawable.ic_check), contentDescription = "played_mark", + Modifier.background(Color.Green).alpha(alpha).constrainAs(image2) { + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + }) + } + Column(Modifier.weight(1f).padding(start = 6.dp, end = 6.dp) + .combinedClickable(onClick = onClick, onLongClick = onLongClick)) { + Row { + if (episode.media?.getMediaType() == MediaType.VIDEO) + Image(painter = painterResource(R.drawable.ic_videocam), contentDescription = "isVideo", Modifier.width(14.dp).height(14.dp)) + if (episode.isFavorite) + Image(painter = painterResource(R.drawable.ic_star), contentDescription = "isFavorite", Modifier.width(14.dp).height(14.dp)) + if (curQueue.contains(episode)) + Image(painter = painterResource(R.drawable.ic_playlist_play), contentDescription = "ivInPlaylist", Modifier.width(14.dp).height(14.dp)) + Text("·", color = textColor) + Text(formatAbbrev(LocalContext.current, episode.getPubDate()), color = textColor, style = MaterialTheme.typography.body2) + Text("·", color = textColor) + Text(if((episode.media?.size?:0) > 0) Formatter.formatShortFileSize(LocalContext.current, episode.media!!.size) else "", color = textColor, style = MaterialTheme.typography.body2) + } + Text(episode.title?:"", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (InTheatre.isCurMedia(episode.media) || episode.isInProgress) { + val pos = episode.media!!.getPosition() + val dur = episode.media!!.getDuration() + val prog = if (dur > 0 && pos >= 0 && dur >= pos) 1.0f * pos / dur else 0f + Row { + Text(DurationConverter.getDurationStringLong(pos), color = textColor) + LinearProgressIndicator( + progress = prog, + modifier = Modifier.weight(1f).height(4.dp).align(Alignment.CenterVertically) + ) + Text(DurationConverter.getDurationStringLong(dur), color = textColor) + } + } + } + IconButton( + onClick = { /* Do something */ }, + Modifier.align(Alignment.CenterVertically) + ) { + Image( + painter = painterResource(R.drawable.ic_delete), + contentDescription = "Delete" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 119c967b..9fcedd93 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -1196,6 +1196,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar private const val PREF_SCROLL_Y = "prefScrollY" private const val PREF_PLAYABLE_ID = "prefPlayableId" + var media3Controller: MediaController? = null + var prefs: SharedPreferences? = null fun getSharedPrefs(context: Context) { if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsCFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsCFragment.kt new file mode 100644 index 00000000..51e42d9a --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsCFragment.kt @@ -0,0 +1,508 @@ +package ac.mdiq.podcini.ui.fragment + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.DownloadsFragmentBinding +import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding +import ac.mdiq.podcini.net.download.service.DownloadServiceInterface +import ac.mdiq.podcini.preferences.UserPreferences +import ac.mdiq.podcini.preferences.UserPreferences.appPrefs +import ac.mdiq.podcini.storage.database.Episodes.getEpisodes +import ac.mdiq.podcini.storage.database.RealmDB.realm +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.upsert +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeFilter +import ac.mdiq.podcini.storage.model.EpisodeMedia +import ac.mdiq.podcini.storage.model.EpisodeSortOrder +import ac.mdiq.podcini.storage.utils.EpisodeUtil +import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn +import ac.mdiq.podcini.ui.compose.InforBar +import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog +import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog +import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog +import ac.mdiq.podcini.ui.utils.EmptyViewHandler +import ac.mdiq.podcini.util.EventFlow +import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import com.google.android.material.appbar.MaterialToolbar +import com.leinardi.android.speeddial.SpeedDialActionItem +import com.leinardi.android.speeddial.SpeedDialView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.commons.lang3.StringUtils +import java.io.File +import java.util.* + +/** + * Displays all completed downloads and provides a button to delete them. + */ +@UnstableApi class DownloadsCFragment : Fragment(), Toolbar.OnMenuItemClickListener { + + private var _binding: DownloadsFragmentBinding? = null + private val binding get() = _binding!! + + private var runningDownloads: Set = HashSet() + private var episodes = mutableStateListOf() + + private var infoBarText = mutableStateOf("") + + private lateinit var toolbar: MaterialToolbar +// private lateinit var recyclerView: EpisodesRecyclerView + private lateinit var swipeActions: SwipeActions + private lateinit var speedDialView: SpeedDialView + private lateinit var emptyView: EmptyViewHandler + + private var displayUpArrow = false + + @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = DownloadsFragmentBinding.inflate(inflater) + + Logd(TAG, "fragment onCreateView") + toolbar = binding.toolbar +// toolbar.setTitle(R.string.downloadsC_label) + toolbar.setTitle("Preview only") + toolbar.inflateMenu(R.menu.downloads_completed) + toolbar.setOnMenuItemClickListener(this) + toolbar.setOnLongClickListener { +// recyclerView.scrollToPosition(5) +// recyclerView.post { recyclerView.smoothScrollToPosition(0) } + false + } + displayUpArrow = parentFragmentManager.backStackEntryCount != 0 + if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) + + (activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow) + + swipeActions = SwipeActions(this, TAG) + binding.infobar.setContent { + CustomTheme(requireContext()) { + InforBar(infoBarText, leftActionConfig = {swipeActions.showDialog()}, rightActionConfig = { swipeActions.showDialog() }) + } + } + + binding.lazyColumn.setContent { + CustomTheme(requireContext()) { + EpisodeLazyColumn(activity as MainActivity, episodes = episodes, + leftAction = { swipeActions.actions?.left?.performAction(it, this, EpisodeFilter())}, + rightAction = { swipeActions.actions?.right?.performAction(it, this, EpisodeFilter())}) + } + } +// recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool) +// adapter.setOnSelectModeListener(this) +// recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) + +// swipeActions = SwipeActions(this, TAG).attachTo(recyclerView) + lifecycle.addObserver(swipeActions) + swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.downloaded.name)) + refreshSwipeTelltale() +// binding.leftActionIcon.setOnClickListener { swipeActions.showDialog() } +// binding.rightActionIcon.setOnClickListener { swipeActions.showDialog() } + +// val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator +// if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false + + val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root) + speedDialView = multiSelectDial.fabSD + speedDialView.overlayLayout = multiSelectDial.fabSDOverlay + speedDialView.inflate(R.menu.episodes_apply_action_speeddial) + speedDialView.removeActionItemById(R.id.download_batch) + speedDialView.removeActionItemById(R.id.remove_from_queue_batch) + speedDialView.setOnChangeListener(object : SpeedDialView.OnChangeListener { + override fun onMainActionSelected(): Boolean { + return false + } + override fun onToggleChanged(open: Boolean) { +// if (open && adapter.selectedCount == 0) { +// (activity as MainActivity).showSnackbarAbovePlayer(R.string.no_items_selected, Snackbar.LENGTH_SHORT) +// speedDialView.close() +// } + } + }) + speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> +// adapter.selectedItems.let { +// EpisodeMultiSelectHandler((activity as MainActivity), actionItem.id).handleAction(it) +// } +// adapter.endSelectMode() + true + } + if (arguments != null && requireArguments().getBoolean(ARG_SHOW_LOGS, false)) DownloadLogFragment().show(childFragmentManager, null) + + addEmptyView() + return binding.root + } + + fun leftAction(episode: Episode) { + swipeActions.actions?.left?.performAction(episode, this, EpisodeFilter()) + } + + override fun onStart() { + super.onStart() + procFlowEvents() + loadItems() + } + + override fun onStop() { + super.onStop() + cancelFlowEvents() +// val recyclerView = binding.recyclerView +// val childCount = recyclerView.childCount +// for (i in 0 until childCount) { +// val child = recyclerView.getChildAt(i) +// val viewHolder = recyclerView.getChildViewHolder(child) as? EpisodeViewHolder +// viewHolder?.stopDBMonitor() +// } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_UP_ARROW, displayUpArrow) + super.onSaveInstanceState(outState) + } + + override fun onDestroyView() { + Logd(TAG, "onDestroyView") + _binding = null +// adapter.endSelectMode() +// adapter.clearData() + toolbar.setOnMenuItemClickListener(null) + toolbar.setOnLongClickListener(null) + episodes.clear() + + super.onDestroyView() + } + + @UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.filter_items -> DownloadsFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) + R.id.action_download_logs -> DownloadLogFragment().show(childFragmentManager, null) + R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance()) + R.id.downloads_sort -> DownloadsSortDialog().show(childFragmentManager, "SortDialog") + R.id.switch_queue -> SwitchQueueDialog(activity as MainActivity).show() + R.id.reconcile -> reconcile() + else -> return false + } + return true + } + + private fun getFilter(): EpisodeFilter { + return EpisodeFilter(prefFilterDownloads) + } + + private val nameEpisodeMap: MutableMap = mutableMapOf() + private val filesRemoved: MutableList = mutableListOf() + private fun reconcile() { + runOnIOScope { + val items = realm.query(Episode::class).query("media.episode == nil").find() + Logd(TAG, "number of episode with null backlink: ${items.size}") + for (item in items) { + upsert(item) { it.media!!.episode = it } + } + nameEpisodeMap.clear() + for (e in episodes) { + var fileUrl = e.media?.fileUrl ?: continue + fileUrl = fileUrl.substring(fileUrl.lastIndexOf('/') + 1) + Logd(TAG, "reconcile: fileUrl: $fileUrl") + nameEpisodeMap[fileUrl] = e + } + val mediaDir = requireContext().getExternalFilesDir("media") ?: return@runOnIOScope + mediaDir.listFiles()?.forEach { file -> traverse(file, mediaDir) } + Logd(TAG, "reconcile: end, episodes missing file: ${nameEpisodeMap.size}") + if (nameEpisodeMap.isNotEmpty()) { + for (e in nameEpisodeMap.values) { + upsertBlk(e) { it.media?.setfileUrlOrNull(null) } + } + } + loadItems() + Logd(TAG, "Episodes reconsiled: ${nameEpisodeMap.size}\nFiles removed: ${filesRemoved.size}") + withContext(Dispatchers.Main) { + Toast.makeText(requireContext(), "Episodes reconsiled: ${nameEpisodeMap.size}\nFiles removed: ${filesRemoved.size}", Toast.LENGTH_LONG).show() + } + } + } + + private fun traverse(srcFile: File, srcRootDir: File) { + val relativePath = srcFile.absolutePath.substring(srcRootDir.absolutePath.length+1) + if (srcFile.isDirectory) { + Logd(TAG, "traverse folder title: $relativePath") + val dirFiles = srcFile.listFiles() + dirFiles?.forEach { file -> traverse(file, srcFile) } + } else { + Logd(TAG, "traverse: $srcFile") + val episode = nameEpisodeMap.remove(relativePath) + if (episode == null) { + Logd(TAG, "traverse: error: episode not exist in map: $relativePath") + filesRemoved.add(relativePath) + srcFile.delete() + return + } + Logd(TAG, "traverse found episode: ${episode.title}") + } + } + + private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { + val newRunningDownloads: MutableSet = HashSet() + for (url in event.urls) { + if (DownloadServiceInterface.get()?.isDownloadingEpisode(url) == true) newRunningDownloads.add(url) + } + if (newRunningDownloads != runningDownloads) { + runningDownloads = newRunningDownloads + loadItems() + return // Refreshed anyway + } +// for (downloadUrl in event.urls) { +// val pos = EpisodeUtil.indexOfItemWithDownloadUrl(episodes.toList(), downloadUrl) +// if (pos >= 0) adapter.notifyItemChangedCompat(pos) +// } + } + + private var eventSink: Job? = null + private var eventStickySink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + eventStickySink?.cancel() + eventStickySink = null + } + private fun procFlowEvents() { + if (eventSink == null) eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) + is FlowEvent.DownloadsFilterEvent -> onFilterChanged(event) + is FlowEvent.EpisodeMediaEvent -> onEpisodeMediaEvent(event) + is FlowEvent.PlayerSettingsEvent -> loadItems() + is FlowEvent.DownloadLogEvent -> loadItems() + is FlowEvent.QueueEvent -> loadItems() + is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() + is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) + else -> {} + } + } + } +// if (eventStickySink == null) eventStickySink = lifecycleScope.launch { +// EventFlow.stickyEvents.collectLatest { event -> +// Logd(TAG, "Received sticky event: ${event.TAG}") +// when (event) { +// is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) +// else -> {} +// } +// } +// } + } + + private fun onFilterChanged(event: FlowEvent.DownloadsFilterEvent) { + val fSet = event.filterValues?.toMutableSet() ?: mutableSetOf() + fSet.add(EpisodeFilter.States.downloaded.name) + prefFilterDownloads = StringUtils.join(fSet, ",") + Logd(TAG, "onFilterChanged: $prefFilterDownloads") + loadItems() + } + + private fun addEmptyView() { + emptyView = EmptyViewHandler(requireContext()) + emptyView.setIcon(R.drawable.ic_download) + emptyView.setTitle(R.string.no_comp_downloads_head_label) + emptyView.setMessage(R.string.no_comp_downloads_label) +// emptyView.attachToRecyclerView(recyclerView) + } + + private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { +// Logd(TAG, "onEpisodeEvent() called with ${event.TAG}") + var i = 0 + val size: Int = event.episodes.size + while (i < size) { + val item: Episode = event.episodes[i++] + val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id) + if (pos >= 0) { + episodes.removeAt(pos) + val media = item.media + if (media != null && media.downloaded) episodes.add(pos, item) + } + } +// have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash +// if (size > 0) adapter.updateItems(episodes) + refreshInfoBar() + } + + private fun onEpisodeMediaEvent(event: FlowEvent.EpisodeMediaEvent) { +// Logd(TAG, "onEpisodeEvent() called with ${event.TAG}") + var i = 0 + val size: Int = event.episodes.size + while (i < size) { + val item: Episode = event.episodes[i++] + val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id) + if (pos >= 0) { + episodes.removeAt(pos) + val media = item.media + if (media != null && media.downloaded) episodes.add(pos, item) + } + } +// have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash +// if (size > 0) adapter.updateItems(episodes) + refreshInfoBar() + } + + private fun refreshSwipeTelltale() { +// if (swipeActions.actions?.left != null) binding.leftActionIcon.setImageResource(swipeActions.actions!!.left!!.getActionIcon()) +// if (swipeActions.actions?.right != null) binding.rightActionIcon.setImageResource(swipeActions.actions!!.right!!.getActionIcon()) + } + + private var loadItemsRunning = false + private fun loadItems() { + emptyView.hide() + if (!loadItemsRunning) { + loadItemsRunning = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + val sortOrder: EpisodeSortOrder? = downloadsSortedOrder + val filter = getFilter() + val downloadedItems = getEpisodes(0, Int.MAX_VALUE, filter, sortOrder) + if (runningDownloads.isEmpty()) { + episodes.clear() + episodes.addAll(downloadedItems) + } else { + val mediaUrls: MutableList = ArrayList() + for (url in runningDownloads) { + if (EpisodeUtil.indexOfItemWithDownloadUrl(downloadedItems, url) != -1) continue + mediaUrls.add(url) + } + val currentDownloads = getEpisdesWithUrl(mediaUrls).toMutableList() + currentDownloads.addAll(downloadedItems) + episodes.clear() + episodes.addAll(currentDownloads) + } + episodes.retainAll { filter.matchesForQueues(it) } + } + withContext(Dispatchers.Main) { +// adapter.setDummyViews(0) +// adapter.updateItems(episodes) + refreshInfoBar() + } + } catch (e: Throwable) { +// adapter.setDummyViews(0) +// adapter.updateItems(mutableListOf()) + Log.e(TAG, Log.getStackTraceString(e)) + } finally { loadItemsRunning = false } + } + } + } + + private fun getEpisdesWithUrl(urls: List): List { + Logd(TAG, "getEpisdesWithUrl() called ") + if (urls.isEmpty()) return listOf() + val episodes: MutableList = mutableListOf() + for (url in urls) { + val media = realm.query(EpisodeMedia::class).query("downloadUrl == $0", url).first().find() ?: continue + val item_ = media.episodeOrFetch() + if (item_ != null) episodes.add(item_) + } + return realm.copyFromRealm(episodes) + } + + private fun refreshInfoBar() { + var info = String.format(Locale.getDefault(), "%d%s", episodes.size, getString(R.string.episodes_suffix)) + if (episodes.isNotEmpty()) { + var sizeMB: Long = 0 + for (item in episodes) sizeMB += item.media?.size ?: 0 + info += " • " + (sizeMB / 1000000) + " MB" + } + Logd(TAG, "filter value: ${getFilter().values.size} ${getFilter().values.joinToString()}") + if (getFilter().values.size > 1) info += " - ${getString(R.string.filtered_label)}" +// binding.infoBar.text = info + infoBarText.value = info + } + +// override fun onStartSelectMode() { +// swipeActions.detach() +// speedDialView.visibility = View.VISIBLE +// } +// +// override fun onEndSelectMode() { +// speedDialView.close() +// speedDialView.visibility = View.GONE +//// swipeActions.attachTo(recyclerView) +// } + + class DownloadsSortDialog : EpisodeSortDialog() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sortOrder = downloadsSortedOrder + } + override fun onAddItem(title: Int, ascending: EpisodeSortOrder, descending: EpisodeSortOrder, ascendingIsDefault: Boolean) { + if (ascending == EpisodeSortOrder.DATE_OLD_NEW + || ascending == EpisodeSortOrder.PLAYED_DATE_OLD_NEW + || ascending == EpisodeSortOrder.COMPLETED_DATE_OLD_NEW + || ascending == EpisodeSortOrder.DOWNLOAD_DATE_OLD_NEW + || ascending == EpisodeSortOrder.DURATION_SHORT_LONG + || ascending == EpisodeSortOrder.EPISODE_TITLE_A_Z + || ascending == EpisodeSortOrder.SIZE_SMALL_LARGE + || ascending == EpisodeSortOrder.FEED_TITLE_A_Z) { + super.onAddItem(title, ascending, descending, ascendingIsDefault) + } + } + override fun onSelectionChanged() { + super.onSelectionChanged() + downloadsSortedOrder = sortOrder + EventFlow.postEvent(FlowEvent.DownloadLogEvent()) + } + } + + class DownloadsFilterDialog : EpisodeFilterDialog() { + override fun onFilterChanged(newFilterValues: Set) { + EventFlow.postEvent(FlowEvent.DownloadsFilterEvent(newFilterValues)) + } + companion object { + fun newInstance(filter: EpisodeFilter?): DownloadsFilterDialog { + val dialog = DownloadsFilterDialog() + dialog.filter = filter + dialog.filtersDisabled.add(FeedItemFilterGroup.DOWNLOADED) + dialog.filtersDisabled.add(FeedItemFilterGroup.MEDIA) + return dialog + } + } + } + + companion object { + val TAG = DownloadsCFragment::class.simpleName ?: "Anonymous" + + const val ARG_SHOW_LOGS: String = "show_logs" + private const val KEY_UP_ARROW = "up_arrow" + + // the sort order for the downloads. + var downloadsSortedOrder: EpisodeSortOrder? + get() { + val sortOrderStr = appPrefs.getString(UserPreferences.Prefs.prefDownloadSortedOrder.name, "" + EpisodeSortOrder.DATE_NEW_OLD.code) + return EpisodeSortOrder.fromCodeString(sortOrderStr) + } + set(sortOrder) { + appPrefs.edit().putString(UserPreferences.Prefs.prefDownloadSortedOrder.name, "" + sortOrder!!.code).apply() + } + + var prefFilterDownloads: String + get() = appPrefs.getString(UserPreferences.Prefs.prefDownloadsFilter.name, EpisodeFilter.States.downloaded.name) ?: EpisodeFilter.States.downloaded.name + set(filter) { + appPrefs.edit().putString(UserPreferences.Prefs.prefDownloadsFilter.name, filter).apply() + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt index 94b29168..5f56a7e1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt @@ -416,7 +416,7 @@ import java.util.* for (item in episodes) sizeMB += item.media?.size ?: 0 info += " • " + (sizeMB / 1000000) + " MB" } - if (getFilter().values.isNotEmpty()) info += " - ${getString(R.string.filtered_label)}" + if (getFilter().values.size > 1) info += " - ${getString(R.string.filtered_label)}" binding.infoBar.text = info } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index e029a299..23fd5ab5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -643,23 +643,13 @@ import java.util.concurrent.Semaphore } } -// private inner class FeedEpisodesAdapter(mainActivity: MainActivity) : EpisodesAdapter(mainActivity, ::refreshPosCallback) { -// @UnstableApi override fun beforeBindViewHolder(holder: EpisodeViewHolder, pos: Int) { -//// holder.coverHolder.visibility = View.GONE -// } -// } - class FeedEpisodeFilterDialog(val feed: Feed?) : EpisodeFilterDialog() { @OptIn(UnstableApi::class) override fun onFilterChanged(newFilterValues: Set) { if (feed != null) { Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]") runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() - if (feed_ != null) { - upsert(feed_) { - it.preferences?.filterString = newFilterValues.joinToString() - } - } + if (feed_ != null) upsert(feed_) { it.preferences?.filterString = newFilterValues.joinToString() } } } } @@ -688,9 +678,7 @@ import java.util.concurrent.Semaphore Logd(TAG, "persist Episode SortOrder") runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() - if (feed_ != null) { - upsert(feed_) { it.sortOrder = sortOrder } - } + if (feed_ != null) upsert(feed_) { it.sortOrder = sortOrder } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt index d94d9d1f..5de63bc4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt @@ -278,9 +278,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { (activity as MainActivity).showSnackbarAbovePlayer(string.ok, Snackbar.LENGTH_SHORT) } } catch (e: Throwable) { - withContext(Dispatchers.Main) { - (activity as MainActivity).showSnackbarAbovePlayer(e.localizedMessage, Snackbar.LENGTH_LONG) - } + withContext(Dispatchers.Main) { (activity as MainActivity).showSnackbarAbovePlayer(e.localizedMessage?:"No message", Snackbar.LENGTH_LONG) } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index d4aab6f8..86e415e1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -183,6 +183,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { QueuesFragment.TAG -> R.drawable.ic_playlist_play AllEpisodesFragment.TAG -> R.drawable.ic_feed DownloadsFragment.TAG -> R.drawable.ic_download + DownloadsCFragment.TAG -> R.drawable.ic_download HistoryFragment.TAG -> R.drawable.ic_history SubscriptionsFragment.TAG -> R.drawable.ic_subscriptions StatisticsFragment.TAG -> R.drawable.ic_chart_box @@ -324,6 +325,29 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { } } } + DownloadsCFragment.TAG -> { + val epCacheSize = episodeCacheSize + // don't count episodes that can be reclaimed + val spaceUsed = ((datasetStats?.numDownloaded ?: 0) - (datasetStats?.numReclaimables ?: 0)) + holder.count.text = NumberFormat.getInstance().format(spaceUsed.toLong()) + holder.count.visibility = View.VISIBLE + if (epCacheSize in 1..spaceUsed) { + holder.count.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_disc_alert, 0) + holder.count.visibility = View.VISIBLE + holder.count.setOnClickListener { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.episode_cache_full_title) + .setMessage(R.string.episode_cache_full_message) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.open_autodownload_settings) { _: DialogInterface?, _: Int -> + val intent = Intent(context, PreferenceActivity::class.java) + intent.putExtra(PreferenceActivity.OPEN_AUTO_DOWNLOAD_SETTINGS, true) + context.startActivity(intent) + } + .show() + } + } + } HistoryFragment.TAG -> { val historyCount = datasetStats?.historyCount ?: 0 if (historyCount > 0) { @@ -374,7 +398,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) } -// caution: an array in re/values/arrays.xml relates to this + // caution: an array in re/values/arrays.xml relates to this @JvmField @UnstableApi val NAV_DRAWER_TAGS: Array = arrayOf( @@ -382,6 +406,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { QueuesFragment.TAG, AllEpisodesFragment.TAG, DownloadsFragment.TAG, + DownloadsCFragment.TAG, HistoryFragment.TAG, StatisticsFragment.TAG, OnlineSearchFragment.TAG, diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt index c36f0017..5a52dd8e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt @@ -177,7 +177,7 @@ class OnlineSearchFragment : Fragment() { } } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) - (getActivity() as MainActivity).showSnackbarAbovePlayer(e.localizedMessage, Snackbar.LENGTH_LONG) + (getActivity() as MainActivity).showSnackbarAbovePlayer(e.localizedMessage?: "No messaage", Snackbar.LENGTH_LONG) } } } diff --git a/app/src/main/res/layout/downloads_fragment.xml b/app/src/main/res/layout/downloads_fragment.xml new file mode 100644 index 00000000..1abe89d9 --- /dev/null +++ b/app/src/main/res/layout/downloads_fragment.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 07e1584c..cf2975da 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -140,6 +140,7 @@ @string/queue_label @string/episodes_label @string/downloads_label + @string/downloadsC_label @string/playback_history_label @string/statistics_label @string/add_feed_label @@ -167,6 +168,7 @@ QueuesFragment AllEpisodesFragment DownloadsFragment + DownloadsCFragment PlaybackHistoryFragment AddFeedFragment StatisticsFragment @@ -178,6 +180,7 @@ @string/queue_label @string/episodes_label @string/downloads_label + @string/downloadsC_label @string/playback_history_label @string/add_feed_label @string/statistics_label diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28f5066b..54b6dc3a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Favorites Settings Downloads + DownloadsC Open settings Download log Subscriptions diff --git a/changelog.md b/changelog.md index 3bffdef0..90be11ac 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +# 6.7.3 + +* fixed bug in nextcloud auth: thanks to Dacid99 +* fixed "filtered" always shown in Downloads info bar +* minor enhancement in multi-select actions handling +* on-going work to replace recycler view, recycler adapter and view holder for Episodes with Jetpack Compose routines +* introduced "DownloadsC", an early preview (not fully implemented) of the Compose work + # 6.7.2 * added menu item for removing feed in FeedInfo view diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44bc4fc1..adaf6a7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,150 @@ [versions] +activityCompose = "1.9.2" +annotation = "1.8.2" +appcompat = "1.7.0" +awaitility = "4.2.1" +balloon = "1.6.6" +coil = "2.7.0" +commonsLang3 = "3.15.0" +commonsIo = "2.16.1" +composeBom = "2024.09.02" +conscryptAndroid = "2.5.2" +constraintlayoutCompose = "1.0.1" +coordinatorlayout = "1.2.0" +coreKtx = "1.13.1" +coreKtxVersion = "1.8.1" +coreSplashscreen = "1.0.1" +desugar_jdk_libs_nio = "2.1.2" +documentfile = "1.0.1" +espressoCore = "3.6.1" +fontawesomeTypeface = "5.13.3.0-kotlin" +fyydlin = "v0.5.0" +googleMaterialTypeface = "4.0.0.3-kotlin" +googleMaterialTypefaceOutlined = "4.0.0.2-kotlin" +gridlayout = "1.0.0" +iconicsCore = "5.5.0-b01" +iconicsViews = "5.5.0-b01" +javaxInject = "1" +jsoup = "1.18.1" +junit = "1.2.1" +junitVersion = "4.13.2" kotlin = "2.0.20" +kotlinxCoroutinesAndroid = "1.8.1" +libraryBase = "2.1.0" +lifecycleRuntimeKtx = "2.8.6" +material = "1.7.2" +material3Android = "1.3.0" +materialVersion = "1.12.0" +media3Common = "1.4.1" +media3Session = "1.4.1" +media3Ui = "1.4.1" +media3Exoplayer = "1.4.1" +mediarouter = "1.7.0" +mockitoInline = "5.2.0" +nanohttpd = "2.1.1" +okhttp = "4.12.0" +okhttpUrlconnection = "4.12.0" +okio = "3.9.0" +paletteKtx = "1.0.0" +playServicesBase = "18.5.0" +playServicesCastFramework = "21.5.0" +preferenceKtx = "1.2.1" +readability4j = "1.0.8" +recyclerview = "1.3.2" +recyclerviewswipedecorator = "1.3" +robolectric = "4.13" +rules = "1.6.1" +runner = "1.6.2" +rxandroid = "3.0.2" +rxjava = "2.2.21" +rxjavaVersion = "3.1.8" +speedDial = "3.3.0" +searchpreference = "v2.5.0" +stream = "1.2.2" +uiToolingPreview = "1.7.2" +uiTooling = "1.7.2" +viewpager2 = "1.1.0" +vistaguide = "lv0.24.2.6" +wearable = "2.9.0" +webkit = "1.12.0" +window = "1.3.0" +workRuntime = "2.9.1" +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } +androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorlayout" } +androidx-core = { module = "androidx.test:core", version.ref = "rules" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoCore" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } +androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoCore" } +androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3Ui" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } +androidx-mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" } +androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +androidx-rules = { module = "androidx.test:rules", version.ref = "rules" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "uiToolingPreview" } +androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +androidx-window = { module = "androidx.window:window", version.ref = "window" } +androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } +awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" } +balloon = { module = "com.github.skydoves:balloon", version.ref = "balloon" } +coil = { module = "io.coil-kt:coil", version.ref = "coil" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +commons-io = { module = "commons-io:commons-io", version.ref = "commonsIo" } +commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commonsLang3" } +conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroid" } +core-ktx = { module = "com.google.android.play:core-ktx", version.ref = "coreKtxVersion" } +fontawesome-typeface = { module = "com.mikepenz:fontawesome-typeface", version.ref = "fontawesomeTypeface" } +fyydlin = { module = "com.github.mfietz:fyydlin", version.ref = "fyydlin" } +google-material-typeface-outlined = { module = "com.mikepenz:google-material-typeface-outlined", version.ref = "googleMaterialTypefaceOutlined" } +google-material-typeface = { module = "com.mikepenz:google-material-typeface", version.ref = "googleMaterialTypeface" } +iconics-views = { module = "com.mikepenz:iconics-views", version.ref = "iconicsViews" } +iconics-core = { module = "com.mikepenz:iconics-core", version.ref = "iconicsCore" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInject" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +junit = { module = "junit:junit", version.ref = "junitVersion" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +library-base = { module = "io.realm.kotlin:library-base", version.ref = "libraryBase" } +material = { module = "com.google.android.material:material", version.ref = "materialVersion" } +media3-common = { module = "androidx.media3:media3-common", version.ref = "media3Common" } +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +nanohttpd = { module = "com.nanohttpd:nanohttpd", version.ref = "nanohttpd" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +okhttp3-okhttp-urlconnection = { module = "com.squareup.okhttp3:okhttp-urlconnection", version.ref = "okhttpUrlconnection" } +play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBase" } +play-services-cast-framework = { module = "com.google.android.gms:play-services-cast-framework", version.ref = "playServicesCastFramework" } +readability4j = { module = "net.dankito.readability4j:readability4j", version.ref = "readability4j" } +recyclerviewswipedecorator = { module = "com.github.xabaras:RecyclerViewSwipeDecorator", version.ref = "recyclerviewswipedecorator" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" } +rxjava3-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjavaVersion" } +rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" } +searchpreference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchpreference" } +speed-dial = { module = "com.leinardi.android:speed-dial", version.ref = "speedDial" } +stream = { module = "com.annimon:stream", version.ref = "stream" } +vistaguide = { module = "com.github.XilinJia.vistaguide:VistaGuide", version.ref = "vistaguide" } +desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } +wearable = { module = "com.google.android.wearable:wearable", version.ref = "wearable" } [plugins] org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }