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/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)
#### 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.

View File

@ -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"

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.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

View File

@ -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<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 {
private const val POS_DEVELOPERS = 0
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>) {
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<Episode>) {
// Check if an episode actually contains any media files before adding it to queue
val toQueue = mutableListOf<Long>()
for (episode in items) {
if (episode.media != null) toQueue.add(episode.id)
}
// val toQueue = mutableListOf<Long>()
// 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<Episode>) {
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<Episode>) {
@ -109,13 +99,13 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
}
}
private fun getSelectedIds(items: List<Episode>): List<Long> {
val checkedIds = mutableListOf<Long>()
for (i in items.indices) {
checkedIds.add(items[i].id)
}
return checkedIds
}
// private fun getSelectedIds(items: List<Episode>): List<Long> {
// val checkedIds = mutableListOf<Long>()
// for (i in items.indices) {
// checkedIds.add(items[i].id)
// }
// return checkedIds
// }
class PutToQueueDialog(activity: Activity, val items: List<Episode>) {
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)
}
@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)) {

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.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

View File

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

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_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)

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
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
}

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() {
@OptIn(UnstableApi::class) override fun onFilterChanged(newFilterValues: Set<String>) {
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 }
}
}
}

View File

@ -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) }
}
}
}

View File

@ -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<String> = arrayOf(
@ -382,6 +406,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
QueuesFragment.TAG,
AllEpisodesFragment.TAG,
DownloadsFragment.TAG,
DownloadsCFragment.TAG,
HistoryFragment.TAG,
StatisticsFragment.TAG,
OnlineSearchFragment.TAG,

View File

@ -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)
}
}
}

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

View File

@ -16,6 +16,7 @@
<string name="favorite_episodes_label">Favorites</string>
<string name="settings_label">Settings</string>
<string name="downloads_label">Downloads</string>
<string name="downloadsC_label">DownloadsC</string>
<string name="open_autodownload_settings">Open settings</string>
<string name="downloads_log_label">Download log</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
* added menu item for removing feed in FeedInfo view

View File

@ -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" }