diff --git a/README.md b/README.md index b5f321c8..06eec9f5 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c ### Podcast list and Episode list -* A whole new interface of the Subscriptions page showing only the feeds with tags as filters, no longer having tags as folders in the page, * Subscriptions page by default has a list layout and can be opted for a grid layout * New and efficient ways of click and long-click operations on lists: * click on title area opens the podcast/episode @@ -99,14 +98,14 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * Left and right swipe actions on lists now have telltales and can be configured on the spot * Played or new episodes have clearer markings * Sort dialog no longer dims the main view -* download date can be used to sort both feeds and episodes -* Subscriptions view has a filter based on feed preferences, in the same style as episodes filter +* An all new way of filtering for both podcasts and episodes * Subscriptions sorting is now bi-directional based on various explicit measures, and sorting info is shown on every feed (List Layout only) * in Subscriptions view, click on cover image of a feed opens the FeedInfo view (not FeedEpisodes view) * in all episodes list views, click on an episode image brings up the FeedInfo view * in episode list view, if episode has no media, TTS button is shown for fetching transcript (if not exist) and then generating audio file from the transcript. TTS audio files are playable in the same way as local media (with speed setting, pause and rewind/forward) * on action bar of FeedEpisodes view there is a direct access to Queue * Long-press filter button in FeedEpisodes view enables/disables filters without changing filter settings +* Long-press on the action button on the right of any episode in the list brings up more options * History view shows time of last play, and allows filters and sorts ### Podcast/Episode diff --git a/app/build.gradle b/app/build.gradle index 26cdd278..ee142fe6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020280 - versionName "6.12.2" + versionCode 3020281 + versionName "6.12.3" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeFilter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeFilter.kt index 830173eb..c2c87a37 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeFilter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeFilter.kt @@ -1,84 +1,122 @@ package ac.mdiq.podcini.storage.model -import ac.mdiq.podcini.playback.base.InTheatre.curQueue +import ac.mdiq.podcini.R import ac.mdiq.podcini.storage.database.Queues.inAnyQueue +import ac.mdiq.podcini.storage.model.MediaType.Companion.AUDIO_APPLICATION_MIME_STRINGS import java.io.Serializable -class EpisodeFilter(vararg properties: String) : Serializable { - private val properties: Array = arrayOf(*properties.filter { it.isNotEmpty() }.map {it.trim()}.toTypedArray()) - - val showPlayed: Boolean = hasProperty(States.played.name) - val showUnplayed: Boolean = hasProperty(States.unplayed.name) - val showPaused: Boolean = hasProperty(States.paused.name) - val showNotPaused: Boolean = hasProperty(States.not_paused.name) - val showNew: Boolean = hasProperty(States.new.name) - val showQueued: Boolean = hasProperty(States.queued.name) - val showNotQueued: Boolean = hasProperty(States.not_queued.name) - val showDownloaded: Boolean = hasProperty(States.downloaded.name) - val showNotDownloaded: Boolean = hasProperty(States.not_downloaded.name) - val showAutoDownloadable: Boolean = hasProperty(States.auto_downloadable.name) - val showNotAutoDownloadable: Boolean = hasProperty(States.not_auto_downloadable.name) - val showHasMedia: Boolean = hasProperty(States.has_media.name) - val showNoMedia: Boolean = hasProperty(States.no_media.name) - val showHasComments: Boolean = hasProperty(States.has_comments.name) - val showNoComments: Boolean = hasProperty(States.no_comments.name) - val showIsFavorite: Boolean = hasProperty(States.is_favorite.name) - val showNotFavorite: Boolean = hasProperty(States.not_favorite.name) +class EpisodeFilter(vararg properties_: String) : Serializable { + val properties: HashSet = setOf(*properties_).filter { it.isNotEmpty() }.map {it.trim()}.toHashSet() - constructor(properties: String) : this(*(properties.split(",").toTypedArray())) - - private fun hasProperty(property: String): Boolean { - return listOf(*properties).contains(property) - } - - val values: Array - get() = properties.clone() + val showPlayed: Boolean = properties.contains(States.played.name) + val showUnplayed: Boolean = properties.contains(States.unplayed.name) + val showNew: Boolean = properties.contains(States.new.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) + val showIsFavorite: Boolean = properties.contains(States.is_favorite.name) + val showNotFavorite: Boolean = properties.contains(States.not_favorite.name) - val valuesList: List - get() = listOf(*properties) + constructor(properties: String) : this(*(properties.split(",").toTypedArray())) - fun matches(item: Episode): Boolean { - when { - showNew && !item.isNew -> return false - showPlayed && item.playState < PlayState.PLAYED.code -> return false - showUnplayed && item.playState >= PlayState.PLAYED.code -> return false - showPaused && !item.isInProgress -> return false - showNotPaused && item.isInProgress -> return false - showDownloaded && !item.isDownloaded -> return false - showNotDownloaded && item.isDownloaded -> return false - showAutoDownloadable && !item.isAutoDownloadEnabled -> return false - showNotAutoDownloadable && item.isAutoDownloadEnabled -> return false - showHasMedia && item.media == null -> return false - showNoMedia && item.media != null -> return false - showHasComments && item.comment.isEmpty() -> return false - showNoComments && item.comment.isNotEmpty() -> return false - showIsFavorite && !item.isFavorite -> return false - showNotFavorite && item.isFavorite -> return false - showQueued && !inAnyQueue(item) -> return false - showNotQueued && inAnyQueue(item) -> return false - else -> return true - } - } +// fun matches(item: Episode): Boolean { +// when { +// showNew && !item.isNew -> return false +// showPlayed && item.playState < PlayState.PLAYED.code -> return false +// showUnplayed && item.playState >= PlayState.PLAYED.code -> return false +// properties.contains(States.paused.name) && !item.isInProgress -> return false +// properties.contains(States.not_paused.name) && item.isInProgress -> return false +// showDownloaded && !item.isDownloaded -> return false +// showNotDownloaded && item.isDownloaded -> return false +// properties.contains(States.auto_downloadable.name) && !item.isAutoDownloadEnabled -> return false +// properties.contains(States.not_auto_downloadable.name) && item.isAutoDownloadEnabled -> return false +// properties.contains(States.has_media.name) && item.media == null -> return false +// properties.contains(States.no_media.name) && item.media != null -> return false +// properties.contains(States.has_comments.name) && item.comment.isEmpty() -> return false +// properties.contains(States.no_comments.name) && item.comment.isNotEmpty() -> return false +// showIsFavorite && !item.isFavorite -> return false +// showNotFavorite && item.isFavorite -> return false +// showQueued && !inAnyQueue(item) -> return false +// showNotQueued && inAnyQueue(item) -> return false +// else -> return true +// } +// } // filter on queues does not have a query string so it's not applied on query results, need to filter separately fun matchesForQueues(item: Episode): Boolean { - return when { - showQueued && !inAnyQueue(item) -> false - showNotQueued && inAnyQueue(item) -> false - else -> true - } + return when { + showQueued && !inAnyQueue(item) -> false + showNotQueued && inAnyQueue(item) -> false + else -> true + } } fun queryString(): String { - val statements: MutableList = ArrayList() + val statements: MutableList = mutableListOf() when { showPlayed -> statements.add("playState >= ${PlayState.PLAYED.code}") showUnplayed -> statements.add(" playState < ${PlayState.PLAYED.code} ") // Match "New" items (read = -1) as well showNew -> statements.add("playState == -1 ") } + + val mediaTypeQuerys = mutableListOf() + if (properties.contains(States.unknown.name)) mediaTypeQuerys.add(" media == nil OR media.mimeType == nil OR media.mimeType == '' ") + if (properties.contains(States.audio.name)) mediaTypeQuerys.add(" media.mimeType BEGINSWITH 'audio' ") + if (properties.contains(States.video.name)) mediaTypeQuerys.add(" media.mimeType BEGINSWITH 'video' ") + if (properties.contains(States.audio_app.name)) mediaTypeQuerys.add(" media.mimeType IN ${AUDIO_APPLICATION_MIME_STRINGS.toList()} ") + if (mediaTypeQuerys.isNotEmpty()) { + val query = StringBuilder(" (" + mediaTypeQuerys[0]) + if (mediaTypeQuerys.size > 1) for (r in statements.subList(1, mediaTypeQuerys.size)) { + query.append(" OR ") + query.append(r) + } + query.append(") ") + statements.add(query.toString()) + } + + val ratingQuerys = mutableListOf() + if (properties.contains(States.unrated.name)) ratingQuerys.add(" rating == ${Rating.UNRATED.code} ") + if (properties.contains(States.trash.name)) ratingQuerys.add(" rating == ${Rating.TRASH.code} ") + if (properties.contains(States.bad.name)) ratingQuerys.add(" rating == ${Rating.BAD.code} ") + if (properties.contains(States.neutral.name)) ratingQuerys.add(" rating == ${Rating.NEUTRAL.code} ") + if (properties.contains(States.good.name)) ratingQuerys.add(" rating == ${Rating.GOOD.code} ") + if (properties.contains(States.favorite.name)) ratingQuerys.add(" rating == ${Rating.FAVORITE.code} ") + if (ratingQuerys.isNotEmpty()) { + val query = StringBuilder(" (" + ratingQuerys[0]) + if (ratingQuerys.size > 1) for (r in statements.subList(1, ratingQuerys.size)) { + query.append(" OR ") + query.append(r) + } + query.append(") ") + statements.add(query.toString()) + } + + val stateQuerys = mutableListOf() + if (properties.contains(States.unspecified.name)) stateQuerys.add(" playState == ${PlayState.UNSPECIFIED.code} ") + if (properties.contains(States.building.name)) stateQuerys.add(" playState == ${PlayState.BUILDING.code} ") + if (properties.contains(States.new.name)) stateQuerys.add(" playState == ${PlayState.NEW.code} ") + if (properties.contains(States.unplayed.name)) stateQuerys.add(" playState == ${PlayState.UNPLAYED.code} ") + if (properties.contains(States.later.name)) stateQuerys.add(" playState == ${PlayState.LATER.code} ") + if (properties.contains(States.soon.name)) stateQuerys.add(" playState == ${PlayState.SOON.code} ") + if (properties.contains(States.inQueue.name)) stateQuerys.add(" playState == ${PlayState.INQUEUE.code} ") + if (properties.contains(States.inProgress.name)) stateQuerys.add(" playState == ${PlayState.INPROGRESS.code} ") + if (properties.contains(States.skipped.name)) stateQuerys.add(" playState == ${PlayState.SKIPPED.code} ") + if (properties.contains(States.played.name)) stateQuerys.add(" playState == ${PlayState.PLAYED.code} ") + if (properties.contains(States.ignored.name)) stateQuerys.add(" playState == ${PlayState.IGNORED.code} ") + if (stateQuerys.isNotEmpty()) { + val query = StringBuilder(" (" + stateQuerys[0]) + if (stateQuerys.size > 1) for (r in statements.subList(1, stateQuerys.size)) { + query.append(" OR ") + query.append(r) + } + query.append(") ") + statements.add(query.toString()) + } + when { - showPaused -> statements.add(" media.position > 0 ") - showNotPaused -> statements.add(" media.position == 0 ") + properties.contains(States.paused.name) -> statements.add(" media.position > 0 ") + properties.contains(States.not_paused.name) -> statements.add(" media.position == 0 ") } // when { // showQueued -> statements.add("$keyItemId IN (SELECT $keyFeedItem FROM $tableQueue) ") @@ -89,16 +127,20 @@ class EpisodeFilter(vararg properties: String) : Serializable { showNotDownloaded -> statements.add("media.downloaded == false ") } when { - showAutoDownloadable -> statements.add("isAutoDownloadEnabled == true ") - showNotAutoDownloadable -> statements.add("isAutoDownloadEnabled == false ") + properties.contains(States.auto_downloadable.name) -> statements.add("isAutoDownloadEnabled == true ") + properties.contains(States.not_auto_downloadable.name) -> statements.add("isAutoDownloadEnabled == false ") + } + when { + properties.contains(States.has_media.name) -> statements.add("media != nil ") + properties.contains(States.no_media.name) -> statements.add("media == nil ") } when { - showHasMedia -> statements.add("media != nil ") - showNoMedia -> statements.add("media == nil ") + properties.contains(States.has_chapters.name) -> statements.add("chapters.@count > 0 ") + properties.contains(States.no_chapters.name) -> statements.add("chapters.@count == 0 ") } when { - showHasComments -> statements.add(" comment != '' ") - showNoComments -> statements.add(" comment == '' ") + properties.contains(States.has_comments.name) -> statements.add(" comment != '' ") + properties.contains(States.no_comments.name) -> statements.add(" comment == '' ") } when { showIsFavorite -> statements.add("rating == ${Rating.FAVORITE.code} ") @@ -106,22 +148,34 @@ class EpisodeFilter(vararg properties: String) : Serializable { } if (statements.isEmpty()) return "id > 0" - val query = StringBuilder(" (" + statements[0]) - for (r in statements.subList(1, statements.size)) { + if (statements.size > 1) for (r in statements.subList(1, statements.size)) { query.append(" AND ") query.append(r) } query.append(") ") - return query.toString() } @Suppress("EnumEntryName") enum class States { - played, - unplayed, + unspecified, + building, new, + unplayed, + later, + soon, + inQueue, + inProgress, + skipped, + played, + ignored, + has_chapters, + no_chapters, + audio, + video, + unknown, + audio_app, paused, not_paused, is_favorite, @@ -135,7 +189,54 @@ class EpisodeFilter(vararg properties: String) : Serializable { downloaded, not_downloaded, auto_downloadable, - not_auto_downloadable + not_auto_downloadable, + unrated, + trash, + bad, + neutral, + good, + favorite, + } + + enum class EpisodesFilterGroup(val nameRes: Int, vararg values: ItemProperties) { +// PLAYED(ItemProperties(R.string.hide_played_episodes_label, States.played.name), ItemProperties(R.string.not_played, States.unplayed.name)), +// PAUSED(ItemProperties(R.string.hide_paused_episodes_label, States.paused.name), ItemProperties(R.string.not_paused, States.not_paused.name)), +// FAVORITE(ItemProperties(R.string.hide_is_favorite_label, States.is_favorite.name), ItemProperties(R.string.not_favorite, States.not_favorite.name)), + MEDIA(R.string.has_media, ItemProperties(R.string.yes, States.has_media.name), ItemProperties(R.string.no, States.no_media.name)), + RATING(R.string.rating_label, ItemProperties(R.string.unrated, States.unrated.name), + ItemProperties(R.string.trash, States.trash.name), + ItemProperties(R.string.bad, States.bad.name), + ItemProperties(R.string.neutral, States.neutral.name), + ItemProperties(R.string.good, States.good.name), + ItemProperties(R.string.favorite, States.favorite.name), + ), + PLAY_STATE(R.string.playstate, ItemProperties(R.string.unspecified, States.unspecified.name), + ItemProperties(R.string.building, States.building.name), + ItemProperties(R.string.new_label, States.new.name), + ItemProperties(R.string.unplayed, States.unplayed.name), + ItemProperties(R.string.later, States.later.name), + ItemProperties(R.string.soon, States.soon.name), + ItemProperties(R.string.in_queue, States.inQueue.name), + ItemProperties(R.string.in_progress, States.inProgress.name), + ItemProperties(R.string.skipped, States.skipped.name), + ItemProperties(R.string.played, States.played.name), + ItemProperties(R.string.ignored, States.ignored.name), + ), + OPINION(R.string.has_comments, ItemProperties(R.string.yes, States.has_comments.name), ItemProperties(R.string.no, States.no_comments.name)), +// QUEUED(ItemProperties(R.string.queued_label, States.queued.name), ItemProperties(R.string.not_queued_label, States.not_queued.name)), + DOWNLOADED(R.string.downloaded_label, ItemProperties(R.string.yes, States.downloaded.name), ItemProperties(R.string.no, States.not_downloaded.name)), + CHAPTERS(R.string.has_chapters, ItemProperties(R.string.yes, States.has_chapters.name), ItemProperties(R.string.no, States.no_chapters.name)), + MEDIA_TYPE(R.string.media_type, ItemProperties(R.string.unknown, States.unknown.name), + ItemProperties(R.string.audio, States.audio.name), + ItemProperties(R.string.video, States.video.name), + ItemProperties(R.string.audio_app, States.audio_app.name) + ), + AUTO_DOWNLOADABLE(R.string.auto_downloadable_label, ItemProperties(R.string.yes, States.auto_downloadable.name), ItemProperties(R.string.no, States.not_auto_downloadable.name)); + + @JvmField + val values: Array = arrayOf(*values) + + class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String) } companion object { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt index accad8a4..e4b40277 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt @@ -1,91 +1,115 @@ package ac.mdiq.podcini.storage.model +import ac.mdiq.podcini.R +import ac.mdiq.podcini.storage.model.Feed.Companion.MAX_SYNTHETIC_ID import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.SPEED_USE_GLOBAL +import ac.mdiq.podcini.util.Logd import java.io.Serializable -class FeedFilter(vararg properties: String) : Serializable { - - private val properties: Array = arrayOf(*properties.filter { it.isNotEmpty() }.map {it.trim()}.toTypedArray()) - - val showKeepUpdated: Boolean = hasProperty(States.keepUpdated.name) - val showNotKeepUpdated: Boolean = hasProperty(States.not_keepUpdated.name) - val showGlobalPlaySpeed: Boolean = hasProperty(States.global_playSpeed.name) - val showCustomPlaySpeed: Boolean = hasProperty(States.custom_playSpeed.name) - val showHasComments: Boolean = hasProperty(States.has_comments.name) - val showNoComments: Boolean = hasProperty(States.no_comments.name) - val showHasSkips: Boolean = hasProperty(States.has_skips.name) - val showNoSkips: Boolean = hasProperty(States.no_skips.name) - val showAlwaysAutoDelete: Boolean = hasProperty(States.always_auto_delete.name) - val showNeverAutoDelete: Boolean = hasProperty(States.never_auto_delete.name) - val showAutoDownload: Boolean = hasProperty(States.autoDownload.name) - val showNotAutoDownload: Boolean = hasProperty(States.not_autoDownload.name) +class FeedFilter(vararg properties_: String) : Serializable { + val properties: HashSet = setOf(*properties_).filter { it.isNotEmpty() }.map {it.trim()}.toHashSet() constructor(properties: String) : this(*(properties.split(",").toTypedArray())) - private fun hasProperty(property: String): Boolean { - return listOf(*properties).contains(property) - } - - val values: Array - get() = properties.clone() - - val valuesList: List - get() = listOf(*properties) +// fun matches(feed: Feed): Boolean { +// when { +// properties.contains(States.keepUpdated.name) && feed.preferences?.keepUpdated != true -> return false +// properties.contains(States.not_keepUpdated.name) && feed.preferences?.keepUpdated != false -> return false +// properties.contains(States.global_playSpeed.name) && feed.preferences?.playSpeed != SPEED_USE_GLOBAL -> return false +// properties.contains(States.custom_playSpeed.name) && feed.preferences?.playSpeed == SPEED_USE_GLOBAL -> return false +// properties.contains(States.has_comments.name) && feed.comment.isEmpty() -> return false +// properties.contains(States.no_comments.name) && feed.comment.isNotEmpty() -> return false +// properties.contains(States.has_skips.name) && feed.preferences?.introSkip == 0 && feed.preferences?.endingSkip == 0 -> return false +// properties.contains(States.no_skips.name) && (feed.preferences?.introSkip != 0 || feed.preferences?.endingSkip != 0) -> return false +// properties.contains(States.global_auto_delete.name) && feed.preferences?.autoDeleteAction != FeedPreferences.AutoDeleteAction.GLOBAL -> return false +// properties.contains(States.always_auto_delete.name) && feed.preferences?.autoDeleteAction != FeedPreferences.AutoDeleteAction.ALWAYS -> return false +// properties.contains(States.never_auto_delete.name) && feed.preferences?.autoDeleteAction != FeedPreferences.AutoDeleteAction.NEVER -> return false +// properties.contains(States.autoDownload.name) && feed.preferences?.autoDownload != true -> return false +// properties.contains(States.not_autoDownload.name) && feed.preferences?.autoDownload != false -> return false +// properties.contains(States.unrated.name) && feed.rating != Rating.UNRATED.code -> return false +// properties.contains(States.trash.name) && feed.rating != Rating.TRASH.code -> return false +// properties.contains(States.bad.name) && feed.rating != Rating.BAD.code -> return false +// properties.contains(States.neutral.name) && feed.rating != Rating.NEUTRAL.code -> return false +// properties.contains(States.good.name) && feed.rating != Rating.GOOD.code -> return false +// properties.contains(States.favorite.name) && feed.rating != Rating.FAVORITE.code -> return false +// else -> return true +// } +// } - fun matches(feed: Feed): Boolean { + fun queryString(): String { + val statements: MutableList = mutableListOf() when { - showKeepUpdated && feed.preferences?.keepUpdated != true -> return false - showNotKeepUpdated && feed.preferences?.keepUpdated != false -> return false - showGlobalPlaySpeed && feed.preferences?.playSpeed != SPEED_USE_GLOBAL -> return false - showCustomPlaySpeed && feed.preferences?.playSpeed == SPEED_USE_GLOBAL -> return false - showHasComments && feed.comment.isEmpty() -> return false - showNoComments && feed.comment.isEmpty() -> return false - showHasSkips && feed.preferences?.introSkip == 0 && feed.preferences?.endingSkip == 0 -> return false - showNoSkips && (feed.preferences?.introSkip != 0 || feed.preferences?.endingSkip != 0) -> return false - showAlwaysAutoDelete && feed.preferences?.autoDeleteAction != FeedPreferences.AutoDeleteAction.ALWAYS -> return false - showNeverAutoDelete && feed.preferences?.autoDeleteAction != FeedPreferences.AutoDeleteAction.NEVER -> return false - showAutoDownload && feed.preferences?.autoDownload != true -> return false - showNotAutoDownload && feed.preferences?.autoDownload != false -> return false - else -> return true + properties.contains(States.keepUpdated.name) -> statements.add("preferences.keepUpdated == true ") + properties.contains(States.not_keepUpdated.name) -> statements.add(" preferences.keepUpdated == false ") } - } - - fun queryString(): String { - val statements: MutableList = ArrayList() when { - showKeepUpdated -> statements.add("preferences.keepUpdated == true ") - showNotKeepUpdated -> statements.add(" preferences.keepUpdated == false ") + properties.contains(States.global_playSpeed.name) -> statements.add(" preferences.playSpeed == $SPEED_USE_GLOBAL ") + properties.contains(States.custom_playSpeed.name) -> statements.add(" preferences.playSpeed != $SPEED_USE_GLOBAL ") } when { - showGlobalPlaySpeed -> statements.add(" preferences.playSpeed == ${SPEED_USE_GLOBAL} ") - showCustomPlaySpeed -> statements.add(" preferences.playSpeed != $SPEED_USE_GLOBAL ") + properties.contains(States.has_skips.name) -> statements.add(" preferences.introSkip != 0 OR preferences.endingSkip != 0 ") + properties.contains(States.no_skips.name) -> statements.add(" preferences.introSkip == 0 AND preferences.endingSkip == 0 ") } when { - showHasSkips -> statements.add(" preferences.introSkip != 0 OR preferences.endingSkip != 0 ") - showNoSkips -> statements.add(" preferences.introSkip == 0 AND preferences.endingSkip == 0 ") + properties.contains(States.has_comments.name) -> statements.add(" comment != '' ") + properties.contains(States.no_comments.name) -> statements.add(" comment == '' ") } when { - showHasComments -> statements.add(" comment != '' ") - showNoComments -> statements.add(" comment == '' ") + properties.contains(States.synthetic.name) -> statements.add(" id < $MAX_SYNTHETIC_ID ") + properties.contains(States.normal.name) -> statements.add(" id > $MAX_SYNTHETIC_ID ") } when { - showAlwaysAutoDelete -> statements.add(" preferences.autoDelete == ${FeedPreferences.AutoDeleteAction.ALWAYS.code} ") - showNeverAutoDelete -> statements.add(" preferences.playSpeed == ${FeedPreferences.AutoDeleteAction.NEVER.code} ") + properties.contains(States.has_video.name) -> statements.add(" hasVideoMedia == true ") + properties.contains(States.no_video.name) -> statements.add(" hasVideoMedia == false ") } when { - showAutoDownload -> statements.add(" preferences.autoDownload == true ") - showNotAutoDownload -> statements.add(" preferences.autoDownload == false ") + properties.contains(States.youtube.name) -> statements.add(" downloadUrl CONTAINS[c] 'youtube' OR link CONTAINS[c] 'youtube' OR downloadUrl CONTAINS[c] 'youtu.be' OR link CONTAINS[c] 'youtu.be' ") + properties.contains(States.rss.name) -> statements.add(" downloadUrl NOT CONTAINS[c] 'youtube' AND link NOT CONTAINS[c] 'youtube' AND downloadUrl NOT CONTAINS[c] 'youtu.be' AND link NOT CONTAINS[c] 'youtu.be' ") } + val ratingQuerys = mutableListOf() + if (properties.contains(States.unrated.name)) ratingQuerys.add(" rating == ${Rating.UNRATED.code} ") + if (properties.contains(States.trash.name)) ratingQuerys.add(" rating == ${Rating.TRASH.code} ") + if (properties.contains(States.bad.name)) ratingQuerys.add(" rating == ${Rating.BAD.code} ") + if (properties.contains(States.neutral.name)) ratingQuerys.add(" rating == ${Rating.NEUTRAL.code} ") + if (properties.contains(States.good.name)) ratingQuerys.add(" rating == ${Rating.GOOD.code} ") + if (properties.contains(States.favorite.name)) ratingQuerys.add(" rating == ${Rating.FAVORITE.code} ") + if (ratingQuerys.isNotEmpty()) { + val query = StringBuilder(" (" + ratingQuerys[0]) + if (ratingQuerys.size > 1) for (r in statements.subList(1, ratingQuerys.size)) { + query.append(" OR ") + query.append(r) + } + query.append(") ") + statements.add(query.toString()) + } + + val audoDeleteQuerys = mutableListOf() + if (properties.contains(States.global_auto_delete.name)) audoDeleteQuerys.add(" preferences.autoDelete == ${FeedPreferences.AutoDeleteAction.GLOBAL.code} ") + if (properties.contains(States.always_auto_delete.name)) audoDeleteQuerys.add(" preferences.autoDelete == ${FeedPreferences.AutoDeleteAction.ALWAYS.code} ") + if (properties.contains(States.never_auto_delete.name)) audoDeleteQuerys.add(" preferences.playSpeed == ${FeedPreferences.AutoDeleteAction.NEVER.code} ") + if (audoDeleteQuerys.isNotEmpty()) { + val query = StringBuilder(" (" + audoDeleteQuerys[0]) + if (audoDeleteQuerys.size > 1) for (r in statements.subList(1, audoDeleteQuerys.size)) { + query.append(" OR ") + query.append(r) + } + query.append(") ") + Logd("FeedFilter", "audoDeleteQueues: ${query}") + statements.add(query.toString()) + } + when { + properties.contains(States.autoDownload.name) -> statements.add(" preferences.autoDownload == true ") + properties.contains(States.not_autoDownload.name) -> statements.add(" preferences.autoDownload == false ") + } if (statements.isEmpty()) return "id > 0" val query = StringBuilder(" (" + statements[0]) - for (r in statements.subList(1, statements.size)) { + if (statements.size > 1) for (r in statements.subList(1, statements.size)) { query.append(" AND ") query.append(r) } query.append(") ") - return query.toString() } @@ -99,13 +123,51 @@ class FeedFilter(vararg properties: String) : Serializable { no_skips, has_comments, no_comments, -// global_auto_delete, + has_video, + no_video, + youtube, + rss, + synthetic, + normal, + global_auto_delete, always_auto_delete, never_auto_delete, autoDownload, not_autoDownload, + unrated, + trash, + bad, + neutral, + good, + favorite, + } + + enum class FeedFilterGroup(val nameRes: Int, vararg values: ItemProperties) { + KEEP_UPDATED(R.string.keep_updated, ItemProperties(R.string.yes, States.keepUpdated.name), ItemProperties(R.string.no, States.not_keepUpdated.name)), + PLAY_SPEED(R.string.play_speed, ItemProperties(R.string.global_speed, States.global_playSpeed.name), ItemProperties(R.string.custom_speed, States.custom_playSpeed.name)), + OPINION(R.string.commented, ItemProperties(R.string.yes, States.has_comments.name), ItemProperties(R.string.no, States.no_comments.name)), + HAS_VIDEO(R.string.has_video, ItemProperties(R.string.yes, States.has_video.name), ItemProperties(R.string.no, States.no_video.name)), + ORIGIN(R.string.feed_origin, ItemProperties(R.string.youtube, States.youtube.name), ItemProperties(R.string.rss, States.rss.name)), + TYPE(R.string.feed_type, ItemProperties(R.string.synthetic, States.synthetic.name), ItemProperties(R.string.normal, States.normal.name)), + SKIPS(R.string.has_skips, ItemProperties(R.string.yes, States.has_skips.name), ItemProperties(R.string.no, States.no_skips.name)), + RATING(R.string.rating_label, ItemProperties(R.string.unrated, States.unrated.name), + ItemProperties(R.string.trash, States.trash.name), + ItemProperties(R.string.bad, States.bad.name), + ItemProperties(R.string.neutral, States.neutral.name), + ItemProperties(R.string.good, States.good.name), + ItemProperties(R.string.favorite, States.favorite.name), + ), + AUTO_DELETE(R.string.auto_delete, ItemProperties(R.string.always, States.always_auto_delete.name), + ItemProperties(R.string.never, States.never_auto_delete.name), + ItemProperties(R.string.global, States.global_auto_delete.name), ), + AUTO_DOWNLOAD(R.string.auto_download, ItemProperties(R.string.yes, States.autoDownload.name), ItemProperties(R.string.no, States.not_autoDownload.name)); + @JvmField + val values: Array = arrayOf(*values) + + class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String) } + companion object { @JvmStatic fun unfiltered(): FeedFilter { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt index 4a008fd0..1187a2ac 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt @@ -10,7 +10,7 @@ enum class MediaType { // "application/x-flac" // )) - private val AUDIO_APPLICATION_MIME_STRINGS: HashSet = hashSetOf( + val AUDIO_APPLICATION_MIME_STRINGS: HashSet = hashSetOf( "application/ogg", "application/opus", "application/x-flac" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index 5f2e6dcb..212b8207 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -121,3 +121,22 @@ fun LargeTextEditingDialog(textState: TextFieldValue, onTextChange: (TextFieldVa } } } + +@Composable +fun NonlazyGrid(columns: Int, itemCount: Int, modifier: Modifier = Modifier, content: @Composable() (Int) -> Unit) { + Column(modifier = modifier) { + var rows = (itemCount / columns) + if (itemCount.mod(columns) > 0) rows += 1 + for (rowId in 0 until rows) { + val firstIndex = rowId * columns + Row { + for (columnId in 0 until columns) { + val index = firstIndex + columnId + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + if (index < itemCount) content(index) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index 7e96cf8a..6ded64d8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -13,7 +13,6 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.status import ac.mdiq.podcini.storage.database.Episodes import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync import ac.mdiq.podcini.storage.database.Episodes.episodeFromStreamInfo -import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue import ac.mdiq.podcini.storage.database.Feeds.addToMiscSyndicate @@ -52,6 +51,7 @@ import android.net.Uri import android.text.format.Formatter import android.util.Log import android.util.TypedValue +import android.view.Gravity import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.* @@ -77,15 +77,19 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import androidx.constraintlayout.compose.ConstraintLayout import androidx.documentfile.provider.DocumentFile import coil.compose.AsyncImage @@ -261,21 +265,21 @@ fun PlayStateDialog(selected: List, onDismissRequest: () -> Unit) { } when (state) { PlayState.UNPLAYED -> { - if (isProviderConnected && item_?.feed?.isLocalFeed != true && item_?.media != null) { - val actionNew: EpisodeAction = EpisodeAction.Builder(item_!!, EpisodeAction.NEW).currentTimestamp().build() + if (isProviderConnected && item_.feed?.isLocalFeed != true && item_.media != null) { + val actionNew: EpisodeAction = EpisodeAction.Builder(item_, EpisodeAction.NEW).currentTimestamp().build() SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, actionNew) } } PlayState.PLAYED -> { - if (item_?.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) { - val media: EpisodeMedia? = item_?.media + if (item_.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) { + val media_: EpisodeMedia? = item_.media // not all items have media, Gpodder only cares about those that do - if (isProviderConnected && media != null) { - val actionPlay: EpisodeAction = EpisodeAction.Builder(item_!!, EpisodeAction.PLAY) + if (isProviderConnected && media_ != null) { + val actionPlay: EpisodeAction = EpisodeAction.Builder(item_, EpisodeAction.PLAY) .currentTimestamp() - .started(media.getDuration() / 1000) - .position(media.getDuration() / 1000) - .total(media.getDuration() / 1000) + .started(media_.getDuration() / 1000) + .position(media_.getDuration() / 1000) + .total(media_.getDuration() / 1000) .build() SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, actionPlay) } @@ -711,13 +715,13 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: List, feed: Feed? // Logd(TAG, "info row") val ratingIconRes = Rating.fromCode(vm.ratingState).res if (vm.ratingState != Rating.UNRATED.code) - Icon(imageVector = ImageVector.vectorResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(14.dp).height(14.dp)) + Icon(imageVector = ImageVector.vectorResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(18.dp).height(18.dp)) val playStateRes = PlayState.fromCode(vm.playedState).res - Icon(imageVector = ImageVector.vectorResource(playStateRes), tint = textColor, contentDescription = "playState", modifier = Modifier.width(14.dp).height(14.dp)) + Icon(imageVector = ImageVector.vectorResource(playStateRes), tint = textColor, contentDescription = "playState", modifier = Modifier.width(18.dp).height(18.dp)) if (vm.inQueueState) - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp)) + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(18.dp).height(18.dp)) if (vm.episode.media?.getMediaType() == MediaType.VIDEO) - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp)) + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(18.dp).height(18.dp)) val curContext = LocalContext.current val dur = remember { vm.episode.media?.getDuration() ?: 0 } val durText = remember { DurationConverter.getDurationStringLong(dur) } @@ -786,7 +790,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: List, feed: Feed? refreshing = false }) { LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - itemsIndexed(vms, key = {index, vm -> vm.episode.id}) { index, vm -> + itemsIndexed(vms, key = { _, vm -> vm.episode.id}) { index, vm -> vm.startMonitoring() DisposableEffect(Unit) { onDispose { @@ -927,3 +931,118 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List, showDialog: Boolean, onDi } } } + +@Composable +fun EpisodesFilterDialog(filter: EpisodeFilter? = null, filtersDisabled: MutableSet = mutableSetOf(), + onDismissRequest: () -> Unit, onFilterChanged: (Set) -> Unit) { + val filterValues: MutableSet = mutableSetOf() + + Dialog(properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { onDismissRequest() }) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM) + window.setDimAmount(0f) + } + Surface(modifier = Modifier.fillMaxWidth().height(500.dp), shape = RoundedCornerShape(16.dp)) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { + var selectNone by remember { mutableStateOf(false) } + for (item in EpisodeFilter.EpisodesFilterGroup.entries) { + if (item in filtersDisabled) continue + if (item.values.size == 2) { + Row(modifier = Modifier.padding(2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + var selectedIndex by remember { mutableStateOf(-1) } + if (selectNone) selectedIndex = -1 + LaunchedEffect(Unit) { + if (filter != null) { + if (item.values[0].filterId in filter.properties) selectedIndex = 0 + else if (item.values[1].filterId in filter.properties) selectedIndex = 1 + } + } + Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.padding(end = 10.dp)) + Spacer(Modifier.weight(0.3f)) + OutlinedButton( + modifier = Modifier.padding(0.dp), border = BorderStroke(2.dp, if (selectedIndex != 0) textColor else Color.Green), + onClick = { + if (selectedIndex != 0) { + selectNone = false + selectedIndex = 0 + filterValues.add(item.values[0].filterId) + filterValues.remove(item.values[1].filterId) + } else { + selectedIndex = -1 + filterValues.remove(item.values[0].filterId) + } + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[0].displayName), color = textColor) + } + Spacer(Modifier.weight(0.1f)) + OutlinedButton( + modifier = Modifier.padding(0.dp), border = BorderStroke(2.dp, if (selectedIndex != 1) textColor else Color.Green), + onClick = { + if (selectedIndex != 1) { + selectNone = false + selectedIndex = 1 + filterValues.add(item.values[1].filterId) + filterValues.remove(item.values[0].filterId) + } else { + selectedIndex = -1 + filterValues.remove(item.values[1].filterId) + } + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[1].displayName), color = textColor) + } + Spacer(Modifier.weight(0.5f)) + } + } else { + Column(modifier = Modifier.padding(start = 5.dp, bottom = 2.dp).fillMaxWidth()) { + Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor) + NonlazyGrid(columns = 3, itemCount = item.values.size) { index -> + var selected by remember { mutableStateOf(false) } + if (selectNone) selected = false + LaunchedEffect(Unit) { + if (filter != null) { + if (item.values[index].filterId in filter.properties) selected = true + } + } + OutlinedButton(modifier = Modifier.padding(0.dp).heightIn(min = 20.dp).widthIn(min = 20.dp).wrapContentWidth(), + border = BorderStroke(2.dp, if (selected) Color.Green else textColor), + onClick = { + selectNone = false + selected = !selected + if (selected) filterValues.add(item.values[index].filterId) + else filterValues.remove(item.values[index].filterId) + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[index].displayName), maxLines = 1, color = textColor) + } + } + } + } + } + Row { + Spacer(Modifier.weight(0.3f)) + Button(onClick = { + selectNone = true + onFilterChanged(setOf("")) + }) { + Text(stringResource(R.string.reset)) + } + Spacer(Modifier.weight(0.4f)) + Button(onClick = { + onDismissRequest() + }) { + Text(stringResource(R.string.close_label)) + } + Spacer(Modifier.weight(0.3f)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt index 1dcc7fb8..04b681b8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt @@ -52,7 +52,7 @@ fun ChooseRatingDialog(selected: List, onDismissRequest: () -> Unit) { Dialog(onDismissRequest = onDismissRequest) { Surface(shape = RoundedCornerShape(16.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - for (rating in Rating.entries) { + for (rating in Rating.entries.reversed()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { for (item in selected) Feeds.setRating(item, rating.code) onDismissRequest() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt index feb737b3..e9a0050f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt @@ -2,17 +2,17 @@ package ac.mdiq.podcini.ui.dialog import ac.mdiq.podcini.R import ac.mdiq.podcini.storage.model.EpisodeFilter -import ac.mdiq.podcini.storage.model.FeedFilter import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion.TAG -import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.FeedFilterDialog.FeedFilterGroup.ItemProperties -import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.ui.compose.NonlazyGrid import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -22,13 +22,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.google.android.material.bottomsheet.BottomSheetDialogFragment +// TODO: to be removed abstract class EpisodeFilterDialog : BottomSheetDialogFragment() { var filter: EpisodeFilter? = null - val filtersDisabled: MutableSet = mutableSetOf() + val filtersDisabled: MutableSet = mutableSetOf() private val filterValues: MutableSet = mutableSetOf() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -45,73 +47,101 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() { @Composable fun MainView() { val textColor = MaterialTheme.colorScheme.onSurface - Column { - for (item in FeedItemFilterGroup.entries) { + val scrollState = rememberScrollState() + Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { + var selectNone by remember { mutableStateOf(false) } + for (item in EpisodeFilter.EpisodesFilterGroup.entries) { if (item in filtersDisabled) continue - Row(modifier = Modifier.padding(2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - var selectedIndex by remember { mutableStateOf(-1) } - LaunchedEffect(Unit) { - if (filter != null) { - if (item.values[0].filterId in filter!!.values) selectedIndex = 0 - else if (item.values[1].filterId in filter!!.values) selectedIndex = 1 - } - } - OutlinedButton(modifier = Modifier.padding(2.dp), border = BorderStroke(2.dp, if (selectedIndex != 0) textColor else Color.Green), - onClick = { - if (selectedIndex != 0) { - selectedIndex = 0 - filterValues.add(item.values[0].filterId) - filterValues.remove(item.values[1].filterId) - } else { - selectedIndex = -1 - filterValues.remove(item.values[0].filterId) + if (item.values.size == 2) { + Row(modifier = Modifier.padding(2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + var selectedIndex by remember { mutableStateOf(-1) } + if (selectNone) selectedIndex = -1 + LaunchedEffect(Unit) { + if (filter != null) { + if (item.values[0].filterId in filter!!.properties) selectedIndex = 0 + else if (item.values[1].filterId in filter!!.properties) selectedIndex = 1 } - onFilterChanged(filterValues) - }, - ) { - Text(text = stringResource(item.values[0].displayName), color = textColor) + } + Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.padding(end = 10.dp)) + Spacer(Modifier.weight(0.3f)) + OutlinedButton( + modifier = Modifier.padding(2.dp), border = BorderStroke(2.dp, if (selectedIndex != 0) textColor else Color.Green), + onClick = { + if (selectedIndex != 0) { + selectNone = false + selectedIndex = 0 + filterValues.add(item.values[0].filterId) + filterValues.remove(item.values[1].filterId) + } else { + selectedIndex = -1 + filterValues.remove(item.values[0].filterId) + } + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[0].displayName), color = textColor) + } + Spacer(Modifier.weight(0.1f)) + OutlinedButton( + modifier = Modifier.padding(2.dp), border = BorderStroke(2.dp, if (selectedIndex != 1) textColor else Color.Green), + onClick = { + if (selectedIndex != 1) { + selectNone = false + selectedIndex = 1 + filterValues.add(item.values[1].filterId) + filterValues.remove(item.values[0].filterId) + } else { + selectedIndex = -1 + filterValues.remove(item.values[1].filterId) + } + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[1].displayName), color = textColor) + } + Spacer(Modifier.weight(0.5f)) } - Spacer(Modifier.width(5.dp)) - OutlinedButton(modifier = Modifier.padding(2.dp), border = BorderStroke(2.dp, if (selectedIndex != 1) textColor else Color.Green), - onClick = { - if (selectedIndex != 1) { - selectedIndex = 1 - filterValues.add(item.values[1].filterId) - filterValues.remove(item.values[0].filterId) - } else { - selectedIndex = -1 - filterValues.remove(item.values[1].filterId) + } else { + Column(modifier = Modifier.padding(start = 5.dp, bottom = 2.dp).fillMaxWidth()) { + Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor) + NonlazyGrid(columns = 3, itemCount = item.values.size) { index -> + var selected by remember { mutableStateOf(false) } + if (selectNone) selected = false + OutlinedButton(modifier = Modifier.padding(0.dp).heightIn(min = 20.dp).widthIn(min = 20.dp).wrapContentWidth(), + border = BorderStroke(2.dp, if (selected) Color.Green else textColor), + onClick = { + selectNone = false + selected = !selected + if (selected) filterValues.add(item.values[index].filterId) + else filterValues.remove(item.values[index].filterId) + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[index].displayName), maxLines = 1, color = textColor) } - onFilterChanged(filterValues) - }, - ) { - Text(text = stringResource(item.values[1].displayName), color = textColor) + } } } } + Row { + Spacer(Modifier.weight(0.3f)) + Button(onClick = { + selectNone = true + onFilterChanged(setOf("")) + }) { + Text(stringResource(R.string.reset)) + } + Spacer(Modifier.weight(0.4f)) + Button(onClick = { + dismiss() + }) { + Text(stringResource(R.string.close_label)) + } + Spacer(Modifier.weight(0.3f)) + } } } - override fun onDestroyView() { - Logd(TAG, "onDestroyView") - super.onDestroyView() - } - abstract fun onFilterChanged(newFilterValues: Set) - enum class FeedItemFilterGroup(vararg values: ItemProperties) { - PLAYED(ItemProperties(R.string.hide_played_episodes_label, EpisodeFilter.States.played.name), ItemProperties(R.string.not_played, EpisodeFilter.States.unplayed.name)), - PAUSED(ItemProperties(R.string.hide_paused_episodes_label, EpisodeFilter.States.paused.name), ItemProperties(R.string.not_paused, EpisodeFilter.States.not_paused.name)), - FAVORITE(ItemProperties(R.string.hide_is_favorite_label, EpisodeFilter.States.is_favorite.name), ItemProperties(R.string.not_favorite, EpisodeFilter.States.not_favorite.name)), - MEDIA(ItemProperties(R.string.has_media, EpisodeFilter.States.has_media.name), ItemProperties(R.string.no_media, EpisodeFilter.States.no_media.name)), - OPINION(ItemProperties(R.string.has_comments, EpisodeFilter.States.has_comments.name), ItemProperties(R.string.no_comments, EpisodeFilter.States.no_comments.name)), - QUEUED(ItemProperties(R.string.queued_label, EpisodeFilter.States.queued.name), ItemProperties(R.string.not_queued_label, EpisodeFilter.States.not_queued.name)), - DOWNLOADED(ItemProperties(R.string.downloaded_label, EpisodeFilter.States.downloaded.name), ItemProperties(R.string.not_downloaded_label, EpisodeFilter.States.not_downloaded.name)), - AUTO_DOWNLOADABLE(ItemProperties(R.string.auto_downloadable_label, EpisodeFilter.States.auto_downloadable.name), ItemProperties(R.string.not_auto_downloadable_label, EpisodeFilter.States.not_auto_downloadable.name)); - - @JvmField - val values: Array = arrayOf(*values) - - class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String) - } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt index dfcd666c..a969a70f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt @@ -9,7 +9,6 @@ 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.activity.MainActivity -import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog import ac.mdiq.podcini.util.EventFlow @@ -90,9 +89,12 @@ class AllEpisodesFragment : BaseEpisodesFragment() { if (super.onOptionsItemSelected(item)) return true when (item.itemId) { - R.id.filter_items -> AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) + R.id.filter_items -> { + showFilterDialog = true +// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) + } R.id.action_favorites -> { - val filter = ArrayList(getFilter().valuesList) + val filter = getFilter().properties.toMutableSet() if (filter.contains(EpisodeFilter.States.is_favorite.name)) filter.remove(EpisodeFilter.States.is_favorite.name) else filter.add(EpisodeFilter.States.is_favorite.name) onFilterChanged(FlowEvent.AllEpisodesFilterEvent(HashSet(filter))) @@ -135,7 +137,7 @@ class AllEpisodesFragment : BaseEpisodesFragment() { override fun updateToolbar() { swipeActions.setFilter(getFilter()) var info = "${episodes.size} episodes" - if (getFilter().values.isNotEmpty()) { + if (getFilter().properties.isNotEmpty()) { info += " - ${getString(R.string.filtered_label)}" emptyView.setMessage(R.string.no_all_episodes_filtered_label) } else emptyView.setMessage(R.string.no_all_episodes_label) @@ -143,6 +145,10 @@ class AllEpisodesFragment : BaseEpisodesFragment() { toolbar.menu?.findItem(R.id.action_favorites)?.setIcon(if (getFilter().showIsFavorite) R.drawable.ic_star else R.drawable.ic_star_border) } + override fun onFilterChanged(filterValues: Set) { + EventFlow.postEvent(FlowEvent.AllEpisodesFilterEvent(filterValues)) + } + class AllEpisodesSortDialog : EpisodeSortDialog() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -164,18 +170,18 @@ class AllEpisodesFragment : BaseEpisodesFragment() { } } - class AllEpisodesFilterDialog : EpisodeFilterDialog() { - override fun onFilterChanged(newFilterValues: Set) { - EventFlow.postEvent(FlowEvent.AllEpisodesFilterEvent(newFilterValues)) - } - companion object { - fun newInstance(filter: EpisodeFilter?): AllEpisodesFilterDialog { - val dialog = AllEpisodesFilterDialog() - dialog.filter = filter - return dialog - } - } - } +// class AllEpisodesFilterDialog : EpisodeFilterDialog() { +// override fun onFilterChanged(newFilterValues: Set) { +// EventFlow.postEvent(FlowEvent.AllEpisodesFilterEvent(newFilterValues)) +// } +// companion object { +// fun newInstance(filter: EpisodeFilter?): AllEpisodesFilterDialog { +// val dialog = AllEpisodesFilterDialog() +// dialog.filter = filter +// return dialog +// } +// } +// } companion object { val TAG = AllEpisodesFragment::class.simpleName ?: "Anonymous" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt index ebd45a98..aa9cda95 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt @@ -10,10 +10,7 @@ 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.CustomTheme -import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn -import ac.mdiq.podcini.ui.compose.EpisodeVM -import ac.mdiq.podcini.ui.compose.InforBar +import ac.mdiq.podcini.ui.compose.* import ac.mdiq.podcini.ui.utils.EmptyViewHandler import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent @@ -23,8 +20,10 @@ import android.util.Log import android.view.* 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.core.util.Pair import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -58,6 +57,7 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene val episodes = mutableListOf() private val vms = mutableStateListOf() + var showFilterDialog by mutableStateOf(false) @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) @@ -84,6 +84,9 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene lifecycle.addObserver(swipeActions) binding.mainView.setContent { CustomTheme(requireContext()) { + if (showFilterDialog) EpisodesFilterDialog(filter = getFilter(), onDismissRequest = { showFilterDialog = false } ) { + onFilterChanged(it) + } Column { InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()}) EpisodeLazyColumn( @@ -115,6 +118,8 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene return binding.root } + open fun onFilterChanged(filterValues: Set) {} + open fun createListAdaptor() {} override fun onStart() { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt index 03a9267b..4c83942b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt @@ -20,11 +20,7 @@ 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.CustomTheme -import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn -import ac.mdiq.podcini.ui.compose.EpisodeVM -import ac.mdiq.podcini.ui.compose.InforBar -import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog +import ac.mdiq.podcini.ui.compose.* import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog import ac.mdiq.podcini.ui.utils.EmptyViewHandler @@ -40,8 +36,10 @@ 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 androidx.media3.common.util.UnstableApi @@ -70,6 +68,7 @@ import java.util.* private var infoBarText = mutableStateOf("") private var leftActionState = mutableStateOf(NoActionSwipeAction()) private var rightActionState = mutableStateOf(NoActionSwipeAction()) + var showFilterDialog by mutableStateOf(false) private lateinit var toolbar: MaterialToolbar private lateinit var swipeActions: SwipeActions @@ -98,6 +97,11 @@ import java.util.* 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)) + } Column { InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()}) EpisodeLazyColumn(activity as MainActivity, vms = vms, @@ -158,7 +162,10 @@ import java.util.* @UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { - R.id.filter_items -> DownloadsFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) + R.id.filter_items -> { + showFilterDialog = true +// DownloadsFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) + } // R.id.action_download_logs -> DownloadLogFragment().show(childFragmentManager, null) R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance()) R.id.downloads_sort -> DownloadsSortDialog().show(childFragmentManager, "SortDialog") @@ -398,8 +405,8 @@ import java.util.* for (item in episodes) sizeMB += item.media?.size ?: 0 info += " • " + (sizeMB / 1000000) + " MB" } - Logd(TAG, "refreshInfoBar filter value: ${getFilter().values.size} ${getFilter().values.joinToString()}") - if (getFilter().values.size > 1) info += " - ${getString(R.string.filtered_label)}" + 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 } @@ -427,20 +434,20 @@ import java.util.* } } - class DownloadsFilterDialog : EpisodeFilterDialog() { - override fun onFilterChanged(newFilterValues: Set) { - EventFlow.postEvent(FlowEvent.DownloadsFilterEvent(newFilterValues)) - } - companion object { - fun newInstance(filter: EpisodeFilter?): DownloadsFilterDialog { - val dialog = DownloadsFilterDialog() - dialog.filter = filter - dialog.filtersDisabled.add(FeedItemFilterGroup.DOWNLOADED) - dialog.filtersDisabled.add(FeedItemFilterGroup.MEDIA) - return dialog - } - } - } +// class DownloadsFilterDialog : EpisodeFilterDialog() { +// override fun onFilterChanged(newFilterValues: Set) { +// EventFlow.postEvent(FlowEvent.DownloadsFilterEvent(newFilterValues)) +// } +// companion object { +// fun newInstance(filter: EpisodeFilter?): DownloadsFilterDialog { +// val dialog = DownloadsFilterDialog() +// dialog.filter = filter +// dialog.filtersDisabled.add(EpisodeFilter.EpisodesFilterGroup.DOWNLOADED) +// dialog.filtersDisabled.add(EpisodeFilter.EpisodesFilterGroup.MEDIA) +// return dialog +// } +// } +// } companion object { val TAG = DownloadsFragment::class.simpleName ?: "Anonymous" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index be7ddd51..706629e1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -19,7 +19,6 @@ 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.ui.dialog.CustomFeedNameDialog -import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog import ac.mdiq.podcini.ui.utils.TransitionEffect @@ -31,7 +30,6 @@ import android.speech.tts.TextToSpeech import android.util.Log import android.view.* import android.widget.Toast -import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -100,6 +98,7 @@ import java.util.concurrent.Semaphore private var filterButColor = mutableStateOf(Color.White) private var showRemoveFeedDialog by mutableStateOf(false) + var showFilterDialog by mutableStateOf(false) private val ioScope = CoroutineScope(Dispatchers.IO) private var onInit: Boolean = true @@ -133,9 +132,10 @@ import java.util.concurrent.Semaphore swipeActions = SwipeActions(this, TAG) fun filterClick() { if (enableFilter && feed != null) { - val dialog = FeedEpisodeFilterDialog(feed) - dialog.filter = feed!!.episodeFilter - dialog.show(childFragmentManager, null) + showFilterDialog = true +// val dialog = FeedEpisodeFilterDialog(feed) +// dialog.filter = feed!!.episodeFilter +// dialog.show(childFragmentManager, null) } } fun filterLongClick() { @@ -146,7 +146,8 @@ import java.util.concurrent.Semaphore val etmp = mutableListOf() if (enableFilter) { filterButColor.value = Color.White - val episodes_ = feed!!.episodes.filter { feed!!.episodeFilter.matches(it) } + val episodes_ = realm.query(Episode::class).query("feedId == ${feed!!.id}").query(feed!!.episodeFilter.queryString()).find() +// val episodes_ = feed!!.episodes.filter { feed!!.episodeFilter.matches(it) } etmp.addAll(episodes_) } else { filterButColor.value = Color.Red @@ -170,6 +171,17 @@ import java.util.concurrent.Semaphore // Make sure fragment is hidden before actually starting to delete requireActivity().supportFragmentManager.executePendingTransactions() } + if (showFilterDialog) EpisodesFilterDialog(filter = feed!!.episodeFilter, + filtersDisabled = mutableSetOf(EpisodeFilter.EpisodesFilterGroup.DOWNLOADED, EpisodeFilter.EpisodesFilterGroup.MEDIA), + onDismissRequest = { showFilterDialog = false } ) { filterValues -> + if (feed != null) { + Logd(TAG, "persist Episode Filter(): feedId = [${feed?.id}], filterValues = [$filterValues]") + runOnIOScope { + val feed_ = realm.query(Feed::class, "id == ${feed!!.id}").first().find() + if (feed_ != null) upsert(feed_) { it.preferences?.filterString = filterValues.joinToString() } + } + } + } Column { FeedEpisodesHeader(activity = (activity as MainActivity), filterButColor = filterButColor.value, filterClickCB = {filterClick()}, filterLongClickCB = {filterLongClick()}) InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = { @@ -455,7 +467,7 @@ import java.util.concurrent.Semaphore if (feed != null) { val pos: Int = ieMap[event.episode.id] ?: -1 if (pos >= 0) { - if (!filterOutEpisode(event.episode)) vms[pos].isPlayingState = event.isPlaying() + if (!isFilteredOut(event.episode)) vms[pos].isPlayingState = event.isPlaying() if (event.isPlaying()) upsertBlk(feed!!) { it.lastPlayed = Date().time } } } @@ -552,7 +564,7 @@ import java.util.concurrent.Semaphore infoTextFiltered = "" if (!feed?.preferences?.filterString.isNullOrEmpty()) { val filter: EpisodeFilter = feed!!.episodeFilter - if (filter.values.isNotEmpty()) { + if (filter.properties.isNotEmpty()) { infoTextFiltered = this.getString(R.string.filtered_label) // binding.header.txtvInformation.setOnClickListener { // val dialog = FeedEpisodeFilterDialog(feed) @@ -601,12 +613,16 @@ import java.util.concurrent.Semaphore while (loadItemsRunning) Thread.sleep(50) } - private fun filterOutEpisode(episode: Episode): Boolean { - if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty() && !feed!!.episodeFilter.matches(episode)) { - episodes.remove(episode) - ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } - ueMap = episodes.mapIndexedNotNull { index, episode_ -> episode_.media?.downloadUrl?.let { it to index } }.toMap() - return true + private fun isFilteredOut(episode: Episode): Boolean { + if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty()) { + val episodes_ = realm.query(Episode::class).query("feedId == ${feed!!.id}").query(feed!!.episodeFilter.queryString()).find() + if (!episodes_.contains(episode)) { + episodes.remove(episode) + ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } + ueMap = episodes.mapIndexedNotNull { index, episode_ -> episode_.media?.downloadUrl?.let { it to index } }.toMap() + return true + } + return false } return false } @@ -633,7 +649,8 @@ import java.util.concurrent.Semaphore Logd(TAG, "loadItems feed_.episodes.size: ${feed_.episodes.size}") val etmp = mutableListOf() if (enableFilter && !feed_.preferences?.filterString.isNullOrEmpty()) { - val episodes_ = feed_.episodes.filter { feed_.episodeFilter.matches(it) } + val episodes_ = realm.query(Episode::class).query("feedId == ${feed_.id}").query(feed_.episodeFilter.queryString()).find() +// val episodes_ = feed_.episodes.filter { feed_.episodeFilter.matches(it) } etmp.addAll(episodes_) } else etmp.addAll(feed_.episodes) val sortOrder = feed_.sortOrder @@ -700,17 +717,17 @@ import java.util.concurrent.Semaphore } } - class FeedEpisodeFilterDialog(val feed: Feed?) : EpisodeFilterDialog() { - @OptIn(UnstableApi::class) override fun onFilterChanged(newFilterValues: Set) { - if (feed != null) { - Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]") - runOnIOScope { - val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() - if (feed_ != null) upsert(feed_) { it.preferences?.filterString = newFilterValues.joinToString() } - } - } - } - } +// class FeedEpisodeFilterDialog(val feed: Feed?) : EpisodeFilterDialog() { +// @OptIn(UnstableApi::class) override fun onFilterChanged(newFilterValues: Set) { +// if (feed != null) { +// Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]") +// runOnIOScope { +// val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() +// if (feed_ != null) upsert(feed_) { it.preferences?.filterString = newFilterValues.joinToString() } +// } +// } +// } +// } class SingleFeedSortDialog(val feed: Feed?) : EpisodeSortDialog() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt index 0a7f0f9a..ec1a3087 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt @@ -125,7 +125,7 @@ import kotlin.math.min swipeActions.setFilter(getFilter()) var info = "${episodes.size} episodes" - if (getFilter().values.isNotEmpty()) { + if (getFilter().properties.isNotEmpty()) { info += " - ${getString(R.string.filtered_label)}" emptyView.setMessage(R.string.no_all_episodes_filtered_label) } else emptyView.setMessage(R.string.no_all_episodes_label) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index 39fd4073..ec6a0bc3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -1,7 +1,9 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.* +import ac.mdiq.podcini.databinding.DialogSwitchPreferenceBinding +import ac.mdiq.podcini.databinding.FragmentSubscriptionsBinding +import ac.mdiq.podcini.databinding.PlaybackSpeedFeedSettingDialogBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.playback.base.VideoMode import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter @@ -21,6 +23,7 @@ import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.FeedAutoDeleteOpt import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.NonlazyGrid import ac.mdiq.podcini.ui.compose.RemoveFeedDialog import ac.mdiq.podcini.ui.compose.Spinner import ac.mdiq.podcini.ui.dialog.CustomFeedNameDialog @@ -41,11 +44,10 @@ import android.content.Intent import android.net.Uri 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.* +import android.view.* +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.CompoundButton import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn @@ -72,8 +74,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -81,6 +83,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import androidx.constraintlayout.compose.ConstraintLayout import androidx.core.util.Consumer import androidx.fragment.app.Fragment @@ -90,8 +94,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.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.* @@ -129,6 +131,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { // private var feedList: MutableList = mutableListOf() private var feedListFiltered = mutableStateListOf() + var showFilterDialog by mutableStateOf(false) private var useGrid by mutableStateOf(null) private val useGridLayout by mutableStateOf(appPrefs.getBoolean(UserPreferences.Prefs.prefFeedGridLayout.name, false)) @@ -167,6 +170,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } binding.lazyColumn.setContent { CustomTheme(requireContext()) { + if (showFilterDialog) FilterDialog(FeedFilter(feedsFilter)) { showFilterDialog = false } LazyList() } } @@ -310,7 +314,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { @UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean { val itemId = item.itemId when (itemId) { - R.id.subscriptions_filter -> FeedFilterDialog.newInstance(FeedFilter(feedsFilter)).show(childFragmentManager, null) + R.id.subscriptions_filter -> { + showFilterDialog = true +// FeedFilterDialog.newInstance(FeedFilter(feedsFilter)).show(childFragmentManager, null) + } R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance()) R.id.subscriptions_sort -> FeedSortDialog().show(childFragmentManager, "FeedSortDialog") R.id.new_synth -> { @@ -506,9 +513,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { Spacer(Modifier.weight(1f)) Text(txtvInformation, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.clickable { if (feedsFilter.isNotEmpty()) { - val filter = FeedFilter(feedsFilter) - val dialog = FeedFilterDialog.newInstance(filter) - dialog.show(childFragmentManager, null) + showFilterDialog = true +// val filter = FeedFilter(feedsFilter) +// val dialog = FeedFilterDialog.newInstance(filter) +// dialog.show(childFragmentManager, null) } } ) Spacer(Modifier.weight(1f)) @@ -1074,6 +1082,124 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } catch (e: Exception) { Log.e(TAG, "exportOPML error: ${e.message}") } } + @Composable + fun FilterDialog(filter: FeedFilter? = null, onDismissRequest: () -> Unit) { + val filterValues: MutableSet = mutableSetOf() + + fun onFilterChanged(newFilterValues: Set) { + feedsFilter = StringUtils.join(newFilterValues, ",") + Logd(TAG, "onFilterChanged: $feedsFilter") + EventFlow.postEvent(FlowEvent.FeedsFilterEvent(newFilterValues)) + } + Dialog(properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { onDismissRequest() }) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM) + window.setDimAmount(0f) + } + Surface(modifier = Modifier.fillMaxWidth().height(500.dp), shape = RoundedCornerShape(16.dp)) { + val textColor = MaterialTheme.colorScheme.onSurface + val scrollState = rememberScrollState() + Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { + var selectNone by remember { mutableStateOf(false) } + for (item in FeedFilter.FeedFilterGroup.entries) { + if (item.values.size == 2) { + Row(modifier = Modifier.padding(start = 5.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Absolute.Left, verticalAlignment = Alignment.CenterVertically) { + var selectedIndex by remember { mutableStateOf(-1) } + if (selectNone) selectedIndex = -1 + LaunchedEffect(Unit) { + if (filter != null) { + if (item.values[0].filterId in filter!!.properties) selectedIndex = 0 + else if (item.values[1].filterId in filter!!.properties) selectedIndex = 1 + } + } + Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.padding(end = 10.dp)) + Spacer(Modifier.weight(0.3f)) + OutlinedButton( + modifier = Modifier.padding(0.dp).heightIn(min = 20.dp).widthIn(min = 20.dp), border = BorderStroke(2.dp, if (selectedIndex != 0) textColor else Color.Green), + onClick = { + if (selectedIndex != 0) { + selectNone = false + selectedIndex = 0 + filterValues.add(item.values[0].filterId) + filterValues.remove(item.values[1].filterId) + } else { + selectedIndex = -1 + filterValues.remove(item.values[0].filterId) + } + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[0].displayName), color = textColor) + } + Spacer(Modifier.weight(0.1f)) + OutlinedButton( + modifier = Modifier.padding(0.dp).heightIn(min = 20.dp).widthIn(min = 20.dp), border = BorderStroke(2.dp, if (selectedIndex != 1) textColor else Color.Green), + onClick = { + if (selectedIndex != 1) { + selectNone = false + selectedIndex = 1 + filterValues.add(item.values[1].filterId) + filterValues.remove(item.values[0].filterId) + } else { + selectedIndex = -1 + filterValues.remove(item.values[1].filterId) + } + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[1].displayName), color = textColor) + } + Spacer(Modifier.weight(0.5f)) + } + } else { + Column(modifier = Modifier.padding(start = 5.dp, bottom = 2.dp).fillMaxWidth()) { + Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor) + NonlazyGrid(columns = 3, itemCount = item.values.size) { index -> + var selected by remember { mutableStateOf(false) } + if (selectNone) selected = false + LaunchedEffect(Unit) { + if (filter != null) { + if (item.values[index].filterId in filter.properties) selected = true + } + } + OutlinedButton(modifier = Modifier.padding(0.dp).heightIn(min = 20.dp).widthIn(min = 20.dp).wrapContentWidth(), + border = BorderStroke(2.dp, if (selected) Color.Green else textColor), + onClick = { + selectNone = false + selected = !selected + if (selected) filterValues.add(item.values[index].filterId) + else filterValues.remove(item.values[index].filterId) + onFilterChanged(filterValues) + }, + ) { + Text(text = stringResource(item.values[index].displayName), maxLines = 1, color = textColor) + } + } + } + } + } + Row { + Spacer(Modifier.weight(0.3f)) + Button(onClick = { + selectNone = true + onFilterChanged(setOf("")) + }) { + Text(stringResource(R.string.reset)) + } + Spacer(Modifier.weight(0.4f)) + Button(onClick = { + onDismissRequest() + }) { + Text(stringResource(R.string.close_label)) + } + Spacer(Modifier.weight(0.3f)) + } + } + } + } + } + class PreferenceSwitchDialog(private var context: Context, private val title: String, private val text: String) { private var onPreferenceChangedListener: OnPreferenceChangedListener? = null interface OnPreferenceChangedListener { @@ -1105,104 +1231,136 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } - class FeedFilterDialog : BottomSheetDialogFragment() { - var filter: FeedFilter? = null - private val filterValues: MutableSet = mutableSetOf() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val composeView = ComposeView(requireContext()).apply { - setContent { - CustomTheme(requireContext()) { - MainView() - } - } - } - return composeView - } - - @Composable - fun MainView() { - val textColor = MaterialTheme.colorScheme.onSurface - Column { - for (item in FeedFilterGroup.entries) { - Row(modifier = Modifier.padding(2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - var selectedIndex by remember { mutableStateOf(-1) } - LaunchedEffect(Unit) { - if (filter != null) { - if (item.values[0].filterId in filter!!.values) selectedIndex = 0 - else if (item.values[1].filterId in filter!!.values) selectedIndex = 1 - } - } - OutlinedButton(modifier = Modifier.padding(2.dp), border = BorderStroke(2.dp, if (selectedIndex != 0) textColor else Color.Green), - onClick = { - if (selectedIndex != 0) { - selectedIndex = 0 - filterValues.add(item.values[0].filterId) - filterValues.remove(item.values[1].filterId) - } else { - selectedIndex = -1 - filterValues.remove(item.values[0].filterId) - } - onFilterChanged(filterValues) - }, - ) { - Text(text = stringResource(item.values[0].displayName), color = textColor) - } - Spacer(Modifier.width(5.dp)) - OutlinedButton(modifier = Modifier.padding(2.dp), border = BorderStroke(2.dp, if (selectedIndex != 1) textColor else Color.Green), - onClick = { - if (selectedIndex != 1) { - selectedIndex = 1 - filterValues.add(item.values[1].filterId) - filterValues.remove(item.values[0].filterId) - } else { - selectedIndex = -1 - filterValues.remove(item.values[1].filterId) - } - onFilterChanged(filterValues) - }, - ) { - Text(text = stringResource(item.values[1].displayName), color = textColor) - } - } - } - } - } - - override fun onDestroyView() { - Logd(TAG, "onDestroyView") -// _binding = null - super.onDestroyView() - } - - private fun onFilterChanged(newFilterValues: Set) { - feedsFilter = StringUtils.join(newFilterValues, ",") - Logd(TAG, "onFilterChanged: $feedsFilter") - EventFlow.postEvent(FlowEvent.FeedsFilterEvent(newFilterValues)) - } - - enum class FeedFilterGroup(vararg values: ItemProperties) { - KEEP_UPDATED(ItemProperties(R.string.keep_updated, FeedFilter.States.keepUpdated.name), ItemProperties(R.string.not_keep_updated, FeedFilter.States.not_keepUpdated.name)), - PLAY_SPEED(ItemProperties(R.string.global_speed, FeedFilter.States.global_playSpeed.name), ItemProperties(R.string.custom_speed, FeedFilter.States.custom_playSpeed.name)), - OPINION(ItemProperties(R.string.has_comments, FeedFilter.States.has_comments.name), ItemProperties(R.string.no_comments, FeedFilter.States.no_comments.name)), - SKIPS(ItemProperties(R.string.has_skips, FeedFilter.States.has_skips.name), ItemProperties(R.string.no_skips, FeedFilter.States.no_skips.name)), - AUTO_DELETE(ItemProperties(R.string.always_auto_delete, FeedFilter.States.always_auto_delete.name), ItemProperties(R.string.never_auto_delete, FeedFilter.States.never_auto_delete.name)), - AUTO_DOWNLOAD(ItemProperties(R.string.auto_download, FeedFilter.States.autoDownload.name), ItemProperties(R.string.not_auto_download, FeedFilter.States.not_autoDownload.name)); - - @JvmField - val values: Array = arrayOf(*values) - - class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String) - } - - companion object { - fun newInstance(filter: FeedFilter?): FeedFilterDialog { - val dialog = FeedFilterDialog() - dialog.filter = filter - return dialog - } - } - } +// class FeedFilterDialog : BottomSheetDialogFragment() { +// var filter: FeedFilter? = null +// private val filterValues: MutableSet = mutableSetOf() +// +// override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { +// val composeView = ComposeView(requireContext()).apply { +// setContent { +// CustomTheme(requireContext()) { +// MainView() +// } +// } +// } +// return composeView +// } +// +// @Composable +// fun MainView() { +// val textColor = MaterialTheme.colorScheme.onSurface +// val scrollState = rememberScrollState() +// Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { +// var selectNone by remember { mutableStateOf(false) } +// for (item in FeedFilter.FeedFilterGroup.entries) { +// if (item.values.size == 2) { +// Row(modifier = Modifier.padding(start = 5.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Absolute.Left, verticalAlignment = Alignment.CenterVertically) { +// var selectedIndex by remember { mutableStateOf(-1) } +// if (selectNone) selectedIndex = -1 +// LaunchedEffect(Unit) { +// if (filter != null) { +// if (item.values[0].filterId in filter!!.properties) selectedIndex = 0 +// else if (item.values[1].filterId in filter!!.properties) selectedIndex = 1 +// } +// } +// Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.padding(end = 10.dp)) +// Spacer(Modifier.weight(0.3f)) +// OutlinedButton( +// modifier = Modifier.padding(2.dp).heightIn(min = 20.dp).widthIn(min = 20.dp), border = BorderStroke(2.dp, if (selectedIndex != 0) textColor else Color.Green), +// onClick = { +// if (selectedIndex != 0) { +// selectNone = false +// selectedIndex = 0 +// filterValues.add(item.values[0].filterId) +// filterValues.remove(item.values[1].filterId) +// } else { +// selectedIndex = -1 +// filterValues.remove(item.values[0].filterId) +// } +// onFilterChanged(filterValues) +// }, +// ) { +// Text(text = stringResource(item.values[0].displayName), color = textColor) +// } +// Spacer(Modifier.weight(0.1f)) +// OutlinedButton( +// modifier = Modifier.padding(2.dp).heightIn(min = 20.dp).widthIn(min = 20.dp), border = BorderStroke(2.dp, if (selectedIndex != 1) textColor else Color.Green), +// onClick = { +// if (selectedIndex != 1) { +// selectNone = false +// selectedIndex = 1 +// filterValues.add(item.values[1].filterId) +// filterValues.remove(item.values[0].filterId) +// } else { +// selectedIndex = -1 +// filterValues.remove(item.values[1].filterId) +// } +// onFilterChanged(filterValues) +// }, +// ) { +// Text(text = stringResource(item.values[1].displayName), color = textColor) +// } +// Spacer(Modifier.weight(0.5f)) +// } +// } else { +// Column(modifier = Modifier.padding(start = 5.dp, bottom = 2.dp).fillMaxWidth()) { +// Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor) +// val lazyGridState = rememberLazyGridState() +// LazyVerticalGrid(state = lazyGridState, columns = GridCells.Adaptive(100.dp), +// verticalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)) { +// items(item.values.size) { index -> +// var selected by remember { mutableStateOf(false) } +// if (selectNone) selected = false +// OutlinedButton( +// modifier = Modifier.padding(2.dp).heightIn(min = 20.dp).widthIn(min = 20.dp).wrapContentWidth(), +// border = BorderStroke(2.dp, if (selected) Color.Green else textColor), +// onClick = { +// selectNone = false +// selected = !selected +// if (selected) filterValues.add(item.values[index].filterId) +// else filterValues.remove(item.values[index].filterId) +// onFilterChanged(filterValues) +// }, +// ) { +// Text(text = stringResource(item.values[index].displayName), maxLines = 1, color = textColor) +// } +// } +// } +// } +// } +// } +// Row { +// Spacer(Modifier.weight(0.3f)) +// Button(onClick = { +// selectNone = true +// onFilterChanged(setOf("")) +// }) { +// Text(stringResource(R.string.reset)) +// } +// Spacer(Modifier.weight(0.4f)) +// Button(onClick = { +// dismiss() +// }) { +// Text(stringResource(R.string.close_label)) +// } +// Spacer(Modifier.weight(0.3f)) +// } +// } +// } +// private fun onFilterChanged(newFilterValues: Set) { +// feedsFilter = StringUtils.join(newFilterValues, ",") +// Logd(TAG, "onFilterChanged: $feedsFilter") +// EventFlow.postEvent(FlowEvent.FeedsFilterEvent(newFilterValues)) +// } +// +// companion object { +// fun newInstance(filter: FeedFilter?): FeedFilterDialog { +// val dialog = FeedFilterDialog() +// dialog.filter = filter +// return dialog +// } +// } +// } companion object { val TAG = SubscriptionsFragment::class.simpleName ?: "Anonymous" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7203a0f6..73af9089 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -294,7 +294,31 @@ Add to favorites Swtich rating Toggle favorites + Media type + Unknown + Audio + Video + Audio app + Rating + Unrated + Trash + Bad + Neutral + Good + Favorite Set rating + Play state + Unspecified + Building + New + Unplayed + Later + Soon + In queue + In progress + Skipped + Played + Ignored Remove from favorites Visit website Skip episode @@ -891,6 +915,7 @@ Queued Not queued Has media + Has chapters No media Paused Not paused @@ -899,16 +924,25 @@ File name Not keep updated - Global play speed - Custom play speed + Play speed + Global + Custom Skips set No Skips set + Commented + Feed type + Synthetic + Normal + Feed origin + Youtube + RSS + Has video Has commented Not commented - Always auto delete - Never auto delete - Auto download enabled - Auto download disabled + Auto delete + Always + Never + Auto download Include playback position diff --git a/changelog.md b/changelog.md index 851c5749..72eb835d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +# 6.12.3 + +* reworked and expanded the filters routines for episodes and feeds + * various criteria have been added + * if a group of criteria has two options, they are mutually exclusive + * if there are more options in a group, multi-select is allowed and filter uses OR for the intra-group selections + * on selections across groups, the filter uses AND + # 6.12.2 * fixed play not resuming after interruption (watch for any side effects) diff --git a/fastlane/metadata/android/en-US/changelogs/3020281.txt b/fastlane/metadata/android/en-US/changelogs/3020281.txt new file mode 100644 index 00000000..c6cc7043 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020281.txt @@ -0,0 +1,7 @@ + Version 6.12.3 + +* reworked and expanded the filters routines for episodes and feeds + * various criteria have been added + * if a group of criteria has two options, they are mutually exclusive + * if there are more options in a group, multi-select is allowed and filter uses OR for the intra-group selections + * on selections across groups, the filter uses AND