6.15.0 commit

This commit is contained in:
Xilin Jia 2024-11-29 13:28:02 +01:00
parent 8c13b7e5a1
commit 884abb5eec
40 changed files with 1082 additions and 1763 deletions

View File

@ -26,8 +26,8 @@ android {
vectorDrawables.useSupportLibrary false
vectorDrawables.generatedDensities = []
versionCode 3020307
versionName "6.14.8"
versionCode 3020308
versionName "6.15.0"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -190,32 +190,6 @@
android:resource="@xml/player_widget_info"/>
</receiver>
<activity
android:name=".ui.activity.OpmlImportActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:label="@string/opml_import_label"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:mimeType="text/xml"/>
<data android:mimeType="text/x-opml"/>
<data android:mimeType="application/xml"/>
<data android:scheme="file"/>
<data android:scheme="content"/>
<data android:scheme="http"/>
<data android:scheme="https"/>
<!-- <data android:host="*"/>-->
<data android:pathPattern="/.*\\.xml" />
<data android:pathPattern="/.*\\.opml" />
</intent-filter>
</activity>
<activity
android:name=".ui.activity.BugReportActivity"
android:label="@string/bug_report_title">
@ -257,6 +231,7 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="http"/>
<data android:host="*"/>
<data android:scheme="https"/>
<data android:mimeType="text/xml"/>
<data android:mimeType="application/rss+xml"/>

View File

@ -233,16 +233,16 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
{ ctx: Context -> MainActivityStarter(ctx).withDownloadLogsOpen().start() },
applicationContext.getString(R.string.download_error_details)))
}
private fun getDownloadLogsIntent(context: Context): PendingIntent {
val intent = MainActivityStarter(context).withDownloadLogsOpen().getIntent()
return PendingIntent.getActivity(context, R.id.pending_intent_download_service_report, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun getDownloadsIntent(context: Context): PendingIntent {
val intent = MainActivityStarter(context).withFragmentLoaded("DownloadsFragment").getIntent()
return PendingIntent.getActivity(context, R.id.pending_intent_download_service_notification, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
// private fun getDownloadLogsIntent(context: Context): PendingIntent {
// val intent = MainActivityStarter(context).withDownloadLogsOpen().getIntent()
// return PendingIntent.getActivity(context, R.id.pending_intent_download_service_report, intent,
// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
// }
// private fun getDownloadsIntent(context: Context): PendingIntent {
// val intent = MainActivityStarter(context).withFragmentLoaded("DownloadsFragment").getIntent()
// return PendingIntent.getActivity(context, R.id.pending_intent_download_service_notification, intent,
// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
// }
private fun sendErrorNotification(title: String) {
// TODO: need to get number of subscribers in SharedFlow
// if (EventBus.getDefault().hasSubscriberForEvent(FlowEvent.MessageEvent::class.java)) {
@ -254,7 +254,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
.setContentTitle(applicationContext.getString(R.string.download_report_title))
.setContentText(applicationContext.getString(R.string.download_error_tap_for_details))
.setSmallIcon(R.drawable.ic_notification_sync_error)
.setContentIntent(getDownloadLogsIntent(applicationContext))
// .setContentIntent(getDownloadLogsIntent(applicationContext))
.setAutoCancel(true)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -277,7 +277,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
.setContentTitle(applicationContext.getString(R.string.download_notification_title_episodes))
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
.setContentIntent(getDownloadsIntent(applicationContext))
// .setContentIntent(getDownloadsIntent(applicationContext))
.setAutoCancel(false)
.setOngoing(true)
.setWhen(0)

View File

@ -245,7 +245,7 @@ class WifiSyncService(val context: Context, params: WorkerParameters) : SyncSer
// only push downloaded items
val pausedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.paused.name), EpisodeSortOrder.DATE_NEW_OLD)
val readItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.played.name), EpisodeSortOrder.DATE_NEW_OLD)
val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.favorite.name), EpisodeSortOrder.DATE_NEW_OLD)
val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD)
val comItems = mutableSetOf<Episode>()
comItems.addAll(pausedItems)
comItems.addAll(readItems)

View File

@ -16,6 +16,10 @@ import android.util.Log
import android.util.Xml
import androidx.core.app.ActivityCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.apache.commons.io.input.BOMInputStream
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
@ -164,54 +168,51 @@ class OpmlTransporter {
}
companion object {
fun startImport(context: Context, uri: Uri) {
fun startImport(context: Context, uri: Uri, CB: (List<OpmlElement>)->Unit) {
val TAG = "OpmlTransporter"
// CoroutineScope(Dispatchers.IO).launch {
try {
val opmlFileStream = context.contentResolver.openInputStream(uri)
val bomInputStream = BOMInputStream(opmlFileStream)
val bom = bomInputStream.bom
val charsetName = if (bom == null) "UTF-8" else bom.charsetName
val reader: Reader = InputStreamReader(bomInputStream, charsetName)
val opmlReader = OpmlReader()
val result = opmlReader.readDocument(reader)
reader.close()
// withContext(Dispatchers.Main) {
// binding.progressBar.visibility = View.GONE
Logd(TAG, "Parsing was successful")
// readElements = result
// }
} catch (e: Throwable) {
// withContext(Dispatchers.Main) {
Logd(TAG, Log.getStackTraceString(e))
val message = if (e.message == null) "" else e.message!!
if (message.lowercase().contains("permission")) {
val permission = ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
CoroutineScope(Dispatchers.IO).launch {
try {
val opmlFileStream = context.contentResolver.openInputStream(uri)
val bomInputStream = BOMInputStream(opmlFileStream)
val bom = bomInputStream.bom
val charsetName = if (bom == null) "UTF-8" else bom.charsetName
val reader: Reader = InputStreamReader(bomInputStream, charsetName)
val opmlReader = OpmlReader()
val result = opmlReader.readDocument(reader)
reader.close()
withContext(Dispatchers.Main) { CB(result) }
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
Logd(TAG, Log.getStackTraceString(e))
val message = if (e.message == null) "" else e.message!!
if (message.lowercase().contains("permission")) {
val permission = ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
// requestPermission()
return
}
}
// binding.progressBar.visibility = View.GONE
val alert = MaterialAlertDialogBuilder(context)
alert.setTitle(R.string.error_label)
val userReadable = context.getString(R.string.opml_reader_error)
val details = e.message
val total = """
CB(listOf())
return@withContext
}
}
val alert = MaterialAlertDialogBuilder(context)
alert.setTitle(R.string.error_label)
val userReadable = context.getString(R.string.opml_reader_error)
val details = e.message
val total = """
$userReadable
$details
""".trimIndent()
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778), userReadable.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
alert.setMessage(errorMessage)
alert.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778), userReadable.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
alert.setMessage(errorMessage)
alert.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
// finish()
}
alert.show()
CB(listOf())
}
}
alert.show()
// }
}
// }
}
}
}

View File

@ -50,10 +50,12 @@ object UserPreferences {
val isThemeColorTinted: Boolean
get() = Build.VERSION.SDK_INT >= 31 && appPrefs.getBoolean(Prefs.prefTintedColors.name, false)
// not using this
var hiddenDrawerItems: List<String>
get() {
val hiddenItems = appPrefs.getString(Prefs.prefHiddenDrawerItems.name, "")
return hiddenItems?.split(",") ?: listOf()
return listOf()
// val hiddenItems = appPrefs.getString(Prefs.prefHiddenDrawerItems.name, "")
// return hiddenItems?.split(",") ?: listOf()
}
set(items) {
val str = items.joinToString()

View File

@ -22,6 +22,7 @@ import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.Feed.Companion.MAX_NATURAL_SYNTHETIC_ID
import ac.mdiq.podcini.storage.model.FeedPreferences.AudioType
import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction
import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.TAG_ROOT
import ac.mdiq.podcini.storage.utils.FilesUtils.feedfilePath
@ -481,6 +482,13 @@ object Feeds {
return !feed.isLocalFeed || isAutoDeleteLocal
}
fun createYTSyndicates() {
getYoutubeSyndicate(true, false)
getYoutubeSyndicate(false, false)
getYoutubeSyndicate(true, true)
getYoutubeSyndicate(false, true)
}
private fun getYoutubeSyndicate(video: Boolean, music: Boolean): Feed {
var feedId: Long = if (video) 1 else 2
if (music) feedId += 2 // music feed takes ids 3 and 4
@ -492,6 +500,7 @@ object Feeds {
feed = createSynthetic(feedId, name)
feed.type = Feed.FeedType.YOUTUBE.name
feed.hasVideoMedia = video
feed.preferences!!.audioTypeSetting = if (music) AudioType.MOVIE else AudioType.SPEECH
feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
upsertBlk(feed) {}
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED))
@ -509,8 +518,26 @@ object Feeds {
episode.feedId = feed.id
episode.media?.id = episode.id
upsertBlk(episode) {}
feed.episodes.add(episode)
upsertBlk(feed) {}
upsertBlk(feed) {
it.episodes.add(episode)
}
EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false))
return 1
}
fun addToSyndicate(episode: Episode, feed: Feed) : Int {
Logd(TAG, "addToYoutubeSyndicate: feed: ${feed.title}")
if (searchEpisodeByIdentifyingValue(feed.episodes, episode) != null) return 2
Logd(TAG, "addToSyndicate adding new episode: ${episode.title}")
episode.feed = feed
episode.id = Feed.newId()
episode.feedId = feed.id
episode.media?.id = episode.id
upsertBlk(episode) {}
upsertBlk(feed) {
it.episodes.add(episode)
}
EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false))
return 1
}

View File

@ -51,7 +51,7 @@ object Queues {
*/
var queueKeepSortedOrder: EpisodeSortOrder?
get() {
val sortOrderStr = appPrefs.getString(UserPreferences.Prefs.prefQueueKeepSortedOrder.name, "use-default")
val sortOrderStr = appPrefs.getString(UserPreferences.Prefs.prefQueueKeepSortedOrder.name, "use-default")!!
return EpisodeSortOrder.parseWithDefault(sortOrderStr, EpisodeSortOrder.DATE_NEW_OLD)
}
set(sortOrder) {
@ -61,8 +61,8 @@ object Queues {
var enqueueLocation: EnqueueLocation
get() {
val valStr = appPrefs.getString(UserPreferences.Prefs.prefEnqueueLocation.name, EnqueueLocation.BACK.name)
try { return EnqueueLocation.valueOf(valStr!!)
val valStr = appPrefs.getString(UserPreferences.Prefs.prefEnqueueLocation.name, EnqueueLocation.BACK.name)!!
try { return EnqueueLocation.valueOf(valStr)
} catch (t: Throwable) {
// should never happen but just in case
Log.e(TAG, "getEnqueueLocation: invalid value '$valStr' Use default.", t)

View File

@ -40,7 +40,7 @@ object RealmDB {
SubscriptionLog::class,
Chapter::class))
.name("Podcini.realm")
.schemaVersion(35)
.schemaVersion(36)
.migration({ mContext ->
val oldRealm = mContext.oldRealm // old realm using the previous schema
val newRealm = mContext.newRealm // new realm using the new schema

View File

@ -56,6 +56,11 @@ class Episode : RealmObject {
var feedId: Long? = null
// parent in these refers to the original parent of the content (shared)
var parentTitle: String? = null
var parentURL: String? = null
var podcastIndexChapterUrl: String? = null
var playState: Int

View File

@ -7,13 +7,17 @@ import java.io.Serializable
class EpisodeFilter(vararg properties_: String) : Serializable {
val properties: HashSet<String> = setOf(*properties_).filter { it.isNotEmpty() }.map {it.trim()}.toHashSet()
val showQueued: Boolean = properties.contains(States.queued.name)
val showNotQueued: Boolean = properties.contains(States.not_queued.name)
// val showQueued: Boolean = properties.contains(States.queued.name)
// val showNotQueued: Boolean = properties.contains(States.not_queued.name)
val showDownloaded: Boolean = properties.contains(States.downloaded.name)
val showNotDownloaded: Boolean = properties.contains(States.not_downloaded.name)
constructor(properties: String) : this(*(properties.split(",").toTypedArray()))
fun add(vararg properties_: String) {
properties.addAll(setOf(*properties_).filter { it.isNotEmpty() }.map {it.trim()})
}
fun queryString(): String {
val statements: MutableList<String> = mutableListOf()
val mediaTypeQuerys = mutableListOf<String>()
@ -37,7 +41,7 @@ class EpisodeFilter(vararg properties_: String) : Serializable {
if (properties.contains(States.bad.name)) ratingQuerys.add(" rating == ${Rating.BAD.code} ")
if (properties.contains(States.neutral.name)) ratingQuerys.add(" rating == ${Rating.OK.code} ")
if (properties.contains(States.good.name)) ratingQuerys.add(" rating == ${Rating.GOOD.code} ")
if (properties.contains(States.favorite.name)) ratingQuerys.add(" rating == ${Rating.SUPER.code} ")
if (properties.contains(States.superb.name)) ratingQuerys.add(" rating == ${Rating.SUPER.code} ")
if (ratingQuerys.isNotEmpty()) {
val query = StringBuilder(" (" + ratingQuerys[0])
if (ratingQuerys.size > 1) for (r in ratingQuerys.subList(1, ratingQuerys.size)) {
@ -135,8 +139,8 @@ class EpisodeFilter(vararg properties_: String) : Serializable {
no_media,
has_comments,
no_comments,
queued,
not_queued,
// queued,
// not_queued,
downloaded,
not_downloaded,
auto_downloadable,
@ -146,7 +150,7 @@ class EpisodeFilter(vararg properties_: String) : Serializable {
bad,
neutral,
good,
favorite,
superb,
}
enum class EpisodesFilterGroup(val nameRes: Int, vararg values_: ItemProperties) {
@ -155,7 +159,7 @@ class EpisodeFilter(vararg properties_: String) : Serializable {
ItemProperties(R.string.bad, States.bad.name),
ItemProperties(R.string.OK, States.neutral.name),
ItemProperties(R.string.good, States.good.name),
ItemProperties(R.string.Super, States.favorite.name),
ItemProperties(R.string.Super, States.superb.name),
),
PLAY_STATE(R.string.playstate, ItemProperties(R.string.unspecified, States.unspecified.name),
ItemProperties(R.string.building, States.building.name),

View File

@ -38,25 +38,26 @@ enum class EpisodeSortOrder(val code: Int, val res: Int) {
* Converts the string representation to its enum value. If the string value is unknown,
* the given default value is returned.
*/
fun parseWithDefault(value: String?, defaultValue: EpisodeSortOrder): EpisodeSortOrder {
return try { valueOf(value!!) } catch (e: IllegalArgumentException) { defaultValue }
fun parseWithDefault(value: String, defaultValue: EpisodeSortOrder): EpisodeSortOrder {
return try { valueOf(value) } catch (e: IllegalArgumentException) { defaultValue }
}
fun fromCodeString(codeStr: String?): EpisodeSortOrder? {
if (codeStr.isNullOrEmpty()) return null
fun fromCodeString(codeStr: String?): EpisodeSortOrder {
if (codeStr.isNullOrEmpty()) return EPISODE_TITLE_A_Z
val code = codeStr.toInt()
for (sortOrder in entries) {
if (sortOrder.code == code) return sortOrder
}
throw IllegalArgumentException("Unsupported code: $code")
return EPISODE_TITLE_A_Z
// throw IllegalArgumentException("Unsupported code: $code")
}
fun fromCode(code: Int): EpisodeSortOrder? {
return enumValues<EpisodeSortOrder>().firstOrNull { it.code == code }
fun fromCode(code: Int): EpisodeSortOrder {
return enumValues<EpisodeSortOrder>().firstOrNull { it.code == code } ?: EPISODE_TITLE_A_Z
}
fun toCodeString(sortOrder: EpisodeSortOrder?): String? {
return sortOrder?.code?.toString()
fun toCodeString(sortOrder: EpisodeSortOrder): String? {
return sortOrder.code.toString()
}
fun valuesOf(stringValues: Array<String?>): Array<EpisodeSortOrder?> {

View File

@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodeMedia
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
@ -14,7 +14,6 @@ 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.PlayState
import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.fragment.*
@ -22,7 +21,6 @@ import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.fullDateTimeString
import ac.mdiq.podcini.util.MiscFormatter.localDateTimeString
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
@ -55,7 +53,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import java.util.*
@ -70,30 +67,14 @@ interface SwipeAction {
@DrawableRes
fun getActionColor(): Int
fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter)
fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
fun performAction(item: Episode, fragment: Fragment)
}
class SwipeActions(private val fragment: Fragment, private val tag: String) : DefaultLifecycleObserver {
@set:JvmName("setFilterProperty")
var filter: EpisodeFilter? = null
var actions: Actions
init {
actions = getPrefs(tag)
}
var actions: Actions = getPrefs(tag, "")
override fun onStart(owner: LifecycleOwner) {
actions = getPrefs(tag)
}
@JvmName("setFilterFunction")
fun setFilter(filter: EpisodeFilter?) {
this.filter = filter
actions = getPrefs(tag, "")
}
fun showDialog() {
@ -106,7 +87,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
showDialog.value = false
(fragment.view as? ViewGroup)?.removeView(this@apply)
}) {
actions = getPrefs(this@SwipeActions.tag)
actions = getPrefs(this@SwipeActions.tag, "")
// TODO: remove the need of event
EventFlow.postEvent(FlowEvent.SwipeActionsChangedEvent())
}
@ -162,7 +143,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.add_to_queue_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
addToQueue(item)
}
}
@ -180,7 +161,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.combo_action)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
var showDialog by remember { mutableStateOf(true) }
@ -195,7 +176,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
for (action in swipeActions) {
if (action.getId() == ActionTypes.NO_ACTION.name || action.getId() == ActionTypes.COMBO.name) continue
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable {
action.performAction(item, fragment, filter)
action.performAction(item, fragment)
showDialog = false
(fragment.view as? ViewGroup)?.removeView(this@apply)
}) {
@ -231,20 +212,18 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.delete_episode_label)
}
override fun performAction(item_: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item_: Episode, fragment: Fragment) {
var item = item_
if (!item.isDownloaded && item.feed?.isLocalFeed != true) return
val media = item.media
if (media != null) {
val almostEnded = hasAlmostEnded(media)
if (almostEnded && item.playState < PlayState.PLAYED.code) item = runBlocking { setPlayStateSync(PlayState.PLAYED.code, item, resetMediaPosition = true, removeFromQueue = false) }
if (almostEnded && item.playState < PlayState.PLAYED.code)
item = runBlocking { setPlayStateSync(PlayState.PLAYED.code, item, resetMediaPosition = true, removeFromQueue = false) }
if (almostEnded) item = upsertBlk(item) { it.media?.playbackCompletionDate = Date() }
}
deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item))
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showDownloaded && (item.isDownloaded || item.feed?.isLocalFeed == true)
}
}
class SetRatingSwipeAction : SwipeAction {
@ -260,7 +239,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.set_rating_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
var showChooseRatingDialog by mutableStateOf(true)
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
@ -289,7 +268,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.add_opinion_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
var showEditComment by mutableStateOf(true)
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
@ -328,7 +307,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.no_action_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {}
override fun performAction(item: Episode, fragment: Fragment) {}
}
class RemoveFromHistorySwipeAction : SwipeAction {
@ -346,7 +325,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.remove_history_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
val playbackCompletionDate: Date? = item.media?.playbackCompletionDate
val lastPlayedDate = item.media?.lastPlayedTime
setHistoryDates(item)
@ -356,9 +335,6 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
.setAction(fragment.getString(R.string.undo)) {
if (playbackCompletionDate != null) setHistoryDates(item, lastPlayedDate?:0, playbackCompletionDate) }
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return true
}
private fun setHistoryDates(episode: Episode, lastPlayed: Long = 0, completed: Date = Date(0)) {
runOnIOScope {
val episode_ = realm.query(Episode::class).query("id == $0", episode.id).first().find()
@ -386,8 +362,8 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.remove_from_queue_label)
}
override fun performAction(item_: Episode, fragment: Fragment, filter: EpisodeFilter) {
val position: Int = curQueue.episodes.indexOf(item_)
override fun performAction(item_: Episode, fragment: Fragment) {
// val position: Int = curQueue.episodes.indexOf(item_)
var item = item_
val media = item.media
if (media != null) {
@ -398,36 +374,6 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
if (item.playState < PlayState.SKIPPED.code) item = runBlocking { setPlayStateSync(PlayState.SKIPPED.code, item, resetMediaPosition = false, removeFromQueue = false) }
// removeFromQueue(item)
runOnIOScope { removeFromQueueSync(curQueue, item) }
if (willRemove(filter, item)) {
(fragment.requireActivity() as MainActivity).showSnackbarAbovePlayer(fragment.resources.getQuantityString(R.plurals.removed_from_queue_batch_label, 1, 1), Snackbar.LENGTH_LONG)
.setAction(fragment.getString(R.string.undo)) {
addToQueueAt(item, position)
}
}
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showQueued || filter.showNotQueued
}
/**
* Inserts a Episode in the queue at the specified index. The 'read'-attribute of the Episode will be set to
* true. If the Episode is already in the queue, the queue will not be modified.
* @param episode the Episode that should be added to the queue.
* @param index Destination index. Must be in range 0..queue.size()
* @throws IndexOutOfBoundsException if index < 0 || index >= queue.size()
*/
private fun addToQueueAt(episode: Episode, index: Int) : Job {
return runOnIOScope {
if (curQueue.episodeIds.contains(episode.id)) return@runOnIOScope
if (episode.isNew) setPlayState(PlayState.UNPLAYED.code, false, episode)
curQueue = upsert(curQueue) {
it.episodeIds.add(index, episode.id)
it.update()
}
// curQueue.episodes.add(index, episode)
EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, index))
// if (performAutoDownload) autodownloadEpisodeMedia(context)
}
}
}
@ -444,7 +390,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.put_in_queue_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
var showPutToQueueDialog by mutableStateOf(true)
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
@ -473,7 +419,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.download_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) {
DownloadActionButton(item).onClick(fragment.requireContext())
}
@ -493,7 +439,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.set_play_state_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
var showPlayStateDialog by mutableStateOf(true)
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
@ -531,7 +477,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.shelve_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
var showShelveDialog by mutableStateOf(true)
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
@ -560,7 +506,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
override fun getTitle(context: Context): String {
return context.getString(R.string.erase_episodes_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
override fun performAction(item: Episode, fragment: Fragment) {
var showEraseDialog by mutableStateOf(true)
val composeView = ComposeView(fragment.requireContext()).apply {
setContent {
@ -600,16 +546,6 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
return Actions(prefsString)
}
fun getPrefs(tag: String): Actions {
return getPrefs(tag, "")
}
@JvmStatic
fun getPrefsWithDefaults(tag: String): Actions {
val defaultActions = "${ActionTypes.NO_ACTION.name},${ActionTypes.NO_ACTION.name}"
return getPrefs(tag, defaultActions)
}
// fun isSwipeActionEnabled(tag: String): Boolean {
// return prefs!!.getBoolean(KEY_PREFIX_NO_ACTION + tag, true)
// }
@ -638,7 +574,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
val context = LocalContext.current
val textColor = MaterialTheme.colorScheme.onSurface
val actions = getPrefsWithDefaults(tag)
val actions = getPrefs(tag, "${ActionTypes.NO_ACTION.name},${ActionTypes.NO_ACTION.name}")
val leftAction = remember { mutableStateOf(actions.left) }
val rightAction = remember { mutableStateOf(actions.right) }
var keys = swipeActions
@ -679,15 +615,18 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
Dialog(onDismissRequest = { onDismissRequest() }) {
var forFragment = ""
when (tag) {
AllEpisodesFragment.TAG -> {
// AllEpisodesFragment.TAG -> {
// forFragment = stringResource(R.string.episodes_label)
// keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name) }
// }
EpisodesFragment.TAG -> {
forFragment = stringResource(R.string.episodes_label)
keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name) }
}
DownloadsFragment.TAG -> {
forFragment = stringResource(R.string.downloads_label)
keys = keys.filter { a: SwipeAction ->
(!a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name) && !a.getId().equals(ActionTypes.START_DOWNLOAD.name)) }
}
// DownloadsFragment.TAG -> {
// forFragment = stringResource(R.string.downloads_label)
// keys = keys.filter { a: SwipeAction ->
// (!a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name) && !a.getId().equals(ActionTypes.START_DOWNLOAD.name)) }
// }
FeedEpisodesFragment.TAG -> {
forFragment = stringResource(R.string.subscription)
keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name) }
@ -698,10 +637,10 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
(!a.getId().equals(ActionTypes.ADD_TO_QUEUE.name) && !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name)) }.toList()
// keys = keys.filter { a: SwipeAction -> (!a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name)) }
}
HistoryFragment.TAG -> {
forFragment = stringResource(R.string.playback_history_label)
keys = keys.toList()
}
// HistoryFragment.TAG -> {
// forFragment = stringResource(R.string.playback_history_label)
// keys = keys.toList()
// }
else -> {}
}
if (tag != QueuesFragment.TAG) keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_QUEUE.name) }
@ -740,9 +679,7 @@ class SwipeActions(private val fragment: Fragment, private val tag: String) : De
EventFlow.postEvent(FlowEvent.SwipeActionsChangedEvent())
callback()
onDismissRequest()
}) {
Text("Confirm")
}
}) { Text("Confirm") }
}
}
}

View File

@ -456,11 +456,11 @@ class MainActivity : CastEnabledActivity() {
val fragment: Fragment
when (tag) {
QueuesFragment.TAG -> fragment = QueuesFragment()
AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment()
DownloadsFragment.TAG -> fragment = DownloadsFragment()
EpisodesFragment.TAG -> fragment = EpisodesFragment()
// AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment()
// DownloadsFragment.TAG -> fragment = DownloadsFragment()
LogsFragment.TAG -> fragment = LogsFragment()
// SubscriptionLogFragment.TAG -> fragment = SubscriptionLogFragment()
HistoryFragment.TAG -> fragment = HistoryFragment()
// HistoryFragment.TAG -> fragment = HistoryFragment()
OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment()
SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment()
StatisticsFragment.TAG -> fragment = StatisticsFragment()
@ -734,9 +734,9 @@ class MainActivity : CastEnabledActivity() {
"/deeplink/main" -> {
val feature = uri.getQueryParameter("page") ?: return
when (feature) {
"DOWNLOADS" -> loadFragment(DownloadsFragment.TAG, null)
"HISTORY" -> loadFragment(HistoryFragment.TAG, null)
"EPISODES" -> loadFragment(AllEpisodesFragment.TAG, null)
// "DOWNLOADS" -> loadFragment(DownloadsFragment.TAG, null)
// "HISTORY" -> loadFragment(HistoryFragment.TAG, null)
"EPISODES" -> loadFragment(EpisodesFragment.TAG, null)
"QUEUE" -> loadFragment(QueuesFragment.TAG, null)
"SUBSCRIPTIONS" -> loadFragment(SubscriptionsFragment.TAG, null)
"STATISTCS" -> loadFragment(StatisticsFragment.TAG, null)

View File

@ -1,248 +0,0 @@
package ac.mdiq.podcini.ui.activity
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.OpmlSelectionBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlElement
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlReader
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.util.Logd
import android.Manifest
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.apache.commons.io.input.BOMInputStream
import java.io.InputStreamReader
import java.io.Reader
class OpmlImportActivity : AppCompatActivity() {
private var uri: Uri? = null
private var _binding: OpmlSelectionBinding? = null
private val binding get() = _binding!!
private lateinit var selectAll: MenuItem
private lateinit var deselectAll: MenuItem
private var listAdapter: ArrayAdapter<String>? = null
private var readElements: ArrayList<OpmlElement>? = null
private val titleList: List<String>
get() {
val result: MutableList<String> = ArrayList()
if (!readElements.isNullOrEmpty()) for (element in readElements!!) if (element.text != null) result.add(element.text!!)
return result
}
private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) startImport()
else {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.opml_import_ask_read_permission)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> requestPermission() }
.setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> finish() }
.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTheme(this))
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
_binding = OpmlSelectionBinding.inflate(layoutInflater)
setContentView(binding.root)
Logd(TAG, "onCreate")
binding.feedlist.choiceMode = ListView.CHOICE_MODE_MULTIPLE
binding.feedlist.onItemClickListener = OnItemClickListener { _: AdapterView<*>?, _: View?, _: Int, _: Long ->
val checked = binding.feedlist.checkedItemPositions
var checkedCount = 0
for (i in 0 until checked.size()) if (checked.valueAt(i)) checkedCount++
if (listAdapter != null) {
if (checkedCount == listAdapter!!.count) {
selectAll.isVisible = false
deselectAll.isVisible = true
} else {
deselectAll.isVisible = false
selectAll.isVisible = true
}
}
}
binding.butCancel.setOnClickListener {
setResult(RESULT_CANCELED)
finish()
}
binding.butConfirm.setOnClickListener {
binding.progressBar.visibility = View.VISIBLE
val checked = binding.feedlist.checkedItemPositions
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
for (i in 0 until checked.size()) {
if (!checked.valueAt(i)) continue
if (!readElements.isNullOrEmpty()) {
val element = readElements!![checked.keyAt(i)]
val feed = Feed(element.xmlUrl, null, if (element.text != null) element.text else "Unknown podcast")
feed.episodes.clear()
updateFeed(this@OpmlImportActivity, feed, false)
}
}
runOnce(this@OpmlImportActivity)
}
binding.progressBar.visibility = View.GONE
val intent = Intent(this@OpmlImportActivity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
} catch (e: Throwable) {
e.printStackTrace()
binding.progressBar.visibility = View.GONE
Toast.makeText(this@OpmlImportActivity, (e.message ?: "Import error"), Toast.LENGTH_LONG).show()
}
}
}
var uri = intent.data
if (uri != null && uri.toString().startsWith("/")) uri = Uri.parse("file://$uri")
else {
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)
if (extraText != null) uri = Uri.parse(extraText)
}
importUri(uri)
}
private fun importUri(uri: Uri?) {
if (uri == null) {
MaterialAlertDialogBuilder(this).setMessage(R.string.opml_import_error_no_file).setPositiveButton(android.R.string.ok, null).show()
return
}
this.uri = uri
startImport()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
val inflater = menuInflater
inflater.inflate(R.menu.opml_selection_options, menu)
selectAll = menu.findItem(R.id.select_all_item)
deselectAll = menu.findItem(R.id.deselect_all_item)
deselectAll.isVisible = false
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val itemId = item.itemId
when (itemId) {
R.id.select_all_item -> {
selectAll.isVisible = false
selectAllItems(true)
deselectAll.isVisible = true
return true
}
R.id.deselect_all_item -> {
deselectAll.isVisible = false
selectAllItems(false)
selectAll.isVisible = true
return true
}
android.R.id.home -> finish()
}
return false
}
private fun selectAllItems(b: Boolean) {
for (i in 0 until binding.feedlist.count) {
binding.feedlist.setItemChecked(i, b)
}
}
private fun requestPermission() {
requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
/** Starts the import process. */
private fun startImport() {
binding.progressBar.visibility = View.VISIBLE
lifecycleScope.launch(Dispatchers.IO) {
try {
val opmlFileStream = contentResolver.openInputStream(uri!!)
val bomInputStream = BOMInputStream(opmlFileStream)
val bom = bomInputStream.bom
val charsetName = if (bom == null) "UTF-8" else bom.charsetName
val reader: Reader = InputStreamReader(bomInputStream, charsetName)
val opmlReader = OpmlReader()
val result = opmlReader.readDocument(reader)
reader.close()
withContext(Dispatchers.Main) {
binding.progressBar.visibility = View.GONE
Logd(TAG, "Parsing was successful")
readElements = result
listAdapter = ArrayAdapter(this@OpmlImportActivity, android.R.layout.simple_list_item_multiple_choice, titleList)
binding.feedlist.adapter = listAdapter
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
Logd(TAG, Log.getStackTraceString(e))
val message = if (e.message == null) "" else e.message!!
if (message.lowercase().contains("permission")) {
val permission = ActivityCompat.checkSelfPermission(this@OpmlImportActivity, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
requestPermission()
return@withContext
}
}
binding.progressBar.visibility = View.GONE
val alert = MaterialAlertDialogBuilder(this@OpmlImportActivity)
alert.setTitle(R.string.error_label)
val userReadable = getString(R.string.opml_reader_error)
val details = e.message
val total = """
$userReadable
$details
""".trimIndent()
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778), userReadable.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
alert.setMessage(errorMessage)
alert.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> finish() }
alert.show()
}
}
}
}
override fun onDestroy() {
_binding = null
super.onDestroy()
}
companion object {
private val TAG: String = OpmlImportActivity::class.simpleName ?: "Anonymous"
}
}

View File

@ -9,6 +9,7 @@ import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.newBuilder
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.reinit
import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.net.sync.SyncService
import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid
import ac.mdiq.podcini.net.sync.SynchronizationCredentials
@ -28,14 +29,13 @@ import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.hostPort
import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.startInstantSync
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.prefPlaybackSpeed
import ac.mdiq.podcini.preferences.*
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlElement
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter
import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.UserPreferences.defaultPage
import ac.mdiq.podcini.preferences.UserPreferences.fallbackSpeed
import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs
import ac.mdiq.podcini.preferences.UserPreferences.fullNotificationButtons
import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.preferences.UserPreferences.proxyConfig
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.preferences.UserPreferences.setVideoMode
@ -45,6 +45,7 @@ import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl
import ac.mdiq.podcini.storage.database.Episodes.getEpisodes
import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.database.Queues.EnqueueLocation
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
@ -52,9 +53,9 @@ import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName
import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.SwipeActionsDialog
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.OpmlImportSelectionDialog
import ac.mdiq.podcini.ui.compose.PlaybackSpeedDialog
import ac.mdiq.podcini.ui.fragment.*
import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.navMap
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
@ -77,6 +78,7 @@ import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.util.Log
import android.util.Patterns
import android.util.SparseBooleanArray
import android.view.*
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
@ -120,8 +122,6 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.bytehamster.lib.preferencesearch.SearchPreferenceResult
import com.bytehamster.lib.preferencesearch.SearchPreferenceResultListener
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.*
@ -146,7 +146,6 @@ import java.util.*
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import javax.xml.parsers.DocumentBuilderFactory
import kotlin.Throws
import kotlin.math.round
/**
@ -594,12 +593,12 @@ class PreferenceActivity : AppCompatActivity() {
ActivityCompat.recreate(requireActivity())
})
}
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
drawerPreferencesDialog(requireContext(), null)
})) {
Text(stringResource(R.string.pref_nav_drawer_items_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.pref_nav_drawer_items_sum), color = textColor)
}
// Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
// drawerPreferencesDialog(requireContext(), null)
// })) {
// Text(stringResource(R.string.pref_nav_drawer_items_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// Text(stringResource(R.string.pref_nav_drawer_items_sum), color = textColor)
// }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.pref_episode_cover_title), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
@ -731,44 +730,43 @@ class PreferenceActivity : AppCompatActivity() {
enum class DefaultPages(val res: Int) {
SubscriptionsFragment(R.string.subscriptions_label),
QueuesFragment(R.string.queue_label),
AllEpisodesFragment(R.string.episodes_label),
DownloadsFragment(R.string.downloads_label),
EpisodesFragment(R.string.episodes_label),
// DownloadsFragment(R.string.downloads_label),
PlaybackHistoryFragment(R.string.playback_history_label),
AddFeedFragment(R.string.add_feed_label),
StatisticsFragment(R.string.statistics_label),
remember(R.string.remember_last_page);
}
fun drawerPreferencesDialog(context: Context, callback: Runnable?) {
val hiddenItems = hiddenDrawerItems.map { it.trim() }.toMutableSet()
// val navTitles = context.resources.getStringArray(R.array.nav_drawer_titles)
val navTitles = navMap.values.map { context.resources.getString(it.nameRes).trim() }.toTypedArray()
val checked = BooleanArray(navMap.size)
for (i in navMap.keys.indices) {
val tag = navMap.keys.toList()[i]
if (!hiddenItems.contains(tag)) checked[i] = true
}
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(R.string.drawer_preferences)
builder.setMultiChoiceItems(navTitles, checked) { _: DialogInterface?, which: Int, isChecked: Boolean ->
if (isChecked) hiddenItems.remove(navMap.keys.toList()[which])
else hiddenItems.add((navMap.keys.toList()[which]).trim())
}
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
hiddenDrawerItems = hiddenItems.toList()
if (hiddenItems.contains(defaultPage)) {
for (tag in navMap.keys) {
if (!hiddenItems.contains(tag)) {
defaultPage = tag
break
}
}
}
callback?.run()
}
builder.setNegativeButton(R.string.cancel_label, null)
builder.create().show()
}
// fun drawerPreferencesDialog(context: Context, callback: Runnable?) {
// val hiddenItems = hiddenDrawerItems.map { it.trim() }.toMutableSet()
// val navTitles = navMap.values.map { context.resources.getString(it.nameRes).trim() }.toTypedArray()
// val checked = BooleanArray(navMap.size)
// for (i in navMap.keys.indices) {
// val tag = navMap.keys.toList()[i]
// if (!hiddenItems.contains(tag)) checked[i] = true
// }
// val builder = MaterialAlertDialogBuilder(context)
// builder.setTitle(R.string.drawer_preferences)
// builder.setMultiChoiceItems(navTitles, checked) { _: DialogInterface?, which: Int, isChecked: Boolean ->
// if (isChecked) hiddenItems.remove(navMap.keys.toList()[which])
// else hiddenItems.add((navMap.keys.toList()[which]).trim())
// }
// builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
// hiddenDrawerItems = hiddenItems.toList()
// if (hiddenItems.contains(defaultPage)) {
// for (tag in navMap.keys) {
// if (!hiddenItems.contains(tag)) {
// defaultPage = tag
// break
// }
// }
// }
// callback?.run()
// }
// builder.setNegativeButton(R.string.cancel_label, null)
// builder.create().show()
// }
private fun showFullNotificationButtonsDialog() {
val context: Context? = activity
@ -855,10 +853,10 @@ class PreferenceActivity : AppCompatActivity() {
@Suppress("EnumEntryName")
private enum class Prefs(val res: Int, val tag: String) {
prefSwipeQueue(R.string.queue_label, QueuesFragment.TAG),
prefSwipeEpisodes(R.string.episodes_label, AllEpisodesFragment.TAG),
prefSwipeDownloads(R.string.downloads_label, DownloadsFragment.TAG),
prefSwipeEpisodes(R.string.episodes_label, EpisodesFragment.TAG),
// prefSwipeDownloads(R.string.downloads_label, DownloadsFragment.TAG),
prefSwipeFeed(R.string.individual_subscription, FeedEpisodesFragment.TAG),
prefSwipeHistory(R.string.playback_history_label, HistoryFragment.TAG)
// prefSwipeHistory(R.string.playback_history_label, HistoryFragment.TAG)
}
}
@ -1209,32 +1207,145 @@ class PreferenceActivity : AppCompatActivity() {
}
class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(OpmlWriter(), uri, ExportTypes.OPML)
}
private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseOpmlExportPathResult(result) }
private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(HtmlWriter(), uri, ExportTypes.HTML)
}
private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseHtmlExportPathResult(result) }
private val chooseFavoritesExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(FavoritesWriter(), uri, ExportTypes.FAVORITES)
}
private val chooseFavoritesExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseFavoritesExportPathResult(result) }
private val chooseProgressExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(EpisodesProgressWriter(), uri, ExportTypes.PROGRESS)
}
private val chooseProgressExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseProgressExportPathResult(result) }
private val restoreProgressLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data?.data == null) return@registerForActivityResult
val uri = result.data!!.data
uri?.let {
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isJsonFile(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri)
val reader = BufferedReader(InputStreamReader(inputStream))
EpisodeProgressReader.readDocument(reader)
reader.close()
}
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_file_type_toast) + ".json"
showTransportErrorDialog(Throwable(message))
}
}
}
private val restoreProgressLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restoreProgressResult(result) }
private val restoreDatabaseLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult
val uri = result.data!!.data
uri?.let {
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isRealmFile(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
DatabaseTransporter.importBackup(uri, requireContext())
}
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_file_type_toast) + ".realm"
showTransportErrorDialog(Throwable(message))
}
}
}
private val restoreDatabaseLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restoreDatabaseResult(result) }
private val backupDatabaseLauncher = registerForActivityResult<String, Uri>(BackupDatabase()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { DatabaseTransporter.exportToDocument(uri, requireContext()) }
withContext(Dispatchers.Main) {
showExportSuccessSnackbar(uri, "application/x-sqlite3")
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
}
private val backupDatabaseLauncher = registerForActivityResult<String, Uri>(BackupDatabase()) { uri: Uri? -> this.backupDatabaseResult(uri) }
private var showOpmlImportSelectionDialog by mutableStateOf(false)
private val readElements = mutableStateListOf<OpmlElement>()
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) {
uri: Uri? -> this.chooseOpmlImportPathResult(uri) }
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
Logd(TAG, "chooseOpmlImportPathResult: uri: $uri")
OpmlTransporter.startImport(requireContext(), uri) {
readElements.addAll(it)
Logd(TAG, "readElements: ${readElements.size}")
}
// showImportSuccessDialog()
showOpmlImportSelectionDialog = true
}
private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restorePreferencesResult(result) }
private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data?.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isPrefDir(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { PreferencesTransporter.importBackup(uri, requireContext()) }
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_directory_toast) + "Podcini-Prefs"
showTransportErrorDialog(Throwable(message))
}
}
private val backupPreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
@ -1243,11 +1354,45 @@ class PreferenceActivity : AppCompatActivity() {
}
}
private val restoreMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restoreMediaFilesResult(result) }
private val restoreMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data?.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isMediaFilesDir(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { MediaFilesTransporter.importBackup(uri, requireContext()) }
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_directory_toast) + "Podcini-MediaFiles"
showTransportErrorDialog(Throwable(message))
}
}
private val backupMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.exportMediaFilesResult(result) }
private val backupMediaFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode != RESULT_OK || result.data?.data == null) return@registerForActivityResult
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { MediaFilesTransporter.exportToDocument(uri, requireContext()) }
withContext(Dispatchers.Main) {
showExportSuccessSnackbar(uri, null)
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
}
private var showProgress by mutableStateOf(false)
@ -1273,7 +1418,7 @@ class PreferenceActivity : AppCompatActivity() {
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) {
Text(stringResource(R.string.database), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
exportDatabase()
backupDatabaseLauncher.launch(dateStampFilename("PodciniBackup-%s.realm"))
})) {
Text(stringResource(R.string.database_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.database_export_summary), color = textColor)
@ -1285,7 +1430,6 @@ class PreferenceActivity : AppCompatActivity() {
Text(stringResource(R.string.database_import_summary), color = textColor)
}
HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp))
Text(stringResource(R.string.media_files), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp))
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
exportMediaFiles()
@ -1300,7 +1444,6 @@ class PreferenceActivity : AppCompatActivity() {
Text(stringResource(R.string.media_files_import_summary), color = textColor)
}
HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp))
Text(stringResource(R.string.preferences), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp))
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
exportPreferences()
@ -1315,7 +1458,6 @@ class PreferenceActivity : AppCompatActivity() {
Text(stringResource(R.string.preferences_import_summary), color = textColor)
}
HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp))
Text(stringResource(R.string.opml), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp))
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
openExportPathPicker(ExportTypes.OPML, chooseOpmlExportPathLauncher, OpmlWriter())
@ -1323,18 +1465,14 @@ class PreferenceActivity : AppCompatActivity() {
Text(stringResource(R.string.opml_export_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.opml_export_summary), color = textColor)
}
if (showOpmlImportSelectionDialog) OpmlImportSelectionDialog(readElements) { showOpmlImportSelectionDialog = false }
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
try {
chooseOpmlImportPathLauncher.launch("*/*")
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "No activity found. Should never happen...")
}
try { chooseOpmlImportPathLauncher.launch("*/*") } catch (e: ActivityNotFoundException) { Log.e(TAG, "No activity found. Should never happen...") }
})) {
Text(stringResource(R.string.opml_import_label), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.opml_import_summary), color = textColor)
}
HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp))
Text(stringResource(R.string.progress), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp))
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
openExportPathPicker(ExportTypes.PROGRESS, chooseProgressExportPathLauncher, EpisodesProgressWriter())
@ -1349,7 +1487,6 @@ class PreferenceActivity : AppCompatActivity() {
Text(stringResource(R.string.progress_import_summary), color = textColor)
}
HorizontalDivider(modifier = Modifier.fillMaxWidth().height(1.dp))
Text(stringResource(R.string.html), color = textColor, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp))
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = {
openExportPathPicker(ExportTypes.HTML, chooseHtmlExportPathLauncher, HtmlWriter())
@ -1392,9 +1529,7 @@ class PreferenceActivity : AppCompatActivity() {
val worker = DocumentFileExportWorker(exportWriter, context!!, uri)
try {
val output = worker.exportFile()
withContext(Dispatchers.Main) {
showExportSuccessSnackbar(output.uri, exportType.contentType)
}
withContext(Dispatchers.Main) { showExportSuccessSnackbar(output.uri, exportType.contentType) }
} catch (e: Exception) { showTransportErrorDialog(e)
} finally { showProgress = false }
}
@ -1451,10 +1586,6 @@ class PreferenceActivity : AppCompatActivity() {
builder.show()
}
private fun exportDatabase() {
backupDatabaseLauncher.launch(dateStampFilename("PodciniBackup-%s.realm"))
}
private fun importDatabase() {
// setup the alert builder
val builder = MaterialAlertDialogBuilder(requireActivity())
@ -1519,100 +1650,11 @@ class PreferenceActivity : AppCompatActivity() {
builder.show()
}
private fun chooseProgressExportPathResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(EpisodesProgressWriter(), uri, ExportTypes.PROGRESS)
}
private fun chooseOpmlExportPathResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(OpmlWriter(), uri, ExportTypes.OPML)
}
private fun chooseHtmlExportPathResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(HtmlWriter(), uri, ExportTypes.HTML)
}
private fun chooseFavoritesExportPathResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
exportWithWriter(FavoritesWriter(), uri, ExportTypes.FAVORITES)
}
private fun restoreProgressResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data?.data == null) return
val uri = result.data!!.data
uri?.let {
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isJsonFile(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri)
val reader = BufferedReader(InputStreamReader(inputStream))
EpisodeProgressReader.readDocument(reader)
reader.close()
}
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_file_type_toast) + ".json"
showTransportErrorDialog(Throwable(message))
}
}
}
private fun isJsonFile(uri: Uri): Boolean {
val fileName = uri.lastPathSegment ?: return false
return fileName.endsWith(".json", ignoreCase = true)
}
private fun restoreDatabaseResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data == null) return
val uri = result.data!!.data
uri?.let {
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isRealmFile(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
DatabaseTransporter.importBackup(uri, requireContext())
}
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_file_type_toast) + ".realm"
showTransportErrorDialog(Throwable(message))
}
}
}
private fun isRealmFile(uri: Uri): Boolean {
val fileName = uri.lastPathSegment ?: return false
return fileName.endsWith(".realm", ignoreCase = true)
@ -1628,101 +1670,6 @@ class PreferenceActivity : AppCompatActivity() {
return fileName.contains("Podcini-MediaFiles", ignoreCase = true)
}
private fun restorePreferencesResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data?.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isPrefDir(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
PreferencesTransporter.importBackup(uri, requireContext())
}
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_directory_toast) + "Podcini-Prefs"
showTransportErrorDialog(Throwable(message))
}
}
private fun restoreMediaFilesResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data?.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
if (isMediaFilesDir(uri)) {
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
MediaFilesTransporter.importBackup(uri, requireContext())
}
withContext(Dispatchers.Main) {
showImportSuccessDialog()
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
} else {
val context = requireContext()
val message = context.getString(R.string.import_directory_toast) + "Podcini-MediaFiles"
showTransportErrorDialog(Throwable(message))
}
}
private fun exportMediaFilesResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data?.data == null) return
val uri = result.data!!.data!!
// val takeFlags = result.data?.flags?.and(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) ?: 0
// requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
MediaFilesTransporter.exportToDocument(uri, requireContext())
}
withContext(Dispatchers.Main) {
showExportSuccessSnackbar(uri, null)
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
}
private fun backupDatabaseResult(uri: Uri?) {
if (uri == null) return
showProgress = true
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
DatabaseTransporter.exportToDocument(uri, requireContext())
}
withContext(Dispatchers.Main) {
showExportSuccessSnackbar(uri, "application/x-sqlite3")
showProgress = false
}
} catch (e: Throwable) { showTransportErrorDialog(e) }
}
}
private fun chooseOpmlImportPathResult(uri: Uri?) {
if (uri == null) return
Logd(TAG, "chooseOpmlImportPathResult: uri: $uri")
// OpmlTransporter.startImport(requireContext(), uri)
// showImportSuccessDialog()
val intent = Intent(context, OpmlImportActivity::class.java)
intent.setData(uri)
startActivity(intent)
}
private fun openExportPathPicker(exportType: ExportTypes, result: ActivityResultLauncher<Intent>, writer: ExportWriter) {
val title = dateStampFilename(exportType.outputNameTemplate)
@ -1745,9 +1692,7 @@ class PreferenceActivity : AppCompatActivity() {
private class BackupDatabase : CreateDocument() {
override fun createIntent(context: Context, input: String): Intent {
return super.createIntent(context, input)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/x-sqlite3")
return super.createIntent(context, input).addCategory(Intent.CATEGORY_OPENABLE).setType("application/x-sqlite3")
}
}
@ -1837,9 +1782,7 @@ class PreferenceActivity : AppCompatActivity() {
}
}
when {
// for debug version importing release version
BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug")
// for release version importing debug version
!BuildConfig.DEBUG && destName.contains(".debug") -> destName = destName.replace(".debug", "")
}
val destFile = File(sharedPreferencesDir, destName)
@ -1865,9 +1808,7 @@ class PreferenceActivity : AppCompatActivity() {
val mediaDir = context.getExternalFilesDir("media") ?: return
val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid")
val exportSubDir = chosenDir.createDirectory("Podcini-MediaFiles") ?: throw IOException("Error creating subdirectory Podcini-Prefs")
mediaDir.listFiles()?.forEach { file ->
copyRecursive(context, file, mediaDir, exportSubDir)
}
mediaDir.listFiles()?.forEach { file -> copyRecursive(context, file, mediaDir, exportSubDir) }
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
@ -1879,9 +1820,7 @@ class PreferenceActivity : AppCompatActivity() {
val dirFiles = srcFile.listFiles()
if (!dirFiles.isNullOrEmpty()) {
val destDir = destRootDir.findFile(relativePath) ?: destRootDir.createDirectory(relativePath) ?: return
dirFiles.forEach { file ->
copyRecursive(context, file, srcFile, destDir)
}
dirFiles.forEach { file -> copyRecursive(context, file, srcFile, destDir) }
}
} else {
val destFile = destRootDir.createFile("application/octet-stream", relativePath) ?: return
@ -1907,14 +1846,10 @@ class PreferenceActivity : AppCompatActivity() {
feed = nameFeedMap[relativePath] ?: return
Logd(TAG, "copyRecursive found feed: ${feed?.title}")
nameEpisodeMap.clear()
feed!!.episodes.forEach { e ->
if (!e.title.isNullOrEmpty()) nameEpisodeMap[generateFileName(e.title!!)] = e
}
feed!!.episodes.forEach { e -> if (!e.title.isNullOrEmpty()) nameEpisodeMap[generateFileName(e.title!!)] = e }
val destFile = File(destRootDir, relativePath)
if (!destFile.exists()) destFile.mkdirs()
srcFile.listFiles().forEach { file ->
copyRecursive(context, file, srcFile, destFile)
}
srcFile.listFiles().forEach { file -> copyRecursive(context, file, srcFile, destFile) }
} else {
val nameParts = relativePath.split(".")
if (nameParts.size < 3) return
@ -1950,9 +1885,7 @@ class PreferenceActivity : AppCompatActivity() {
private fun copyStream(inputStream: InputStream, outputStream: OutputStream) {
val buffer = ByteArray(1024)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
while (inputStream.read(buffer).also { bytesRead = it } != -1) outputStream.write(buffer, 0, bytesRead)
}
@Throws(IOException::class)
fun importBackup(uri: Uri, context: Context) {
@ -1963,12 +1896,8 @@ class PreferenceActivity : AppCompatActivity() {
val fileList = exportedDir.listFiles()
if (fileList.isNotEmpty()) {
val feeds = getFeedList()
feeds.forEach { f ->
if (!f.title.isNullOrEmpty()) nameFeedMap[generateFileName(f.title!!)] = f
}
fileList.forEach { file ->
copyRecursive(context, file, exportedDir, mediaDir)
}
feeds.forEach { f -> if (!f.title.isNullOrEmpty()) nameFeedMap[generateFileName(f.title!!)] = f }
fileList.forEach { file -> copyRecursive(context, file, exportedDir, mediaDir) }
}
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
@ -2094,7 +2023,7 @@ class PreferenceActivity : AppCompatActivity() {
val queuedEpisodeActions: MutableList<EpisodeAction> = mutableListOf()
val pausedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.paused.name), EpisodeSortOrder.DATE_NEW_OLD)
val readItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.played.name), EpisodeSortOrder.DATE_NEW_OLD)
val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.favorite.name), EpisodeSortOrder.DATE_NEW_OLD)
val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD)
val comItems = mutableSetOf<Episode>()
comItems.addAll(pausedItems)
comItems.addAll(readItems)
@ -2153,7 +2082,7 @@ class PreferenceActivity : AppCompatActivity() {
val favTemplate = IOUtils.toString(favTemplateStream, UTF_8)
val feedTemplateStream = context.assets.open(FEED_TEMPLATE)
val feedTemplate = IOUtils.toString(feedTemplateStream, UTF_8)
val allFavorites = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.favorite.name), EpisodeSortOrder.DATE_NEW_OLD)
val allFavorites = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.superb.name), EpisodeSortOrder.DATE_NEW_OLD)
val favoritesByFeed = buildFeedMap(allFavorites)
writer!!.append(templateParts[0])
for (feedId in favoritesByFeed.keys) {
@ -2724,7 +2653,6 @@ class PreferenceActivity : AppCompatActivity() {
}
class SynchronizationPreferencesFragment : PreferenceFragmentCompat() {
var selectedProvider by mutableStateOf(SynchronizationProviderViewData.fromIdentifier(selectedSyncProviderKey))
var loggedIn by mutableStateOf(isProviderConnected)
@ -3153,7 +3081,7 @@ class PreferenceActivity : AppCompatActivity() {
login.isEnabled = false
progressBar.visibility = View.VISIBLE
txtvError.visibility = View.GONE
val inputManager = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val inputManager = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
inputManager.hideSoftInputFromWindow(login.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
lifecycleScope.launch {
@ -3442,7 +3370,7 @@ class PreferenceActivity : AppCompatActivity() {
appPrefs.edit().putBoolean(UserPreferences.Prefs.prefShowDownloadReport.name, it).apply()
})
}
if (SynchronizationSettings.isProviderConnected) {
if (isProviderConnected) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.notification_channel_sync_error), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)

View File

@ -4,7 +4,7 @@ import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.ShareLog
import ac.mdiq.podcini.ui.compose.ConfirmAddYoutubeEpisode
import ac.mdiq.podcini.ui.compose.ConfirmAddYoutubeEpisode1
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.util.Logd
import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeServiceURL
@ -52,7 +52,7 @@ class ShareReceiverActivity : AppCompatActivity() {
setContent {
val showDialog = remember { mutableStateOf(true) }
CustomTheme(this) {
ConfirmAddYoutubeEpisode(listOf(sharedUrl!!), showDialog.value, onDismissRequest = {
ConfirmAddYoutubeEpisode1(listOf(sharedUrl!!), showDialog.value, onDismissRequest = {
showDialog.value = false
finish()
})

View File

@ -1,16 +1,22 @@
package ac.mdiq.podcini.ui.compose
import ac.mdiq.podcini.R
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
@ -165,3 +171,49 @@ fun NonlazyGrid(columns: Int, itemCount: Int, modifier: Modifier = Modifier, con
}
}
}
@Composable
fun SimpleSwitchDialog(title: String, text: String, onDismissRequest: ()->Unit, callback: (Boolean)-> Unit) {
val textColor = MaterialTheme.colorScheme.onSurface
var isChecked by remember { mutableStateOf(false) }
AlertDialog(onDismissRequest = { onDismissRequest() },
title = { Text(title, style = MaterialTheme.typography.titleLarge) },
text = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) {
Text(text, color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Switch(checked = isChecked, onCheckedChange = { isChecked = it })
}
},
confirmButton = {
TextButton(onClick = {
callback(isChecked)
onDismissRequest()
}) { Text(text = "OK") }
},
dismissButton = { TextButton(onClick = { onDismissRequest() }) { Text(text = "Cancel") } }
)
}
@Composable
fun TitleSummaryColumn(titleRes: Int, summaryRes: Int, callback: ()-> Unit) {
val textColor = MaterialTheme.colorScheme.onSurface
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp).clickable(onClick = { callback() })) {
Text(stringResource(titleRes), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(summaryRes), color = textColor)
}
}
@Composable
fun TitleSummarySwitchRow(titleRes: Int, summaryRes: Int, prefName: String) {
val textColor = MaterialTheme.colorScheme.onSurface
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 10.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(titleRes), color = textColor, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(summaryRes), color = textColor)
}
var isChecked by remember { mutableStateOf(appPrefs.getBoolean(prefName, false)) }
Switch(checked = isChecked, onCheckedChange = {
isChecked = it
appPrefs.edit().putBoolean(prefName, it).apply() })
}
}

View File

@ -35,6 +35,8 @@ import ac.mdiq.podcini.storage.model.Feed.Companion.MAX_SYNTHETIC_ID
import ac.mdiq.podcini.storage.model.Feed.Companion.newId
import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong
import ac.mdiq.podcini.storage.database.Episodes.hasAlmostEnded
import ac.mdiq.podcini.storage.database.Feeds.addToSyndicate
import ac.mdiq.podcini.storage.database.Feeds.createYTSyndicates
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.EpisodeActionButton
import ac.mdiq.podcini.ui.actions.NullActionButton
@ -71,6 +73,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -98,6 +101,7 @@ import androidx.documentfile.provider.DocumentFile
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.skydoves.balloon.textForm
import io.realm.kotlin.notifications.SingleQueryChange
import io.realm.kotlin.notifications.UpdatedObject
import kotlinx.coroutines.*
@ -341,9 +345,7 @@ fun ShelveDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) {
val scrollState = rememberScrollState()
Column(modifier = Modifier
.verticalScroll(scrollState)
.padding(16.dp), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Column(modifier = Modifier.verticalScroll(scrollState).padding(16.dp), verticalArrangement = Arrangement.spacedBy(1.dp)) {
var removeChecked by remember { mutableStateOf(false) }
var toFeed by remember { mutableStateOf<Feed?>(null) }
if (synthetics.isNotEmpty()) {
@ -455,7 +457,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList<EpisodeVM>, feed:
val showConfirmYoutubeDialog = remember { mutableStateOf(false) }
val youtubeUrls = remember { mutableListOf<String>() }
ConfirmAddYoutubeEpisode(youtubeUrls, showConfirmYoutubeDialog.value, onDismissRequest = { showConfirmYoutubeDialog.value = false })
ConfirmAddYoutubeEpisode1(youtubeUrls, showConfirmYoutubeDialog.value, onDismissRequest = { showConfirmYoutubeDialog.value = false })
var showChooseRatingDialog by remember { mutableStateOf(false) }
if (showChooseRatingDialog) ChooseRatingDialog(selected) { showChooseRatingDialog = false }
@ -581,14 +583,12 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList<EpisodeVM>, feed:
for (e in selected) {
Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}")
val url = URL(e.media?.downloadUrl ?: "")
if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) {
if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url))
youtubeUrls.add(e.media!!.downloadUrl!!)
} else addToMiscSyndicate(e)
else addToMiscSyndicate(e)
}
Logd(TAG, "youtubeUrls: ${youtubeUrls.size}")
withContext(Dispatchers.Main) {
showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty()
}
withContext(Dispatchers.Main) { showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty() }
}
}, verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AddCircle, "Reserve episodes")
@ -899,32 +899,56 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList<EpisodeVM>, feed:
}
@Composable
fun ConfirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDismissRequest: () -> Unit) {
fun ConfirmAddYoutubeEpisode1(sharedUrls: List<String>, showDialog: Boolean, onDismissRequest: () -> Unit) {
val TAG = "confirmAddEpisode"
var showToast by remember { mutableStateOf(false) }
var toastMassege by remember { mutableStateOf("")}
if (showToast) CustomToast(message = toastMassege, onDismiss = { showToast = false })
if (showDialog) {
val YTSyndMap = remember { mutableStateMapOf<Int, Boolean>() }
val synthetics = remember { realm.query(Feed::class).query("id >= 1 && id <= 1000").find().toMutableStateList() }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
var audioOnly by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) {
Checkbox(checked = audioOnly, onCheckedChange = { audioOnly = it })
Text(text = stringResource(R.string.pref_video_mode_audio_only), style = MaterialTheme.typography.bodyLarge.merge())
val textColor = MaterialTheme.colorScheme.onSurface
Card(modifier = Modifier.height(350.dp).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) {
var toFeed by remember { mutableStateOf<Feed?>(null) }
var showComfirmButton by remember { mutableStateOf(toFeed != null) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_to_feed), color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
if (YTSyndMap.size < 4) {
Button(onClick = {
createYTSyndicates()
synthetics.clear()
synthetics.addAll(realm.query(Feed::class).query("id >= 1 && id <= 1000").find())
}) { Text(stringResource(R.string.create_YT_syndicates)) }
}
var showComfirmButton by remember { mutableStateOf(true) }
if (synthetics.isNotEmpty()) {
LazyColumn(modifier = Modifier.weight(1f).padding(start = 10.dp, end = 10.dp), verticalArrangement = Arrangement.Center) {
items(synthetics.size) { index ->
val f = synthetics[index]
if (f.id <= 4) YTSyndMap[f.id.toInt()] = true
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = toFeed == f, onClick = {
toFeed = f
showComfirmButton = true
})
Text(f.title ?: "No title", color = textColor)
}
}
}
} else Text(text = stringResource(R.string.create_synthetic_first_note), color = textColor)
var showProgress by remember { mutableStateOf(false) }
if (showComfirmButton) {
Button(onClick = {
showComfirmButton = false
showProgress = true
CoroutineScope(Dispatchers.IO).launch {
for (url in sharedUrls) {
val log = realm.query(ShareLog::class).query("url == $0", url).first().find()
try {
val info = StreamInfo.getInfo(Vista.getService(0), url)
val episode = episodeFromStreamInfo(info)
val status = addToYoutubeSyndicate(episode, !audioOnly)
val status = addToSyndicate(episode, toFeed!!)
if (log != null) upsert(log) {
it.title = episode.title
it.status = status
@ -932,14 +956,15 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDi
} catch (e: Throwable) {
toastMassege = "Receive share error: ${e.message}"
Log.e(TAG, toastMassege)
if (log != null) upsert(log) { it.details = e.message?: "error" }
if (log != null) upsert(log) { it.details = e.message ?: "error" }
withContext(Dispatchers.Main) { showToast = true }
}
}
withContext(Dispatchers.Main) { onDismissRequest() }
}
}) { Text("Confirm") }
} else CircularProgressIndicator(progress = { 0.6f }, strokeWidth = 4.dp, modifier = Modifier.padding(start = 20.dp, end = 20.dp).width(30.dp).height(30.dp))
}
if (showProgress) CircularProgressIndicator(progress = { 0.6f }, strokeWidth = 4.dp, modifier = Modifier.padding(start = 40.dp, end = 40.dp).width(30.dp).height(30.dp))
}
}
}

View File

@ -2,6 +2,7 @@ package ac.mdiq.podcini.ui.compose
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.feed.FeedBuilder
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.net.feed.searcher.PodcastSearchResult
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
@ -10,19 +11,19 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.prefPlaybackSpeed
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService
import ac.mdiq.podcini.preferences.OpmlTransporter
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.storage.database.Feeds.buildTags
import ac.mdiq.podcini.storage.database.Feeds.createSynthetic
import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.Feed.Companion.MAX_SYNTHETIC_ID
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
import ac.mdiq.podcini.ui.fragment.OnlineFeedFragment
@ -33,8 +34,11 @@ import ac.mdiq.podcini.util.MiscFormatter
import ac.mdiq.podcini.util.MiscFormatter.localDateTimeString
import android.util.Log
import android.view.Gravity
import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@ -44,6 +48,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
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
@ -69,6 +74,7 @@ import coil.request.ImageRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import java.text.DecimalFormat
@ -589,3 +595,58 @@ fun PlaybackSpeedFullDialog(settingCode: BooleanArray, indexDefault: Int, maxSpe
}
}
}
@Composable
fun OpmlImportSelectionDialog(readElements: SnapshotStateList<OpmlTransporter.OpmlElement>, onDismissRequest: () -> Unit) {
val context = LocalContext.current
val selectedItems = remember { mutableStateMapOf<Int, Boolean>() }
AlertDialog(onDismissRequest = { onDismissRequest() },
title = { Text("Import OPML file") },
text = {
var isSelectAllChecked by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = "Select/Deselect All", modifier = Modifier.weight(1f))
Checkbox(checked = isSelectAllChecked, onCheckedChange = { isChecked ->
isSelectAllChecked = isChecked
readElements.forEachIndexed { index, _ -> selectedItems.put(index, isChecked) }
})
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(readElements) { index, item ->
Row(modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = item.text?:"", modifier = Modifier.weight(1f))
Checkbox(checked = selectedItems[index]?: false, onCheckedChange = { checked -> selectedItems.put(index, checked) })
}
}
}
}
},
confirmButton = {
Button(onClick = {
Logd("OpmlImportSelectionDialog", "checked: $selectedItems")
CoroutineScope(Dispatchers.IO).launch {
try {
withContext(Dispatchers.IO) {
if (readElements.isNotEmpty()) {
for (i in selectedItems.keys) {
if (selectedItems[i] != true) continue
val element = readElements[i]
val feed = Feed(element.xmlUrl, null, if (element.text != null) element.text else "Unknown podcast")
feed.episodes.clear()
updateFeed(context, feed, false)
}
runOnce(context)
}
}
} catch (e: Throwable) {
e.printStackTrace()
Toast.makeText(context, (e.message ?: "Import error"), Toast.LENGTH_LONG).show()
}
}
onDismissRequest()
}) { Text("Confirm") }
},
dismissButton = { Button(onClick = { onDismissRequest() }) { Text("Dismiss") } }
)
}

View File

@ -1,112 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
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.Episodes.getEpisodesCount
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.util.Logd
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import org.apache.commons.lang3.StringUtils
class AllEpisodesFragment : BaseEpisodesFragment() {
private var allEpisodes: List<Episode> = listOf()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label)
sortOrder = allEpisodesSortOrder ?: EpisodeSortOrder.DATE_NEW_OLD
updateToolbar()
// txtvInformation.setOnClickListener {
// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null)
// }
return root
}
override fun onDestroyView() {
allEpisodes = listOf()
super.onDestroyView()
}
private var loadItemsRunning = false
override fun loadData(): List<Episode> {
val filter = getFilter()
if (!loadItemsRunning) {
loadItemsRunning = true
allEpisodes = getEpisodes(0, Int.MAX_VALUE, filter, allEpisodesSortOrder, false)
Logd(TAG, "loadData ${allEpisodes.size}")
loadItemsRunning = false
}
if (allEpisodes.isEmpty()) return listOf()
// allEpisodes = allEpisodes.filter { filter.matchesForQueues(it) }
return allEpisodes
}
override fun loadTotalItemCount(): Int {
return getEpisodesCount(getFilter())
}
override fun getFilter(): EpisodeFilter {
return EpisodeFilter(prefFilterAllEpisodes)
}
override fun getPrefName(): String {
return PREF_NAME
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
when (item.itemId) {
R.id.filter_items -> showFilterDialog = true
R.id.episodes_sort -> showSortDialog = true
else -> return false
}
return true
}
override fun updateToolbar() {
swipeActions.setFilter(getFilter())
var info = "${episodes.size} episodes"
if (getFilter().properties.isNotEmpty()) info += " - ${getString(R.string.filtered_label)}"
infoBarText.value = info
}
override fun onFilterChanged(filterValues: Set<String>) {
prefFilterAllEpisodes = StringUtils.join(filterValues, ",")
page = 1
loadItems()
}
override fun onSort(order: EpisodeSortOrder) {
allEpisodesSortOrder = order
page = 1
loadItems()
}
companion object {
val TAG = AllEpisodesFragment::class.simpleName ?: "Anonymous"
const val PREF_NAME: String = "PrefAllEpisodesFragment"
var allEpisodesSortOrder: EpisodeSortOrder?
get() = EpisodeSortOrder.fromCodeString(appPrefs.getString(UserPreferences.Prefs.prefEpisodesSort.name, "" + EpisodeSortOrder.DATE_NEW_OLD.code))
set(s) {
appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodesSort.name, "" + s!!.code).apply()
}
var prefFilterAllEpisodes: String
get() = appPrefs.getString(UserPreferences.Prefs.prefEpisodesFilter.name, "")?:""
set(filter) {
appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodesFilter.name, filter).apply()
}
}
}

View File

@ -7,6 +7,7 @@ import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.ui.actions.EpisodeActionButton
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction
@ -24,7 +25,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.util.Pair
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
@ -37,13 +37,11 @@ import kotlinx.coroutines.withContext
abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val TAG = this::class.simpleName ?: "Anonymous"
@JvmField
protected var page: Int = 1
private var displayUpArrow = false
var _binding: ComposeFragmentBinding? = null
protected val binding get() = _binding!!
private var displayUpArrow = false
protected var infoBarText = mutableStateOf("")
private var leftActionState = mutableStateOf<SwipeAction>(NoActionSwipeAction())
private var rightActionState = mutableStateOf<SwipeAction>(NoActionSwipeAction())
@ -52,11 +50,13 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
lateinit var swipeActions: SwipeActions
val episodes = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
protected val vms = mutableStateListOf<EpisodeVM>()
var showFilterDialog by mutableStateOf(false)
var showSortDialog by mutableStateOf(false)
var sortOrder by mutableStateOf(EpisodeSortOrder.DATE_NEW_OLD)
var actionButtonToPass by mutableStateOf<((Episode) -> EpisodeActionButton)?>(null)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
@ -65,24 +65,17 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
toolbar = binding.toolbar
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)
// recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool)
// recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar))
swipeActions = SwipeActions(this, TAG)
lifecycle.addObserver(swipeActions)
binding.mainView.setContent {
CustomTheme(requireContext()) {
if (showFilterDialog) EpisodesFilterDialog(filter = getFilter(), onDismissRequest = { showFilterDialog = false } ) { onFilterChanged(it) }
if (showFilterDialog) EpisodesFilterDialog(filter = getFilter(), filtersDisabled = filtersDisabled(),
onDismissRequest = { showFilterDialog = false } ) { onFilterChanged(it) }
if (showSortDialog) EpisodeSortDialog(initOrder = sortOrder, onDismissRequest = {showSortDialog = false}) { order, _ -> onSort(order) }
Column {
@ -91,24 +84,29 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
activity as MainActivity, vms = vms,
leftSwipeCB = {
if (leftActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else leftActionState.value.performAction(it, this@BaseEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
else leftActionState.value.performAction(it, this@BaseEpisodesFragment)
},
rightSwipeCB = {
if (rightActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else rightActionState.value.performAction(it, this@BaseEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
else rightActionState.value.performAction(it, this@BaseEpisodesFragment)
},
actionButton_ = actionButtonToPass
)
}
}
}
swipeActions.setFilter(getFilter())
// swipeActions.setFilter(getFilter())
refreshSwipeTelltale()
return binding.root
}
open fun onFilterChanged(filterValues: Set<String>) {}
open fun filtersDisabled(): MutableSet<EpisodeFilter.EpisodesFilterGroup> {
return mutableSetOf()
}
open fun onSort(order: EpisodeSortOrder) {}
override fun onStart() {
@ -122,15 +120,8 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
cancelFlowEvents()
}
// override fun onPause() {
// super.onPause()
//// recyclerView.saveScrollPosition(getPrefName())
//// unregisterForContextMenu(recyclerView)
// }
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
override fun onMenuItemClick(item: MenuItem): Boolean {
// if (super.onMenuItemClick(item)) return true
val itemId = item.itemId
when (itemId) {
R.id.action_search -> {
@ -164,7 +155,6 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
// if (!event.isCompleted(url)) continue
val pos: Int = Episodes.indexOfItemWithDownloadUrl(episodes, url)
if (pos >= 0) {
// episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
}
}
@ -187,6 +177,9 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale()
is FlowEvent.EpisodeEvent -> onEpisodeEvent(event)
is FlowEvent.EpisodeMediaEvent -> onEpisodeMediaEvent(event)
is FlowEvent.HistoryEvent -> onHistoryEvent(event)
is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent, is FlowEvent.RatingEvent -> loadItems()
else -> {}
}
@ -210,6 +203,12 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
}
}
protected open fun onHistoryEvent(event: FlowEvent.HistoryEvent) {}
protected open fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { }
protected open fun onEpisodeMediaEvent(event: FlowEvent.EpisodeMediaEvent) {}
private fun refreshSwipeTelltale() {
leftActionState.value = swipeActions.actions.left[0]
rightActionState.value = swipeActions.actions.right[0]
@ -222,14 +221,13 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
Logd(TAG, "loadItems() called")
lifecycleScope.launch {
try {
val data = withContext(Dispatchers.IO) { Pair(loadData().toMutableList(), loadTotalItemCount()) }
val restoreScrollPosition = episodes.isEmpty()
episodes.clear()
episodes.addAll(data.first)
withContext(Dispatchers.IO) {
episodes.clear()
episodes.addAll(loadData())
}
withContext(Dispatchers.Main) {
vms.clear()
for (e in data.first) { vms.add(EpisodeVM(e)) }
// if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName())
for (e in episodes) { vms.add(EpisodeVM(e)) }
updateToolbar()
}
} catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
@ -240,8 +238,6 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
protected abstract fun loadData(): List<Episode>
protected abstract fun loadTotalItemCount(): Int
open fun getFilter(): EpisodeFilter {
return EpisodeFilter.unfiltered()
}
@ -257,6 +253,5 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
companion object {
private const val KEY_UP_ARROW = "up_arrow"
const val EPISODES_PER_PAGE: Int = 50
}
}

View File

@ -1,426 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.ComposeFragmentBinding
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
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.ui.actions.DeleteActionButton
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.actions.SwipeActions.NoActionSwipeAction
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.*
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.foundation.layout.Column
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
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.
*/
class DownloadsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var _binding: ComposeFragmentBinding? = null
private val binding get() = _binding!!
private var runningDownloads: Set<String> = HashSet()
private val episodes = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
private var infoBarText = mutableStateOf("")
private var leftActionState = mutableStateOf<SwipeAction>(NoActionSwipeAction())
private var rightActionState = mutableStateOf<SwipeAction>(NoActionSwipeAction())
var showFilterDialog by mutableStateOf(false)
var showSortDialog by mutableStateOf(false)
var sortOrder by mutableStateOf(EpisodeSortOrder.DATE_NEW_OLD)
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeActions: SwipeActions
private var displayUpArrow = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = ComposeFragmentBinding.inflate(inflater)
sortOrder = downloadsSortedOrder ?: EpisodeSortOrder.DATE_NEW_OLD
Logd(TAG, "fragment onCreateView")
toolbar = binding.toolbar
toolbar.setTitle(R.string.downloads_label)
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)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.downloaded.name))
binding.mainView.setContent {
CustomTheme(requireContext()) {
if (showFilterDialog) EpisodesFilterDialog(filter = getFilter(),
filtersDisabled = mutableSetOf(EpisodeFilter.EpisodesFilterGroup.DOWNLOADED, EpisodeFilter.EpisodesFilterGroup.MEDIA),
onDismissRequest = { showFilterDialog = false } ) {
// EventFlow.postEvent(FlowEvent.DownloadsFilterEvent(it))
val fSet = it.toMutableSet()
fSet.add(EpisodeFilter.States.downloaded.name)
prefFilterDownloads = StringUtils.join(fSet, ",")
Logd(TAG, "onFilterChanged: $prefFilterDownloads")
loadItems()
}
if (showSortDialog) EpisodeSortDialog(initOrder = sortOrder, onDismissRequest = {showSortDialog = false}) { order, _ ->
downloadsSortedOrder = order
loadItems()
}
Column {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
EpisodeLazyColumn(activity as MainActivity, vms = vms,
leftSwipeCB = {
if (leftActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else leftActionState.value.performAction(it, this@DownloadsFragment, swipeActions.filter ?: EpisodeFilter())
},
rightSwipeCB = {
if (rightActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else rightActionState.value.performAction(it, this@DownloadsFragment, swipeActions.filter ?: EpisodeFilter())
},
actionButton_ = { DeleteActionButton(it) })
}
}
}
// recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool)
// recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar))
lifecycle.addObserver(swipeActions)
refreshSwipeTelltale()
// if (arguments != null && requireArguments().getBoolean(ARG_SHOW_LOGS, false)) DownloadLogFragment().show(childFragmentManager, null)
// addEmptyView()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
loadItems()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_UP_ARROW, displayUpArrow)
super.onSaveInstanceState(outState)
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
toolbar.setOnMenuItemClickListener(null)
toolbar.setOnLongClickListener(null)
episodes.clear()
vms.clear()
super.onDestroyView()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.filter_items -> showFilterDialog = true
R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance())
R.id.downloads_sort -> showSortDialog = true
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) {
if (item.media != null ) 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 = Episodes.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 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 = Episodes.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
vms.removeAt(pos)
val media = item.media
if (media != null && media.downloaded) {
episodes.add(pos, item)
vms.add(pos, EpisodeVM(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 = Episodes.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
vms.removeAt(pos)
val media = item.media
if (media != null && media.downloaded) {
episodes.add(pos, item)
vms.add(pos, EpisodeVM(item))
}
}
}
// have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash
// if (size > 0) adapter.updateItems(episodes)
refreshInfoBar()
}
private fun refreshSwipeTelltale() {
leftActionState.value = swipeActions.actions.left[0]
rightActionState.value = swipeActions.actions.right[0]
}
private var loadItemsRunning = false
private fun loadItems() {
// emptyView.hide()
Logd(TAG, "loadItems() called")
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, false)
if (runningDownloads.isEmpty()) {
episodes.clear()
episodes.addAll(downloadedItems)
} else {
val mediaUrls: MutableList<String> = ArrayList()
for (url in runningDownloads) {
if (Episodes.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) {
vms.clear()
for (e in episodes) vms.add(EpisodeVM(e))
}
}
withContext(Dispatchers.Main) { refreshInfoBar() }
} catch (e: Throwable) { 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, "refreshInfoBar filter value: ${getFilter().properties.size} ${getFilter().properties.joinToString()}")
if (getFilter().properties.size > 1) info += " - ${getString(R.string.filtered_label)}"
infoBarText.value = info
}
companion object {
val TAG = DownloadsFragment::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

@ -0,0 +1,354 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.storage.database.Episodes
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.model.EpisodeSortOrder.Companion.getPermutor
import ac.mdiq.podcini.ui.actions.DeleteActionButton
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.EpisodeVM
import ac.mdiq.podcini.ui.compose.SpinnerExternalSet
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.DatesFilterDialog
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import java.io.File
import java.util.*
import kotlin.math.min
class EpisodesFragment : BaseEpisodesFragment() {
val prefs: SharedPreferences by lazy { requireContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) }
private val spinnerTexts = QuickAccess.entries.map { it.name }
private var curIndex by mutableIntStateOf(0)
private lateinit var spinnerView: ComposeView
private var startDate : Long = 0L
private var endDate : Long = Date().time
private var episodesSortOrder: EpisodeSortOrder
get() = EpisodeSortOrder.fromCodeString(appPrefs.getString(UserPreferences.Prefs.prefEpisodesSort.name, "" + EpisodeSortOrder.DATE_NEW_OLD.code))
set(s) {
appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodesSort.name, "" + s.code).apply()
}
private var prefFilterEpisodes: String
get() = appPrefs.getString(UserPreferences.Prefs.prefEpisodesFilter.name, "")?:""
set(filter) {
appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodesFilter.name, filter).apply()
}
private 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()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
curIndex = prefs.getInt("curIndex", 0)
spinnerView = ComposeView(requireContext()).apply {
setContent {
CustomTheme(requireContext()) {
SpinnerExternalSet(items = spinnerTexts, selectedIndex = curIndex) { index: Int ->
Logd(QueuesFragment.Companion.TAG, "Item selected: $index")
curIndex = index
prefs.edit().putInt("curIndex", index).apply()
actionButtonToPass = if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name) {it -> DeleteActionButton(it) } else null
loadItems()
}
}
}
}
toolbar.addView(spinnerView)
toolbar.inflateMenu(R.menu.episodes)
sortOrder = episodesSortOrder
updateToolbar()
return root
}
/**
* Loads the playback history from the database. A FeedItem is in the playback history if playback of the correpsonding episode
* has been played ot completed at least once.
* @param limit The maximum number of episodes to return.
* @return The playback history. The FeedItems are sorted by their media's playbackCompletionDate in descending order.
*/
fun getHistory(offset: Int, limit: Int, start: Long = 0L, end: Long = Date().time,
sortOrder: EpisodeSortOrder = EpisodeSortOrder.PLAYED_DATE_NEW_OLD): List<Episode> {
Logd(TAG, "getHistory() called")
val medias = realm.query(EpisodeMedia::class).query("(playbackCompletionTime > 0) OR (lastPlayedTime > \$0 AND lastPlayedTime <= \$1)", start, end).find()
var episodes: MutableList<Episode> = mutableListOf()
for (m in medias) {
val item_ = m.episodeOrFetch()
if (item_ != null) episodes.add(item_)
else Logd(TAG, "getHistory: media has null episode: ${m.id}")
}
getPermutor(sortOrder).reorder(episodes)
if (offset > 0 && episodes.size > offset) episodes = episodes.subList(offset, min(episodes.size, offset+limit))
return episodes
}
override fun loadData(): List<Episode> {
return when (spinnerTexts[curIndex]) {
QuickAccess.New.name -> getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.new.name), episodesSortOrder, false)
QuickAccess.Planned.name -> getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.soon.name, EpisodeFilter.States.later.name), episodesSortOrder, false)
QuickAccess.Repeats.name -> getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.again.name, EpisodeFilter.States.forever.name), episodesSortOrder, false)
QuickAccess.Liked.name -> getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.good.name, EpisodeFilter.States.superb.name), episodesSortOrder, false)
QuickAccess.Commented.name -> getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.States.has_comments.name), episodesSortOrder, false)
QuickAccess.History.name -> getHistory(0, Int.MAX_VALUE, sortOrder = episodesSortOrder).toMutableList()
QuickAccess.Downloaded.name -> getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(prefFilterDownloads), episodesSortOrder, false)
QuickAccess.All.name -> getEpisodes(0, Int.MAX_VALUE, getFilter(), episodesSortOrder, false)
else -> getEpisodes(0, Int.MAX_VALUE, getFilter(), episodesSortOrder, false)
}
}
override fun getFilter(): EpisodeFilter {
return EpisodeFilter(prefFilterEpisodes)
}
override fun getPrefName(): String {
return PREF_NAME
}
var progressing by mutableStateOf(false)
override fun updateToolbar() {
toolbar.menu.findItem(R.id.clear_new).isVisible = episodes.isNotEmpty() && spinnerTexts[curIndex] == QuickAccess.New.name
toolbar.menu.findItem(R.id.filter_items).isVisible = episodes.isNotEmpty() && spinnerTexts[curIndex] == QuickAccess.All.name
toolbar.menu.findItem(R.id.clear_history_item).isVisible = episodes.isNotEmpty() && spinnerTexts[curIndex] == QuickAccess.History.name
toolbar.menu.findItem(R.id.reconcile).isVisible = episodes.isNotEmpty() && spinnerTexts[curIndex] == QuickAccess.Downloaded.name
var info = "${episodes.size} episodes"
if (spinnerTexts[curIndex] == QuickAccess.All.name && getFilter().properties.isNotEmpty()) info += " - ${getString(R.string.filtered_label)}"
else if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name && episodes.isNotEmpty()) {
var sizeMB: Long = 0
for (item in episodes) sizeMB += item.media?.size ?: 0
info += "" + (sizeMB / 1000000) + " MB"
}
if (progressing) info += " - ${getString(R.string.progressing_label)}"
infoBarText.value = info
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) return true
when (item.itemId) {
R.id.filter_items -> {
if (spinnerTexts[curIndex] == QuickAccess.History.name) {
val dialog = object: DatesFilterDialog(requireContext(), 0L) {
override fun initParams() {
val calendar = Calendar.getInstance()
calendar.add(Calendar.YEAR, -1) // subtract 1 year
timeFilterFrom = calendar.timeInMillis
showMarkPlayed = false
}
override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) {
EventFlow.postEvent(FlowEvent.HistoryEvent(sortOrder, timeFilterFrom, timeFilterTo))
}
}
dialog.show()
} else showFilterDialog = true
}
R.id.episodes_sort -> showSortDialog = true
R.id.clear_history_item -> {
val conDialog: ConfirmationDialog = object : ConfirmationDialog(requireContext(), R.string.clear_history_label, R.string.clear_playback_history_msg) {
override fun onConfirmButtonPressed(dialog: DialogInterface) {
dialog.dismiss()
clearHistory()
}
}
conDialog.createNewDialog().show()
}
R.id.reconcile -> reconcile()
R.id.clear_new -> clearNew()
else -> return false
}
return true
}
private fun clearNew() {
runOnIOScope {
progressing = true
for (e in episodes) if (e.isNew) upsert(e) { it.setPlayed(false) }
withContext(Dispatchers.Main) {
progressing = false
Toast.makeText(requireContext(), "History cleared", Toast.LENGTH_LONG).show()
}
loadItems()
}
}
private val nameEpisodeMap: MutableMap<String, Episode> = mutableMapOf()
private val filesRemoved: MutableList<String> = mutableListOf()
private fun reconcile() {
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}")
}
}
runOnIOScope {
progressing = true
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) if (item.media != null) 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) {
progressing = false
Toast.makeText(requireContext(), "Episodes reconsiled: ${nameEpisodeMap.size}\nFiles removed: ${filesRemoved.size}", Toast.LENGTH_LONG).show()
}
}
}
fun clearHistory() : Job {
Logd(TAG, "clearHistory called")
return runOnIOScope {
progressing = true
val episodes = realm.query(Episode::class).query("media.playbackCompletionTime > 0 || media.lastPlayedTime > 0").find()
for (e in episodes) {
upsert(e) {
it.media?.playbackCompletionDate = null
it.media?.lastPlayedTime = 0
}
}
withContext(Dispatchers.Main) {
progressing = false
Toast.makeText(requireContext(), "History cleared", Toast.LENGTH_LONG).show()
}
EventFlow.postEvent(FlowEvent.HistoryEvent())
}
}
override fun onFilterChanged(filterValues: Set<String>) {
if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name || spinnerTexts[curIndex] == QuickAccess.All.name) {
val fSet = filterValues.toMutableSet()
if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name) fSet.add(EpisodeFilter.States.downloaded.name)
prefFilterEpisodes = StringUtils.join(fSet, ",")
loadItems()
}
}
override fun onSort(order: EpisodeSortOrder) {
episodesSortOrder = order
loadItems()
}
override fun filtersDisabled(): MutableSet<EpisodeFilter.EpisodesFilterGroup> {
return if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name) mutableSetOf(EpisodeFilter.EpisodesFilterGroup.DOWNLOADED, EpisodeFilter.EpisodesFilterGroup.MEDIA)
else mutableSetOf()
}
override fun onHistoryEvent(event: FlowEvent.HistoryEvent) {
if (spinnerTexts[curIndex] == QuickAccess.History.name) {
sortOrder = event.sortOrder
if (event.startDate > 0) startDate = event.startDate
endDate = event.endDate
loadItems()
updateToolbar()
}
}
override fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) {
if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name) {
var i = 0
val size: Int = event.episodes.size
while (i < size) {
val item: Episode = event.episodes[i++]
val pos = Episodes.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
vms.removeAt(pos)
val media = item.media
if (media != null && media.downloaded) {
episodes.add(pos, item)
vms.add(pos, EpisodeVM(item))
}
}
}
updateToolbar()
}
}
override fun onEpisodeMediaEvent(event: FlowEvent.EpisodeMediaEvent) {
if (spinnerTexts[curIndex] == QuickAccess.Downloaded.name) {
var i = 0
val size: Int = event.episodes.size
while (i < size) {
val item: Episode = event.episodes[i++]
val pos = Episodes.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
vms.removeAt(pos)
val media = item.media
if (media != null && media.downloaded) {
episodes.add(pos, item)
vms.add(pos, EpisodeVM(item))
}
}
}
updateToolbar()
}
}
enum class QuickAccess {
New, Planned, Repeats, Liked, Commented, Downloaded, History, All
}
companion object {
val TAG = EpisodesFragment::class.simpleName ?: "Anonymous"
const val PREF_NAME: String = "PrefEpisodesFragment"
}
}

View File

@ -204,12 +204,12 @@ class FeedEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
refreshCB = { FeedUpdateManager.runOnceOrAsk(requireContext(), feed) },
leftSwipeCB = {
if (leftActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else leftActionState.value.performAction(it, this@FeedEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
else leftActionState.value.performAction(it, this@FeedEpisodesFragment)
},
rightSwipeCB = {
Logd(TAG, "rightActionState: ${rightActionState.value.getId()}")
if (rightActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else rightActionState.value.performAction(it, this@FeedEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
else rightActionState.value.performAction(it, this@FeedEpisodesFragment)
},
)
}
@ -635,7 +635,7 @@ class FeedEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
withContext(Dispatchers.Main) {
Logd(TAG, "loadItems subscribe called ${feed?.title}")
rating = feed?.rating ?: Rating.UNRATED.code
swipeActions.setFilter(feed?.episodeFilter)
// swipeActions.setFilter(feed?.episodeFilter)
refreshHeaderView()
// if (feed != null) {
// adapter.updateItems(episodes, feed)

View File

@ -1,199 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
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.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.getPermutor
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.DatesFilterDialog
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.*
import kotlin.math.min
class HistoryFragment : BaseEpisodesFragment() {
// private var sortOrder : EpisodeSortOrder = EpisodeSortOrder.PLAYED_DATE_NEW_OLD
private var startDate : Long = 0L
private var endDate : Long = Date().time
private var allHistory: List<Episode> = listOf()
override fun getPrefName(): String {
return TAG
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
sortOrder = EpisodeSortOrder.PLAYED_DATE_NEW_OLD
toolbar.inflateMenu(R.menu.playback_history)
toolbar.setTitle(R.string.playback_history_label)
updateToolbar()
return root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
allHistory = listOf()
super.onDestroyView()
}
override fun onSort(order: EpisodeSortOrder) {
// EventFlow.postEvent(FlowEvent.HistoryEvent(sortOrder))
sortOrder = order
loadItems()
updateToolbar()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
when (item.itemId) {
R.id.episodes_sort -> showSortDialog = true
R.id.filter_items -> {
val dialog = object: DatesFilterDialog(requireContext(), 0L) {
override fun initParams() {
val calendar = Calendar.getInstance()
calendar.add(Calendar.YEAR, -1) // subtract 1 year
timeFilterFrom = calendar.timeInMillis
showMarkPlayed = false
}
override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) {
EventFlow.postEvent(FlowEvent.HistoryEvent(sortOrder, timeFilterFrom, timeFilterTo))
}
}
dialog.show()
}
R.id.clear_history_item -> {
val conDialog: ConfirmationDialog = object : ConfirmationDialog(requireContext(), R.string.clear_history_label, R.string.clear_playback_history_msg) {
override fun onConfirmButtonPressed(dialog: DialogInterface) {
dialog.dismiss()
clearHistory()
}
}
conDialog.createNewDialog().show()
}
else -> return false
}
return true
}
override fun updateToolbar() {
// Not calling super, as we do not have a refresh button that could be updated
toolbar.menu.findItem(R.id.episodes_sort).isVisible = episodes.isNotEmpty()
toolbar.menu.findItem(R.id.filter_items).isVisible = episodes.isNotEmpty()
toolbar.menu.findItem(R.id.clear_history_item).isVisible = episodes.isNotEmpty()
swipeActions.setFilter(getFilter())
var info = "${episodes.size} episodes"
if (getFilter().properties.isNotEmpty()) {
info += " - ${getString(R.string.filtered_label)}"
}
infoBarText.value = info
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.HistoryEvent -> {
sortOrder = event.sortOrder
if (event.startDate > 0) startDate = event.startDate
endDate = event.endDate
loadItems()
updateToolbar()
}
else -> {}
}
}
}
}
private var loadItemsRunning = false
override fun loadData(): List<Episode> {
if (!loadItemsRunning) {
loadItemsRunning = true
allHistory = getHistory(0, Int.MAX_VALUE, startDate, endDate, sortOrder).toMutableList()
loadItemsRunning = false
}
if (allHistory.isEmpty()) return listOf()
return allHistory
}
override fun loadTotalItemCount(): Int {
return getNumberOfPlayed().toInt()
}
fun clearHistory() : Job {
Logd(TAG, "clearHistory called")
return runOnIOScope {
val episodes = realm.query(Episode::class).query("media.playbackCompletionTime > 0 || media.lastPlayedTime > 0").find()
for (e in episodes) {
upsert(e) {
it.media?.playbackCompletionDate = null
it.media?.lastPlayedTime = 0
}
}
EventFlow.postEvent(FlowEvent.HistoryEvent())
}
}
companion object {
val TAG = HistoryFragment::class.simpleName ?: "Anonymous"
fun getNumberOfPlayed(): Long {
Logd(TAG, "getNumberOfPlayed called")
return realm.query(EpisodeMedia::class).query("lastPlayedTime > 0 || playbackCompletionTime > 0").count().find()
}
/**
* Loads the playback history from the database. A FeedItem is in the playback history if playback of the correpsonding episode
* has been played ot completed at least once.
* @param limit The maximum number of episodes to return.
* @return The playback history. The FeedItems are sorted by their media's playbackCompletionDate in descending order.
*/
fun getHistory(offset: Int, limit: Int, start: Long = 0L, end: Long = Date().time,
sortOrder: EpisodeSortOrder = EpisodeSortOrder.PLAYED_DATE_NEW_OLD): List<Episode> {
Logd(TAG, "getHistory() called")
val medias = realm.query(EpisodeMedia::class).query("(playbackCompletionTime > 0) OR (lastPlayedTime > \$0 AND lastPlayedTime <= \$1)", start, end).find()
var episodes: MutableList<Episode> = mutableListOf()
for (m in medias) {
val item_ = m.episodeOrFetch()
if (item_ != null) episodes.add(item_)
else Logd(TAG, "getHistory: media has null episode: ${m.id}")
}
getPermutor(sortOrder).reorder(episodes)
if (offset > 0 && episodes.size > offset) episodes = episodes.subList(offset, min(episodes.size, offset+limit))
return episodes
}
}
}

View File

@ -13,7 +13,7 @@ import ac.mdiq.podcini.storage.model.Rating.Companion.fromCode
import ac.mdiq.podcini.ui.actions.DownloadActionButton
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.ShareReceiverActivity.Companion.receiveShared
import ac.mdiq.podcini.ui.compose.ConfirmAddYoutubeEpisode
import ac.mdiq.podcini.ui.compose.ConfirmAddYoutubeEpisode1
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
@ -120,7 +120,7 @@ class LogsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
var showYTMediaConfirmDialog by remember { mutableStateOf(false) }
var sharedUrl by remember { mutableStateOf("") }
if (showYTMediaConfirmDialog)
ConfirmAddYoutubeEpisode(listOf(sharedUrl), showYTMediaConfirmDialog, onDismissRequest = { showYTMediaConfirmDialog = false })
ConfirmAddYoutubeEpisode1(listOf(sharedUrl), showYTMediaConfirmDialog, onDismissRequest = { showYTMediaConfirmDialog = false })
LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {

View File

@ -1,6 +1,7 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.storage.database.Episodes.getEpisodesCount
import ac.mdiq.podcini.storage.database.Feeds.getFeedCount
@ -11,7 +12,6 @@ import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment.Companion.ARGUMENT_FEED_ID
import ac.mdiq.podcini.ui.fragment.HistoryFragment.Companion.getNumberOfPlayed
import ac.mdiq.podcini.ui.utils.ThemeUtils
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
import ac.mdiq.podcini.util.Logd
@ -98,8 +98,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
private fun getRecentPodcasts() {
var feeds_ = realm.query(Feed::class).sort("lastPlayed", sortOrder = Sort.DESCENDING).find().toMutableList()
if (feeds_.size > 3) feeds_ = feeds_.subList(0, 3)
// for (f in feeds_) Logd(TAG, "getRecentPodcasts ${f.title}")
if (feeds_.size > 5) feeds_ = feeds_.subList(0, 5)
feeds.clear()
feeds.addAll(feeds_)
}
@ -114,6 +113,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
(activity as MainActivity).bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
}) {
Icon(imageVector = ImageVector.vectorResource(nav.iconRes), tint = textColor, contentDescription = nav.tag, modifier = Modifier.padding(start = 10.dp))
// val nametag = if (nav.tag != QueuesFragment.TAG) stringResource(nav.nameRes) else curQueue.name
Text(stringResource(nav.nameRes), color = textColor, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(start = 20.dp))
Spacer(Modifier.weight(1f))
if (nav.count > 0) Text(nav.count.toString(), color = textColor, modifier = Modifier.padding(end = 10.dp))
@ -219,11 +219,11 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
val navMap: LinkedHashMap<String, NavItem> = linkedMapOf(
SubscriptionsFragment.TAG to NavItem(SubscriptionsFragment.TAG, R.drawable.ic_subscriptions, R.string.subscriptions_label),
QueuesFragment.TAG to NavItem(QueuesFragment.TAG, R.drawable.ic_playlist_play, R.string.queue_label),
AllEpisodesFragment.TAG to NavItem(AllEpisodesFragment.TAG, R.drawable.ic_feed, R.string.episodes_label),
DownloadsFragment.TAG to NavItem(DownloadsFragment.TAG, R.drawable.ic_download, R.string.downloads_label),
HistoryFragment.TAG to NavItem(HistoryFragment.TAG, R.drawable.baseline_work_history_24, R.string.playback_history_label),
EpisodesFragment.TAG to NavItem(EpisodesFragment.TAG, R.drawable.ic_feed, R.string.episodes_label),
// AllEpisodesFragment.TAG to NavItem(AllEpisodesFragment.TAG, R.drawable.ic_feed, R.string.episodes_label),
// DownloadsFragment.TAG to NavItem(DownloadsFragment.TAG, R.drawable.ic_download, R.string.downloads_label),
// HistoryFragment.TAG to NavItem(HistoryFragment.TAG, R.drawable.baseline_work_history_24, R.string.playback_history_label),
LogsFragment.TAG to NavItem(LogsFragment.TAG, R.drawable.ic_history, R.string.logs_label),
// SubscriptionLogFragment.TAG to NavItem(SubscriptionLogFragment.TAG, R.drawable.ic_subscriptions, R.string.subscriptions_log_label),
StatisticsFragment.TAG to NavItem(StatisticsFragment.TAG, R.drawable.ic_chart_box, R.string.statistics_label),
OnlineSearchFragment.TAG to NavItem(OnlineSearchFragment.TAG, R.drawable.ic_add, R.string.add_feed_label)
)
@ -251,9 +251,10 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
feedCount = getFeedCount()
navMap[QueuesFragment.TAG]?.count = realm.query(PlayQueue::class).find().sumOf { it.size()}
navMap[SubscriptionsFragment.TAG]?.count = feedCount
navMap[HistoryFragment.TAG]?.count = getNumberOfPlayed().toInt()
navMap[DownloadsFragment.TAG]?.count = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name))
navMap[AllEpisodesFragment.TAG]?.count = numItems
// navMap[HistoryFragment.TAG]?.count = getNumberOfPlayed().toInt()
// navMap[DownloadsFragment.TAG]?.count = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name))
// navMap[AllEpisodesFragment.TAG]?.count = numItems
navMap[EpisodesFragment.TAG]?.count = numItems
navMap[LogsFragment.TAG]?.count = realm.query(ShareLog::class).count().find().toInt() +
realm.query(SubscriptionLog::class).count().find().toInt() +
realm.query(DownloadResult::class).count().find().toInt()

View File

@ -709,7 +709,7 @@ class OnlineFeedFragment : Fragment() {
}
class RemoteEpisodesFragment : BaseEpisodesFragment() {
private val episodeList: MutableList<Episode> = mutableListOf()
private var episodeList: MutableList<Episode> = mutableListOf()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
@ -720,29 +720,21 @@ class OnlineFeedFragment : Fragment() {
return root
}
override fun onDestroyView() {
episodeList.clear()
super.onDestroyView()
}
fun setEpisodes(episodeList_: MutableList<Episode>) {
episodeList.clear()
episodeList.addAll(episodeList_)
episodeList = episodeList_
}
override fun loadData(): List<Episode> {
if (episodeList.isEmpty()) return listOf()
return episodeList
}
override fun loadTotalItemCount(): Int {
return episodeList.size
}
override fun getPrefName(): String {
return PREF_NAME
}
override fun updateToolbar() {
binding.toolbar.menu.findItem(R.id.episodes_sort).isVisible = false
// binding.toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_search).isVisible = false
// binding.toolbar.menu.findItem(R.id.action_favorites).setVisible(false)
binding.toolbar.menu.findItem(R.id.filter_items).isVisible = false
toolbar.menu.findItem(R.id.episodes_sort).isVisible = false
toolbar.menu.findItem(R.id.action_search).isVisible = false
toolbar.menu.findItem(R.id.filter_items).isVisible = false
infoBarText.value = "${episodes.size} episodes"
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@ -772,11 +764,6 @@ class OnlineFeedFragment : Fragment() {
private const val PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload"
private const val KEY_UP_ARROW = "up_arrow"
// var prefs: SharedPreferences? = null
// fun getSharedPrefs(context: Context) {
// if (prefs == null) prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
// }
fun newInstance(feedUrl: String, isShared: Boolean = false): OnlineFeedFragment {
val fragment = OnlineFeedFragment()
val b = Bundle()

View File

@ -10,15 +10,17 @@ import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.net.feed.searcher.*
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.isOPMLRestared
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.performRestore
import ac.mdiq.podcini.preferences.OpmlTransporter
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlElement
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.NonlazyGrid
import ac.mdiq.podcini.ui.compose.OnlineFeedItem
import ac.mdiq.podcini.ui.compose.OpmlImportSelectionDialog
import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.feedCount
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
@ -98,10 +100,46 @@ class OnlineSearchFragment : Fragment() {
private var numColumns by mutableIntStateOf(4)
private val searchResult = mutableStateListOf<PodcastSearchResult>()
private var showOpmlImportSelectionDialog by mutableStateOf(false)
private val readElements = mutableStateListOf<OpmlElement>()
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) { uri: Uri? ->
this.chooseOpmlImportPathResult(uri) }
if (uri == null) return@registerForActivityResult
OpmlTransporter.startImport(requireContext(), uri) { readElements.addAll(it) }
showOpmlImportSelectionDialog = true
}
private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) { uri: Uri? -> this.addLocalFolderResult(uri) }
private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
try {
val feed = withContext(Dispatchers.IO) {
// addLocalFolder(uri)
requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val documentFile = DocumentFile.fromTreeUri(requireContext(), uri)
requireNotNull(documentFile) { "Unable to retrieve document tree" }
var title = documentFile.name
if (title == null) title = getString(R.string.local_folder)
val dirFeed = Feed(Feed.PREFIX_LOCAL_FOLDER + uri.toString(), null, title)
dirFeed.episodes.clear()
dirFeed.sortOrder = EpisodeSortOrder.EPISODE_TITLE_A_Z
val fromDatabase: Feed? = updateFeed(requireContext(), dirFeed, false)
FeedUpdateManager.runOnce(requireContext(), fromDatabase)
fromDatabase
}
withContext(Dispatchers.Main) {
if (feed != null) {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
mainAct?.loadChildFragment(fragment)
}
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
mainAct?.showSnackbarAbovePlayer(e.localizedMessage?: "No messaage", Snackbar.LENGTH_LONG)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
@ -170,6 +208,7 @@ class OnlineSearchFragment : Fragment() {
Text(stringResource(R.string.search_fyyd_label), color = textColor, modifier = Modifier.padding(start = 10.dp, top = 5.dp).clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) }))
Text(stringResource(R.string.gpodnet_search_hint), color = textColor, modifier = Modifier.padding(start = 10.dp, top = 5.dp).clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) }))
Text(stringResource(R.string.search_podcastindex_label), color = textColor, modifier = Modifier.padding(start = 10.dp, top = 5.dp).clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) }))
if (showOpmlImportSelectionDialog) OpmlImportSelectionDialog(readElements) { showOpmlImportSelectionDialog = false }
Text(stringResource(R.string.opml_add_podcast_label), color = textColor, modifier = Modifier.padding(start = 10.dp, top = 5.dp).clickable(onClick = {
try { chooseOpmlImportPathLauncher.launch("*/*")
} catch (e: ActivityNotFoundException) {
@ -332,48 +371,6 @@ class OnlineSearchFragment : Fragment() {
super.onDestroyView()
}
private fun chooseOpmlImportPathResult(uri: Uri?) {
if (uri == null) return
val intent = Intent(context, OpmlImportActivity::class.java)
intent.setData(uri)
startActivity(intent)
}
private fun addLocalFolderResult(uri: Uri?) {
if (uri == null) return
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
try {
val feed = withContext(Dispatchers.IO) { addLocalFolder(uri) }
withContext(Dispatchers.Main) {
if (feed != null) {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
mainAct?.loadChildFragment(fragment)
}
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
mainAct?.showSnackbarAbovePlayer(e.localizedMessage?: "No messaage", Snackbar.LENGTH_LONG)
}
}
}
private fun addLocalFolder(uri: Uri): Feed? {
requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val documentFile = DocumentFile.fromTreeUri(requireContext(), uri)
requireNotNull(documentFile) { "Unable to retrieve document tree" }
var title = documentFile.name
if (title == null) title = getString(R.string.local_folder)
val dirFeed = Feed(Feed.PREFIX_LOCAL_FOLDER + uri.toString(), null, title)
dirFeed.episodes.clear()
dirFeed.sortOrder = EpisodeSortOrder.EPISODE_TITLE_A_Z
val fromDatabase: Feed? = updateFeed(requireContext(), dirFeed, false)
FeedUpdateManager.runOnce(requireContext(), fromDatabase)
return fromDatabase
}
private class AddLocalFolder : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

View File

@ -168,9 +168,9 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
toolbar.addView(spinnerView)
swipeActions = SwipeActions(this, TAG)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
// swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
swipeActionsBin = SwipeActions(this, "$TAG.Bin")
swipeActionsBin.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
// swipeActionsBin.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
binding.mainView.setContent {
CustomTheme(requireContext()) {
@ -179,11 +179,11 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
InforBar(infoBarText, leftAction = leftActionStateBin, rightAction = rightActionStateBin, actionConfig = { swipeActionsBin.showDialog() })
val leftCB = { episode: Episode ->
if (leftActionStateBin.value is NoActionSwipeAction) swipeActionsBin.showDialog()
else leftActionStateBin.value.performAction(episode, this@QueuesFragment, swipeActionsBin.filter ?: EpisodeFilter())
else leftActionStateBin.value.performAction(episode, this@QueuesFragment)
}
val rightCB = { episode: Episode ->
if (rightActionStateBin.value is NoActionSwipeAction) swipeActionsBin.showDialog()
else rightActionStateBin.value.performAction(episode, this@QueuesFragment, swipeActionsBin.filter ?: EpisodeFilter())
else rightActionStateBin.value.performAction(episode, this@QueuesFragment)
}
EpisodeLazyColumn(activity as MainActivity, vms = vms, leftSwipeCB = { leftCB(it) }, rightSwipeCB = { rightCB(it) })
}
@ -200,11 +200,11 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = { swipeActions.showDialog() })
val leftCB = { episode: Episode ->
if (leftActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else leftActionState.value.performAction(episode, this@QueuesFragment, swipeActions.filter ?: EpisodeFilter())
else leftActionState.value.performAction(episode, this@QueuesFragment)
}
val rightCB = { episode: Episode ->
if (rightActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else rightActionState.value.performAction(episode, this@QueuesFragment, swipeActions.filter ?: EpisodeFilter())
else rightActionState.value.performAction(episode, this@QueuesFragment)
}
EpisodeLazyColumn(activity as MainActivity, vms = vms,
isDraggable = dragDropEnabled, dragCB = { iFrom, iTo -> runOnIOScope { moveInQueueSync(iFrom, iTo, true) } },

View File

@ -7,7 +7,6 @@ import ac.mdiq.podcini.net.feed.searcher.CombinedSearcher
import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.Rating
import ac.mdiq.podcini.ui.actions.SwipeAction
@ -117,11 +116,11 @@ class SearchFragment : Fragment() {
EpisodeLazyColumn(activity as MainActivity, vms = vms,
leftSwipeCB = {
if (leftActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else leftActionState.value.performAction(it, this@SearchFragment, swipeActions.filter ?: EpisodeFilter())
else leftActionState.value.performAction(it, this@SearchFragment)
},
rightSwipeCB = {
if (rightActionState.value is NoActionSwipeAction) swipeActions.showDialog()
else rightActionState.value.performAction(it, this@SearchFragment, swipeActions.filter ?: EpisodeFilter())
else rightActionState.value.performAction(it, this@SearchFragment)
},
)
}

View File

@ -2,7 +2,6 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.ComposeFragmentBinding
import ac.mdiq.podcini.databinding.DialogSwitchPreferenceBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.preferences.DocumentFileExportWorker
import ac.mdiq.podcini.preferences.ExportTypes
@ -28,7 +27,10 @@ import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatDateTimeFlex
import android.app.Activity.RESULT_OK
import android.content.*
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.util.Log
@ -75,7 +77,6 @@ import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
@ -532,9 +533,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (showSpeedDialog) PlaybackSpeedDialog(selected, initSpeed = 1f, maxSpeed = 3f, onDismiss = {showSpeedDialog = false}) { newSpeed ->
saveFeedPreferences { it: FeedPreferences -> it.playSpeed = newSpeed }
}
var showAutoDownloadSwitchDialog by remember { mutableStateOf(false) }
if (showAutoDownloadSwitchDialog) SimpleSwitchDialog(stringResource(R.string.auto_download_settings_label), stringResource(R.string.auto_download_label), onDismissRequest = { showAutoDownloadSwitchDialog = false }) { enabled ->
saveFeedPreferences { it: FeedPreferences -> it.autoDownload = enabled }
}
@Composable
fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Feed>, modifier: Modifier = Modifier) {
fun EpisodeSpeedDial(selected: SnapshotStateList<Feed>, modifier: Modifier = Modifier) {
val TAG = "EpisodeSpeedDial ${selected.size}"
var isExpanded by remember { mutableStateOf(false) }
val options = listOf<@Composable () -> Unit>(
@ -558,13 +563,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
isExpanded = false
selectMode = false
Logd(TAG, "ic_download: ${selected.size}")
val preferenceSwitchDialog = PreferenceSwitchDialog(activity, activity.getString(R.string.auto_download_settings_label), activity.getString(R.string.auto_download_label))
preferenceSwitchDialog.setOnPreferenceChangedListener( object: PreferenceSwitchDialog.OnPreferenceChangedListener {
override fun preferenceChanged(enabled: Boolean) {
saveFeedPreferences { it: FeedPreferences -> it.autoDownload = enabled }
}
})
preferenceSwitchDialog.openDialog()
showAutoDownloadSwitchDialog = true
}) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "")
Text(stringResource(id = R.string.auto_download_label)) } },
@ -834,7 +833,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "selectedIds: ${selected.size}")
}))
}
EpisodeSpeedDial(activity as MainActivity, selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp))
EpisodeSpeedDial(selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp))
}
}
}
@ -1430,33 +1429,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
}
class PreferenceSwitchDialog(private var context: Context, private val title: String, private val text: String) {
private var onPreferenceChangedListener: OnPreferenceChangedListener? = null
interface OnPreferenceChangedListener {
fun preferenceChanged(enabled: Boolean)
}
fun openDialog() {
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(title)
val inflater = LayoutInflater.from(this.context)
val layout = inflater.inflate(R.layout.dialog_switch_preference, null, false)
val binding = DialogSwitchPreferenceBinding.bind(layout)
val switchButton = binding.dialogSwitch
switchButton.text = text
builder.setView(layout)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
onPreferenceChangedListener?.preferenceChanged(switchButton.isChecked)
}
builder.setNegativeButton(R.string.cancel_label, null)
builder.create().show()
}
fun setOnPreferenceChangedListener(onPreferenceChangedListener: OnPreferenceChangedListener?) {
this.onPreferenceChangedListener = onPreferenceChangedListener
}
}
companion object {
val TAG = SubscriptionsFragment::class.simpleName ?: "Anonymous"

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/dialog_switch_preference"
android:padding="24dp">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/dialogSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Switch" />
</LinearLayout>

View File

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/butConfirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:layout_margin="8dp"
android:text="@string/confirm_label"
style="@style/Widget.MaterialComponents.Button.TextButton" />
<Button
android:id="@+id/butCancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_toLeftOf="@+id/butConfirm"
android:layout_toStartOf="@+id/butConfirm"
android:layout_margin="8dp"
android:text="@string/cancel_label"
style="@style/Widget.MaterialComponents.Button.TextButton" />
<ListView
android:id="@+id/feedlist"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_above="@id/butConfirm"
android:layout_alignParentTop="true"
tools:listitem="@android:layout/simple_list_item_multiple_choice" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</RelativeLayout>

View File

@ -6,8 +6,14 @@
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_search"
custom:showAsAction="always"
android:title="@string/search_label"/>
android:title="@string/search_label"
custom:showAsAction="always"/>
<item
android:id="@+id/episodes_sort"
android:icon="@drawable/arrows_sort"
android:title="@string/sort"
custom:showAsAction="always" />
<item
android:id="@+id/filter_items"
@ -17,19 +23,19 @@
custom:showAsAction="always"/>
<item
android:id="@+id/episodes_sort"
android:icon="@drawable/arrows_sort"
android:title="@string/sort"
custom:showAsAction="ifRoom" />
android:id="@+id/clear_history_item"
android:icon="@drawable/ic_delete"
android:title="@string/clear_history_label"
custom:showAsAction="never"/>
<!-- <item-->
<!-- android:id="@+id/refresh_item"-->
<!-- android:title="@string/refresh_label"-->
<!-- android:menuCategory="container"-->
<!-- custom:showAsAction="never" />-->
<item
android:id="@+id/reconcile"
android:title="@string/reconcile_label"
custom:showAsAction="never" />
<!-- <item-->
<!-- android:id="@+id/switch_queue"-->
<!-- android:title="@string/switch_queue" />-->
<item
android:id="@+id/clear_new"
android:title="@string/clear_new_label"
custom:showAsAction="never" />
</menu>

View File

@ -127,6 +127,7 @@
<string name="new_synth_yt_label">New synthetic Youtube</string>
<string name="toggle_grid_list">Toggle grid list</string>
<string name="refreshing_label">Refreshing</string>
<string name="clear_new_label">Clear new</string>
<string name="reconcile_label">Reconcile</string>
<string name="chapters_label">Chapters</string>
@ -213,6 +214,7 @@
<string name="filtered_label">Filtered</string>
<string name="progressing_label">Processing</string>
<string name="open_podcast">Open podcast</string>
<string name="open">Open</string>
@ -441,6 +443,8 @@
<string name="import_export_search_keywords">backup, restore</string>
<string name="appearance">Appearance</string>
<string name="external_elements">External elements</string>
<string name="create_YT_syndicates">Create YT syndicates</string>
<string name="add_to_feed">Add to feed</string>
<string name="interruptions">Interruptions</string>
<string name="playback_control">Playback control</string>
<string name="reassign_hardware_buttons">Reassign hardware buttons</string>

View File

@ -1,3 +1,19 @@
# 6.15.0
* added a combo Epiosdes fragment with easy access to various filters
* merged AllEpisodes, History and Download into Episodes
* other easy accesses include: New, Planned (for Soon and Later), Repeats (for Again and Forever), Liked (for Good and Super)
* New episodes can be cleared via the manu
* drawer items customization is disabled in Settings
* drawer includes 5 most recent feeds
* rearranged routines in ImportExportPreferences
* importing OPML file is done within the activity, no longer starts OpmlImportActivity
* removed OpmlImportActivity
* getting shared youtube media has a new interface to select feed
* cleaned up SwipeActions, removed the need for filter
* more Compose migrations
* media3 update to 1.5.0
# 6.14.8
* fixed issues in tags setting

View File

@ -0,0 +1,15 @@
Version 6.15.0
* added a combo Epiosdes fragment with easy access to various filters
* merged AllEpisodes, History and Download into Episodes
* other easy accesses include: New, Planned (for Soon and Later), Repeats (for Again and Forever), Liked (for Good and Super)
* New episodes can be cleared via the manu
* drawer items customization is disabled in Settings
* drawer includes 5 most recent feeds
* rearranged routines in ImportExportPreferences
* importing OPML file is done within the activity, no longer starts OpmlImportActivity
* removed OpmlImportActivity
* getting shared youtube media has a new interface to select feed
* cleaned up SwipeActions, removed the need for filter
* more Compose migrations
* media3 update to 1.5.0

View File

@ -30,10 +30,10 @@ libraryBase = "2.1.0"
lifecycleRuntimeKtx = "2.8.7"
material3 = "1.3.1"
materialVersion = "1.12.0"
media3Common = "1.4.1"
media3Session = "1.4.1"
media3Ui = "1.4.1"
media3Exoplayer = "1.4.1"
media3Common = "1.5.0"
media3Session = "1.5.0"
media3Ui = "1.5.0"
media3Exoplayer = "1.5.0"
mediarouter = "1.7.0"
okhttp = "4.12.0"
okhttpUrlconnection = "4.12.0"