6.7.3 commit

This commit is contained in:
Xilin Jia 2024-09-24 23:40:12 +01:00
parent d84dbeba7e
commit eb6ce74dca
23 changed files with 1235 additions and 167 deletions

View File

@ -12,7 +12,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
[<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/) [<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/)
[<img src="./images/external/amazon.png" alt="Amazon" height="40">](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13) [<img src="./images/external/amazon.png" alt="Amazon" height="40">](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) 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. #### 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. #### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions.

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests" testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020255 versionCode 3020256
versionName "6.7.2" versionName "6.7.3"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""
@ -170,110 +170,115 @@ android {
} }
dependencies { dependencies {
/** Desugaring for using VistaGuide **/ implementation libs.androidx.material3.android
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.2'
implementation 'com.github.XilinJia.vistaguide:VistaGuide:lv0.24.2.6'
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 implementation composeBom
androidTestImplementation composeBom androidTestImplementation composeBom
implementation 'androidx.compose.material:material:1.7.2' implementation libs.androidx.material
implementation 'androidx.compose.ui:ui-tooling-preview:1.7.2' implementation libs.androidx.ui.tooling.preview
debugImplementation 'androidx.compose.ui:ui-tooling:1.7.2' debugImplementation libs.androidx.ui.tooling
implementation libs.androidx.constraintlayout.compose
implementation 'androidx.activity:activity-compose:1.9.2' implementation libs.androidx.activity.compose
implementation 'androidx.window:window:1.3.0' implementation libs.androidx.window
implementation "androidx.core:core-ktx:1.13.1" implementation libs.androidx.core.ktx
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation libs.kotlinx.coroutines.android
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6" implementation libs.androidx.lifecycle.runtime.ktx
implementation "androidx.annotation:annotation:1.8.2" implementation libs.androidx.annotation
implementation 'androidx.appcompat:appcompat:1.7.0' implementation libs.androidx.appcompat
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' implementation libs.androidx.coordinatorlayout
//noinspection UseTomlInstead
implementation "androidx.fragment:fragment-ktx:1.8.3" implementation "androidx.fragment:fragment-ktx:1.8.3"
implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation libs.androidx.gridlayout
implementation "androidx.media3:media3-exoplayer:1.4.1" implementation libs.androidx.media3.exoplayer
implementation "androidx.media3:media3-ui:1.4.1" implementation libs.androidx.media3.ui
implementation "androidx.media3:media3-datasource-okhttp:1.4.1" implementation libs.androidx.media3.media3.datasource.okhttp
implementation "androidx.media3:media3-common:1.4.1" implementation libs.media3.common
implementation "androidx.media3:media3-session:1.4.1" implementation libs.media3.session
implementation "androidx.palette:palette-ktx:1.0.0" implementation libs.androidx.palette.ktx
implementation "androidx.preference:preference-ktx:1.2.1" implementation libs.androidx.preference.ktx
implementation "androidx.recyclerview:recyclerview:1.3.2" implementation libs.androidx.recyclerview
implementation "androidx.viewpager2:viewpager2:1.1.0" implementation libs.androidx.viewpager2
implementation "androidx.work:work-runtime:2.9.1" implementation libs.androidx.work.runtime
implementation "androidx.core:core-splashscreen:1.0.1" implementation libs.androidx.core.splashscreen
implementation 'androidx.documentfile:documentfile:1.0.1' implementation libs.androidx.documentfile
implementation 'androidx.webkit:webkit:1.12.0' 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 libs.commons.lang3
implementation 'commons-io:commons-io:2.16.1' implementation libs.commons.io
implementation 'org.jsoup:jsoup:1.18.1' 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 libs.okhttp
implementation "com.squareup.okhttp3:okhttp-urlconnection:4.12.0" implementation libs.okhttp3.okhttp.urlconnection
implementation 'com.squareup.okio:okio:3.9.0' implementation libs.okio
implementation "io.reactivex.rxjava2:rxjava:2.2.21" implementation libs.rxjava
implementation "io.reactivex.rxjava3:rxjava:3.1.8" implementation libs.rxjava3.rxjava
implementation "io.reactivex.rxjava3:rxandroid:3.0.2" implementation libs.rxandroid
// 5.5.0-b01 is newer than 5.5.0-compose01 // 5.5.0-b01 is newer than 5.5.0-compose01
implementation 'com.mikepenz:iconics-core:5.5.0-b01' implementation libs.iconics.core
implementation 'com.mikepenz:iconics-views:5.5.0-b01' implementation libs.iconics.views
implementation 'com.mikepenz:google-material-typeface:4.0.0.3-kotlin@aar' implementation libs.google.material.typeface
implementation 'com.mikepenz:google-material-typeface-outlined:4.0.0.2-kotlin@aar' implementation libs.google.material.typeface.outlined
implementation 'com.mikepenz:fontawesome-typeface:5.13.3.0-kotlin@aar' implementation libs.fontawesome.typeface
implementation 'com.leinardi.android:speed-dial:3.3.0' implementation libs.speed.dial
implementation 'com.github.ByteHamster:SearchPreference:v2.5.0' implementation libs.searchpreference
implementation 'com.github.skydoves:balloon:1.6.6' implementation libs.balloon
implementation 'com.github.xabaras:RecyclerViewSwipeDecorator:1.3' implementation libs.recyclerviewswipedecorator
implementation "com.annimon:stream:1.2.2" 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' // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
// Non-free dependencies: // Non-free dependencies:
playImplementation 'com.google.android.play:core-ktx:1.8.1' playImplementation libs.core.ktx
compileOnly "com.google.android.wearable:wearable:2.9.0" compileOnly libs.wearable
// this one can not be updated? TODO: need to get an alternative // 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 libs.androidx.espresso.core
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.6.1" androidTestImplementation libs.androidx.espresso.contrib
androidTestImplementation "androidx.test.espresso:espresso-intents:3.6.1" androidTestImplementation libs.androidx.espresso.intents
androidTestImplementation "androidx.test:runner:1.6.2" androidTestImplementation libs.androidx.runner
androidTestImplementation "androidx.test:rules:1.6.1" androidTestImplementation libs.androidx.rules
androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation libs.androidx.junit
androidTestImplementation 'org.awaitility:awaitility:4.2.1' androidTestImplementation libs.awaitility
// Non-free dependencies: // Non-free dependencies:
testImplementation "androidx.test:core:1.6.1" testImplementation libs.androidx.core
testImplementation 'org.awaitility:awaitility:4.2.1' testImplementation libs.awaitility
testImplementation "junit:junit:4.13.2" testImplementation libs.junit
testImplementation 'org.mockito:mockito-inline:5.2.0' testImplementation libs.mockito.inline
testImplementation 'org.robolectric:robolectric:4.13' testImplementation libs.robolectric
testImplementation 'javax.inject:javax.inject:1' testImplementation libs.javax.inject
playImplementation 'com.google.android.gms:play-services-base:18.5.0' playImplementation libs.play.services.base
freeImplementation 'org.conscrypt:conscrypt-android:2.5.2' 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.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" apply plugin: "io.realm.kotlin"

View File

@ -57,6 +57,7 @@ import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter 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.utils.NotificationUtils
import ac.mdiq.podcini.ui.widget.WidgetUpdater import ac.mdiq.podcini.ui.widget.WidgetUpdater
import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState 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.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ClientConfig import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.podcini.util.showStackTrace
import ac.mdiq.vista.extractor.MediaFormat import ac.mdiq.vista.extractor.MediaFormat
import ac.mdiq.vista.extractor.stream.AudioStream import ac.mdiq.vista.extractor.stream.AudioStream
import ac.mdiq.vista.extractor.stream.DeliveryMethod import ac.mdiq.vista.extractor.stream.DeliveryMethod
@ -165,7 +165,6 @@ class PlaybackService : MediaLibraryService() {
private var autoSkippedFeedMediaId: String? = null private var autoSkippedFeedMediaId: String? = null
internal var normalSpeed = 1.0f internal var normalSpeed = 1.0f
// private val mBinder: IBinder = LocalBinder()
private var clickCount = 0 private var clickCount = 0
private val clickHandler = Handler(Looper.getMainLooper()) private val clickHandler = Handler(Looper.getMainLooper())
@ -1169,6 +1168,7 @@ class PlaybackService : MediaLibraryService() {
var position = position var position = position
val duration_: Int val duration_: Int
if (fromMediaPlayer) { if (fromMediaPlayer) {
// position = (media3Controller?.currentPosition ?: 0).toInt() // testing the controller
position = curPosition position = curPosition
duration_ = this.curDuration duration_ = this.curDuration
playable = curMedia playable = curMedia

View File

@ -12,20 +12,22 @@ import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.PagerFragmentBinding import ac.mdiq.podcini.databinding.PagerFragmentBinding
import ac.mdiq.podcini.databinding.SimpleIconListItemBinding
import ac.mdiq.podcini.ui.activity.PreferenceActivity import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.adapter.SimpleIconListAdapter
import ac.mdiq.podcini.util.IntentUtils.openInBrowser import ac.mdiq.podcini.util.IntentUtils.openInBrowser
import android.R.color import android.R.color
import android.content.DialogInterface import android.content.DialogInterface
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView import android.widget.ListView
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.ListFragment import androidx.fragment.app.ListFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@ -98,7 +100,7 @@ class AboutFragment : PreferenceFragmentCompat() {
"", lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent)) "", lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent))
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
listAdapter = SimpleIconListAdapter(requireContext(), licenses) listAdapter = ContributorsPagerFragment.SimpleIconListAdapter(requireContext(), licenses)
} }
}.invokeOnCompletion { throwable -> }.invokeOnCompletion { throwable ->
if (throwable!= null) { 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) 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) { override fun onListItemClick(l: ListView, v: View, position: Int, id: Long) {
super.onListItemClick(l, v, position, id) 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<T : SimpleIconListAdapter.ListItem>(private val context: Context, private val listItems: List<T>)
: ArrayAdapter<T>(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 { companion object {
private const val POS_DEVELOPERS = 0 private const val POS_DEVELOPERS = 0
private const val POS_TRANSLATORS = 1 private const val POS_TRANSLATORS = 1

View File

@ -39,8 +39,6 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
fun handleAction(items: List<Episode>) { fun handleAction(items: List<Episode>) {
when (actionId) { when (actionId) {
R.id.toggle_favorite_batch -> toggleFavorite(items) 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.add_to_queue_batch -> queueChecked(items)
R.id.put_in_queue_batch -> PutToQueueDialog(activity, items).show() R.id.put_in_queue_batch -> PutToQueueDialog(activity, items).show()
R.id.remove_from_queue_batch -> removeFromQueueChecked(items) 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()) setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *items.toTypedArray())
// showMessage(R.plurals.marked_read_batch_label, items.size) // 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.download_batch -> downloadChecked(items)
R.id.delete_batch -> deleteChecked(items) R.id.delete_batch -> deleteChecked(items)
else -> Log.e(TAG, "Unrecognized speed dial action item. Do nothing. id=$actionId") 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<Episode>) { private fun queueChecked(items: List<Episode>) {
// Check if an episode actually contains any media files before adding it to queue // Check if an episode actually contains any media files before adding it to queue
val toQueue = mutableListOf<Long>() // val toQueue = mutableListOf<Long>()
for (episode in items) { // for (episode in items) {
if (episode.media != null) toQueue.add(episode.id) // if (episode.media != null) toQueue.add(episode.id)
} // }
Queues.addToQueue(true, *items.toTypedArray()) 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<Episode>) { private fun removeFromQueueChecked(items: List<Episode>) {
val checkedIds = getSelectedIds(items) // val checkedIds = getSelectedIds(items)
removeFromQueue(*items.toTypedArray()) 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<Episode>) { private fun toggleFavorite(items: List<Episode>) {
@ -109,13 +99,13 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
} }
} }
private fun getSelectedIds(items: List<Episode>): List<Long> { // private fun getSelectedIds(items: List<Episode>): List<Long> {
val checkedIds = mutableListOf<Long>() // val checkedIds = mutableListOf<Long>()
for (i in items.indices) { // for (i in items.indices) {
checkedIds.add(items[i].id) // checkedIds.add(items[i].id)
} // }
return checkedIds // return checkedIds
} // }
class PutToQueueDialog(activity: Activity, val items: List<Episode>) { class PutToQueueDialog(activity: Activity, val items: List<Episode>) {
private val activityRef: WeakReference<Activity> = WeakReference(activity) private val activityRef: WeakReference<Activity> = WeakReference(activity)

View File

@ -318,7 +318,8 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v
return context.getString(R.string.delete_episode_label) 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 if (!item.isDownloaded && item.feed?.isLocalFeed != true) return
deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item)) 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) 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) 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) 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 { override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false 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) 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 val playbackCompletionDate: Date? = item.media?.playbackCompletionDate
deleteFromHistory(item) 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) 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) val position: Int = curQueue.episodes.indexOf(item)
removeFromQueue(item) removeFromQueue(item)
if (willRemove(filter, item)) { if (willRemove(filter, item)) {

View File

@ -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.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.dialog.RatingDialog import ac.mdiq.podcini.ui.dialog.RatingDialog
import ac.mdiq.podcini.ui.fragment.* 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.statistics.StatisticsFragment
import ac.mdiq.podcini.ui.utils.LockableBottomSheetBehavior import ac.mdiq.podcini.ui.utils.LockableBottomSheetBehavior
import ac.mdiq.podcini.ui.utils.ThemeUtils.getDrawableFromAttr import ac.mdiq.podcini.ui.utils.ThemeUtils.getDrawableFromAttr
@ -417,6 +418,7 @@ class MainActivity : CastEnabledActivity() {
QueuesFragment.TAG -> fragment = QueuesFragment() QueuesFragment.TAG -> fragment = QueuesFragment()
AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment() AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment()
DownloadsFragment.TAG -> fragment = DownloadsFragment() DownloadsFragment.TAG -> fragment = DownloadsFragment()
DownloadsCFragment.TAG -> fragment = DownloadsCFragment()
HistoryFragment.TAG -> fragment = HistoryFragment() HistoryFragment.TAG -> fragment = HistoryFragment()
OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment() OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment()
SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment() SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment()
@ -493,11 +495,9 @@ class MainActivity : CastEnabledActivity() {
private fun setNavDrawerSize() { private fun setNavDrawerSize() {
// Tablet layout does not have a drawer // Tablet layout does not have a drawer
if (drawerLayout == null) return if (drawerLayout == null) return
val screenPercent = resources.getInteger(R.integer.nav_drawer_screen_size_percent) * 0.01f val screenPercent = resources.getInteger(R.integer.nav_drawer_screen_size_percent) * 0.01f
val width = (screenWidth * screenPercent).toInt() val width = (screenWidth * screenPercent).toInt()
val maxWidth = resources.getDimension(R.dimen.nav_drawer_max_screen_size).toInt() val maxWidth = resources.getDimension(R.dimen.nav_drawer_max_screen_size).toInt()
navDrawer.layoutParams.width = min(width.toDouble(), maxWidth.toDouble()).toInt() navDrawer.layoutParams.width = min(width.toDouble(), maxWidth.toDouble()).toInt()
Logd(TAG, "setNavDrawerSize: ${navDrawer.layoutParams.width}") Logd(TAG, "setNavDrawerSize: ${navDrawer.layoutParams.width}")
} }
@ -514,7 +514,7 @@ class MainActivity : CastEnabledActivity() {
val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java))
controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() controllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
controllerFuture.addListener({ controllerFuture.addListener({
// mediaController = controllerFuture.get() media3Controller = controllerFuture.get()
// Logd(TAG, "controllerFuture.addListener: $mediaController") // Logd(TAG, "controllerFuture.addListener: $mediaController")
}, MoreExecutors.directExecutor()) }, MoreExecutors.directExecutor())
} }
@ -651,12 +651,12 @@ class MainActivity : CastEnabledActivity() {
handleNavIntent() handleNavIntent()
} }
fun showSnackbarAbovePlayer(text: CharSequence?, duration: Int): Snackbar { fun showSnackbarAbovePlayer(text: CharSequence, duration: Int): Snackbar {
val s: Snackbar val s: Snackbar
if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) { 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) 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() s.show()
return s return s

View File

@ -87,7 +87,6 @@ import kotlin.math.max
*/ */
open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallback: ((Int, Episode) -> Unit)? = null) open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallback: ((Int, Episode) -> Unit)? = null)
: SelectableAdapter<EpisodeViewHolder?>(mainActivity) { : SelectableAdapter<EpisodeViewHolder?>(mainActivity) {
private val TAG: String = this::class.simpleName ?: "Anonymous" private val TAG: String = this::class.simpleName ?: "Anonymous"
val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity) val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)

View File

@ -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<T : SimpleIconListAdapter.ListItem>(private val context: Context, private val listItems: List<T>)
: ArrayAdapter<T>(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)
}

View File

@ -1,8 +1,14 @@
package ac.mdiq.podcini.ui.compose 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.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @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()
}
}
}

View File

@ -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<String>, 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<Episode>): 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<Episode>, leftAction: (Episode) -> Unit, rightAction: (Episode) -> Unit) {
var selectMode by remember { mutableStateOf(false) }
var longPressedItem by remember { mutableStateOf<Episode?>(null) }
var longPressedPosition by remember { mutableStateOf(0) }
val selectedIds by remember { mutableStateOf(mutableSetOf<Long>()) }
val selected = remember { mutableListOf<Episode>()}
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<Boolean>, 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"
)
}
}
}

View File

@ -1196,6 +1196,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
private const val PREF_SCROLL_Y = "prefScrollY" private const val PREF_SCROLL_Y = "prefScrollY"
private const val PREF_PLAYABLE_ID = "prefPlayableId" private const val PREF_PLAYABLE_ID = "prefPlayableId"
var media3Controller: MediaController? = null
var prefs: SharedPreferences? = null var prefs: SharedPreferences? = null
fun getSharedPrefs(context: Context) { fun getSharedPrefs(context: Context) {
if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE) if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE)

View File

@ -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<String> = HashSet()
private var episodes = mutableStateListOf<Episode>()
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<String, Episode> = mutableMapOf()
private val filesRemoved: MutableList<String> = 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<String> = 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<String> = 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<String>): List<Episode> {
Logd(TAG, "getEpisdesWithUrl() called ")
if (urls.isEmpty()) return listOf()
val episodes: MutableList<Episode> = 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<String>) {
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()
}
}
}

View File

@ -416,7 +416,7 @@ import java.util.*
for (item in episodes) sizeMB += item.media?.size ?: 0 for (item in episodes) sizeMB += item.media?.size ?: 0
info += "" + (sizeMB / 1000000) + " MB" 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 binding.infoBar.text = info
} }

View File

@ -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() { class FeedEpisodeFilterDialog(val feed: Feed?) : EpisodeFilterDialog() {
@OptIn(UnstableApi::class) override fun onFilterChanged(newFilterValues: Set<String>) { @OptIn(UnstableApi::class) override fun onFilterChanged(newFilterValues: Set<String>) {
if (feed != null) { if (feed != null) {
Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]") Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]")
runOnIOScope { runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) { if (feed_ != null) upsert(feed_) { it.preferences?.filterString = newFilterValues.joinToString() }
upsert(feed_) {
it.preferences?.filterString = newFilterValues.joinToString()
}
}
} }
} }
} }
@ -688,9 +678,7 @@ import java.util.concurrent.Semaphore
Logd(TAG, "persist Episode SortOrder") Logd(TAG, "persist Episode SortOrder")
runOnIOScope { runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) { if (feed_ != null) upsert(feed_) { it.sortOrder = sortOrder }
upsert(feed_) { it.sortOrder = sortOrder }
}
} }
} }
} }

View File

@ -278,9 +278,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
(activity as MainActivity).showSnackbarAbovePlayer(string.ok, Snackbar.LENGTH_SHORT) (activity as MainActivity).showSnackbarAbovePlayer(string.ok, Snackbar.LENGTH_SHORT)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { (activity as MainActivity).showSnackbarAbovePlayer(e.localizedMessage?:"No message", Snackbar.LENGTH_LONG) }
(activity as MainActivity).showSnackbarAbovePlayer(e.localizedMessage, Snackbar.LENGTH_LONG)
}
} }
} }
} }

View File

@ -183,6 +183,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
QueuesFragment.TAG -> R.drawable.ic_playlist_play QueuesFragment.TAG -> R.drawable.ic_playlist_play
AllEpisodesFragment.TAG -> R.drawable.ic_feed AllEpisodesFragment.TAG -> R.drawable.ic_feed
DownloadsFragment.TAG -> R.drawable.ic_download DownloadsFragment.TAG -> R.drawable.ic_download
DownloadsCFragment.TAG -> R.drawable.ic_download
HistoryFragment.TAG -> R.drawable.ic_history HistoryFragment.TAG -> R.drawable.ic_history
SubscriptionsFragment.TAG -> R.drawable.ic_subscriptions SubscriptionsFragment.TAG -> R.drawable.ic_subscriptions
StatisticsFragment.TAG -> R.drawable.ic_chart_box 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 -> { HistoryFragment.TAG -> {
val historyCount = datasetStats?.historyCount ?: 0 val historyCount = datasetStats?.historyCount ?: 0
if (historyCount > 0) { if (historyCount > 0) {
@ -374,7 +398,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) 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 @JvmField
@UnstableApi @UnstableApi
val NAV_DRAWER_TAGS: Array<String> = arrayOf( val NAV_DRAWER_TAGS: Array<String> = arrayOf(
@ -382,6 +406,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
QueuesFragment.TAG, QueuesFragment.TAG,
AllEpisodesFragment.TAG, AllEpisodesFragment.TAG,
DownloadsFragment.TAG, DownloadsFragment.TAG,
DownloadsCFragment.TAG,
HistoryFragment.TAG, HistoryFragment.TAG,
StatisticsFragment.TAG, StatisticsFragment.TAG,
OnlineSearchFragment.TAG, OnlineSearchFragment.TAG,

View File

@ -177,7 +177,7 @@ class OnlineSearchFragment : Fragment() {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e)) 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)
} }
} }
} }

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:attr/actionBarSize"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/infobar"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/lazyColumn"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<include
layout="@layout/multi_select_speed_dial" />
</LinearLayout>

View File

@ -140,6 +140,7 @@
<item>@string/queue_label</item> <item>@string/queue_label</item>
<item>@string/episodes_label</item> <item>@string/episodes_label</item>
<item>@string/downloads_label</item> <item>@string/downloads_label</item>
<item>@string/downloadsC_label</item>
<item>@string/playback_history_label</item> <item>@string/playback_history_label</item>
<item>@string/statistics_label</item> <item>@string/statistics_label</item>
<item>@string/add_feed_label</item> <item>@string/add_feed_label</item>
@ -167,6 +168,7 @@
<item>QueuesFragment</item> <item>QueuesFragment</item>
<item>AllEpisodesFragment</item> <item>AllEpisodesFragment</item>
<item>DownloadsFragment</item> <item>DownloadsFragment</item>
<item>DownloadsCFragment</item>
<item>PlaybackHistoryFragment</item> <item>PlaybackHistoryFragment</item>
<item>AddFeedFragment</item> <item>AddFeedFragment</item>
<item>StatisticsFragment</item> <item>StatisticsFragment</item>
@ -178,6 +180,7 @@
<item>@string/queue_label</item> <item>@string/queue_label</item>
<item>@string/episodes_label</item> <item>@string/episodes_label</item>
<item>@string/downloads_label</item> <item>@string/downloads_label</item>
<item>@string/downloadsC_label</item>
<item>@string/playback_history_label</item> <item>@string/playback_history_label</item>
<item>@string/add_feed_label</item> <item>@string/add_feed_label</item>
<item>@string/statistics_label</item> <item>@string/statistics_label</item>

View File

@ -16,6 +16,7 @@
<string name="favorite_episodes_label">Favorites</string> <string name="favorite_episodes_label">Favorites</string>
<string name="settings_label">Settings</string> <string name="settings_label">Settings</string>
<string name="downloads_label">Downloads</string> <string name="downloads_label">Downloads</string>
<string name="downloadsC_label">DownloadsC</string>
<string name="open_autodownload_settings">Open settings</string> <string name="open_autodownload_settings">Open settings</string>
<string name="downloads_log_label">Download log</string> <string name="downloads_log_label">Download log</string>
<string name="subscriptions_label">Subscriptions</string> <string name="subscriptions_label">Subscriptions</string>

View File

@ -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 # 6.7.2
* added menu item for removing feed in FeedInfo view * added menu item for removing feed in FeedInfo view

View File

@ -1,6 +1,150 @@
[versions] [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" 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] [plugins]
org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }