6.1.2 commit
This commit is contained in:
parent
d47bdab971
commit
3c2618a29a
|
@ -24,8 +24,6 @@ Apache License 2.0
|
|||
|
||||
[com.squareup.okhttp3](https://github.com/square/okhttp/blob/master/LICENSE.txt) Apache License 2.0
|
||||
|
||||
[//]: # ([io.reactivex.rxjava2](https://github.com/ReactiveX/RxJava/blob/3.x/LICENSE) Apache License 2.0)
|
||||
|
||||
[com.mikepenz:iconics-core](https://github.com/mikepenz/Android-Iconics/blob/develop/LICENSE) Apache License 2.0
|
||||
|
||||
[com.leinardi.android](https://github.com/leinardi/FloatingActionButtonSpeedDial/blob/release/LICENSE) Apache License 2.0
|
||||
|
|
10
README.md
10
README.md
|
@ -38,7 +38,6 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
|
||||
* More convenient player control displayed on all pages
|
||||
* Revamped and more efficient expanded player view showing episode description on the front
|
||||
* External player class is merged into the player
|
||||
* Playback speed setting has been straightened up, three speed can be set separately or combined: current audio, podcast, and global
|
||||
* Added preference "Fast Forward Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a number between 0.0 and 10.0
|
||||
* The "Skip to next episode" button on the player
|
||||
|
@ -50,9 +49,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* single tap not during play has no effect
|
||||
* Added preference "Fallback Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 1.5)
|
||||
* if the user customizes "Fallback speed" to a value greater than 0.1, long-press the Play button during play enters the fallback mode and plays at the set fallback speed, single tap exits the fallback mode
|
||||
* Various efficiency improvements, including removal of:
|
||||
* redundant media loadings and ui updates
|
||||
* frequent list search during audio play
|
||||
* Various efficiency improvements
|
||||
* streamed media somewhat equivalent to downloaded media
|
||||
* enabled episode description on player detailed view
|
||||
* enabled intro- and end- skipping
|
||||
|
@ -77,6 +74,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* 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
|
||||
* Subscriptions sorting is now bi-directional based on various explicit measures
|
||||
* 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)
|
||||
* Long-press filter button in FeedEpisode view enables/disables filters without changing filter settings
|
||||
|
@ -106,8 +104,6 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* Ability to open podcast from webpage address
|
||||
* Online feed info display is handled in similar ways as any local feed, and offers options to subscribe or view episodes
|
||||
* Online feed episodes can be freely played (streamed) without a subscription
|
||||
* externally shared feed opens in the new online feed view fragment
|
||||
* OnlineFeedView` activity is stripped down to only receive externally shared feeds
|
||||
* Youtube channels are accepted from external share or paste of address in podcast search view, and can be subscribed as a normal podcast, though video play is handled externally
|
||||
|
||||
### Instant (or Wifi) sync
|
||||
|
@ -122,6 +118,8 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* When auto download is enabled in the Settings, feeds to be auto-downloaded need to be separately enabled in the feed settings.
|
||||
* Each feed also has its own download policy (only new episodes, newest episodes, and oldest episodes. "newest episodes" meaning most recent episodes, new or old)
|
||||
* Each feed has its own limit (Episode cache) for number of episodes downloaded, this limit rules in combination of the overall limit for the app.
|
||||
* Auto downloads run feeds or feed refreshes, scheduled or manual
|
||||
* auto download always includes any undownloaded episodes (regardless of feeds) added in the current queue
|
||||
* After auto download run, episodes with New status is changed to Unplayed.
|
||||
* auto download feed setting dialog is also changed:
|
||||
* there are now separate dialogs for inclusive and exclusive filters where filter tokens can be specified independently
|
||||
|
|
|
@ -126,8 +126,8 @@ android {
|
|||
buildConfig true
|
||||
}
|
||||
defaultConfig {
|
||||
versionCode 3020215
|
||||
versionName "6.1.1"
|
||||
versionCode 3020216
|
||||
versionName "6.1.2"
|
||||
|
||||
applicationId "ac.mdiq.podcini.R"
|
||||
def commit = ""
|
||||
|
|
|
@ -7,6 +7,7 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadRequest
|
|||
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
|
||||
import ac.mdiq.podcini.net.sync.model.EpisodeAction
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink.needSynch
|
||||
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileEpisodeDownload
|
||||
import ac.mdiq.podcini.preferences.UserPreferences
|
||||
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
|
||||
|
@ -83,7 +84,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
val workInfoList = future.get() // Wait for the completion of the future operation and retrieve the result
|
||||
workInfoList.forEach { workInfo ->
|
||||
if (workInfo.tags.contains(WORK_DATA_WAS_QUEUED)) {
|
||||
if (media.episode != null) Queues.removeFromQueue(null, media.episode!!)
|
||||
if (media.episode != null) Queues.removeFromQueue(media.episode!!)
|
||||
}
|
||||
}
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(tag)
|
||||
|
@ -383,7 +384,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
Log.e(TAG, "ExecutionException in MediaHandlerThread: " + e.message)
|
||||
updatedStatus = DownloadResult(media.id, media.getEpisodeTitle(), DownloadError.ERROR_DB_ACCESS_ERROR, false, e.message?:"")
|
||||
}
|
||||
if (item != null) {
|
||||
if (needSynch() && item != null) {
|
||||
val action = EpisodeAction.Builder(item, EpisodeAction.DOWNLOAD)
|
||||
.currentTimestamp()
|
||||
.build()
|
||||
|
|
|
@ -356,15 +356,9 @@ object FeedUpdateManager {
|
|||
private const val serialVersionUID = 1L
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = FeedParserTask::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
class FeedSyncTask(private val context: Context, request: DownloadRequest) {
|
||||
// var savedFeed: Feed? = null
|
||||
// private set
|
||||
private val task = FeedParserTask(request)
|
||||
private var feedHandlerResult: FeedHandlerResult? = null
|
||||
val downloadStatus: DownloadResult
|
||||
|
@ -379,9 +373,5 @@ object FeedUpdateManager {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = FeedUpdateWorker::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -287,7 +287,7 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont
|
|||
// if (result.first != null) queueToBeRemoved.add(result.second)
|
||||
updatedItems.add(result.second)
|
||||
}
|
||||
removeFromQueue(null, *updatedItems.toTypedArray())
|
||||
removeFromQueue(*updatedItems.toTypedArray())
|
||||
// loadAdditionalFeedItemListData(updatedItems)
|
||||
persistEpisodes(updatedItems)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,10 @@ object SynchronizationQueueSink {
|
|||
// To avoid a dependency loop of every class to SyncService, and from SyncService back to every class.
|
||||
private var serviceStarterImpl = Runnable {}
|
||||
|
||||
fun needSynch() : Boolean {
|
||||
return isProviderConnected
|
||||
}
|
||||
|
||||
fun setServiceStarterImpl(serviceStarter: Runnable) {
|
||||
serviceStarterImpl = serviceStarter
|
||||
}
|
||||
|
@ -57,9 +61,9 @@ object SynchronizationQueueSink {
|
|||
|
||||
fun enqueueEpisodePlayedIfSyncActive(context: Context, media: EpisodeMedia, completed: Boolean) {
|
||||
if (!isProviderConnected) return
|
||||
|
||||
if (media.episode?.feed == null || media.episode!!.feed!!.isLocalFeed) return
|
||||
if (media.startPosition < 0 || (!completed && media.startPosition >= media.getPosition())) return
|
||||
|
||||
val action = EpisodeAction.Builder(media.episode!!, EpisodeAction.PLAY)
|
||||
.currentTimestamp()
|
||||
.started(media.startPosition / 1000)
|
||||
|
|
|
@ -3,6 +3,7 @@ package ac.mdiq.podcini.playback.base
|
|||
import ac.mdiq.podcini.playback.service.PlaybackService
|
||||
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeMedia
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.realm
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.upsert
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
|
||||
import ac.mdiq.podcini.storage.model.*
|
||||
|
@ -45,7 +46,7 @@ object InTheatre {
|
|||
Logd(TAG, "starting curQueue")
|
||||
var curQueue_ = realm.query(PlayQueue::class).sort("updated", Sort.DESCENDING).first().find()
|
||||
if (curQueue_ != null) {
|
||||
curQueue = realm.copyFromRealm(curQueue_)
|
||||
curQueue = unmanaged(curQueue_)
|
||||
curQueue.episodes.addAll(realm.copyFromRealm(realm.query(Episode::class, "id IN $0", curQueue.episodeIds)
|
||||
.find().sortedBy { curQueue.episodeIds.indexOf(it.id) }))
|
||||
}
|
||||
|
@ -68,7 +69,7 @@ object InTheatre {
|
|||
|
||||
Logd(TAG, "starting curState")
|
||||
var curState_ = realm.query(CurrentState::class).first().find()
|
||||
if (curState_ != null) curState = realm.copyFromRealm(curState_)
|
||||
if (curState_ != null) curState = unmanaged(curState_)
|
||||
else {
|
||||
Logd(TAG, "creating new curState")
|
||||
curState_ = CurrentState()
|
||||
|
|
|
@ -312,7 +312,7 @@ class PlaybackService : MediaSessionService() {
|
|||
(action == AutoDeleteAction.GLOBAL && item?.feed != null && shouldAutoDeleteItem(item!!.feed!!)))
|
||||
if (playable is EpisodeMedia && shouldAutoDelete && (item?.isFavorite != true || !shouldFavoriteKeepEpisode())) {
|
||||
item = deleteMediaSync(this@PlaybackService, item!!)
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item!!)
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item!!)
|
||||
}
|
||||
}
|
||||
if (playable is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item!!)
|
||||
|
|
|
@ -43,6 +43,8 @@ object UserPreferences {
|
|||
prefDefaultPage,
|
||||
prefBackButtonOpensDrawer,
|
||||
|
||||
prefFeedFilter,
|
||||
|
||||
prefQueueKeepSorted,
|
||||
prefQueueKeepSortedOrder,
|
||||
prefDownloadSortedOrder,
|
||||
|
|
|
@ -23,8 +23,9 @@ import java.util.*
|
|||
import java.util.concurrent.ExecutionException
|
||||
|
||||
object AutoCleanups {
|
||||
private val TAG: String = AutoCleanups::class.simpleName ?: "Anonymous"
|
||||
|
||||
var episodeCleanupValue: Int
|
||||
private var episodeCleanupValue: Int
|
||||
get() = appPrefs.getString(UserPreferences.Prefs.prefEpisodeCleanup.name, "" + EPISODE_CLEANUP_NULL)!!.toInt()
|
||||
set(episodeCleanupValue) {
|
||||
appPrefs.edit().putString(UserPreferences.Prefs.prefEpisodeCleanup.name, episodeCleanupValue.toString()).apply()
|
||||
|
@ -102,9 +103,6 @@ object AutoCleanups {
|
|||
}
|
||||
return 0
|
||||
}
|
||||
companion object {
|
||||
private val TAG: String = ExceptFavoriteCleanupAlgorithm::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -154,9 +152,6 @@ object AutoCleanups {
|
|||
public override fun getDefaultCleanupParameter(): Int {
|
||||
return getNumEpisodesToCleanup(0)
|
||||
}
|
||||
companion object {
|
||||
private val TAG: String = APQueueCleanupAlgorithm::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -174,9 +169,6 @@ object AutoCleanups {
|
|||
override fun getReclaimableItems(): Int {
|
||||
return 0
|
||||
}
|
||||
companion object {
|
||||
private val TAG: String = APNullCleanupAlgorithm::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
/** the number of days after playback to wait before an item is eligible to be cleaned up.
|
||||
|
@ -232,7 +224,6 @@ object AutoCleanups {
|
|||
return getNumEpisodesToCleanup(0)
|
||||
}
|
||||
companion object {
|
||||
private val TAG: String = APCleanupAlgorithm::class.simpleName ?: "Anonymous"
|
||||
private fun minusHours(baseDate: Date, numberOfHours: Int): Date {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.time = baseDate
|
||||
|
|
|
@ -127,9 +127,6 @@ object AutoDownloads {
|
|||
val status = batteryStatus!!.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
return (status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL)
|
||||
}
|
||||
companion object {
|
||||
private val TAG: String = AutoDownloadAlgorithm::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
class FeedBasedAutoDLAlgorithm : AutoDownloadAlgorithm() {
|
||||
|
@ -141,10 +138,10 @@ object AutoDownloads {
|
|||
val networkShouldAutoDl = (isAutoDownloadAllowed && isEnableAutodownload)
|
||||
// true if we should auto download based on power status
|
||||
val powerShouldAutoDl = (deviceCharging(context) || isEnableAutodownloadOnBattery)
|
||||
Logd(Companion.TAG, "autoDownloadEpisodeMedia prepare $networkShouldAutoDl $powerShouldAutoDl")
|
||||
Logd(TAG, "autoDownloadEpisodeMedia prepare $networkShouldAutoDl $powerShouldAutoDl")
|
||||
// we should only auto download if both network AND power are happy
|
||||
if (networkShouldAutoDl && powerShouldAutoDl) {
|
||||
Logd(Companion.TAG, "autoDownloadEpisodeMedia Performing auto-dl of undownloaded episodes")
|
||||
Logd(TAG, "autoDownloadEpisodeMedia Performing auto-dl of undownloaded episodes")
|
||||
val candidates: MutableSet<Episode> = mutableSetOf()
|
||||
val queueItems = realm.query(Episode::class).query("id IN $0 AND media.downloaded == false", curQueue.episodeIds).find()
|
||||
Logd(TAG, "autoDownloadEpisodeMedia add from queue: ${queueItems.size}")
|
||||
|
@ -155,6 +152,7 @@ object AutoDownloads {
|
|||
var episodes = mutableListOf<Episode>()
|
||||
val downloadedCount = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name), f.id)
|
||||
val allowedDLCount = (f.preferences?.autoDLMaxEpisodes?:0) - downloadedCount
|
||||
Logd(TAG, "autoDownloadEpisodeMedia ${f.preferences?.autoDLMaxEpisodes} downloadedCount: $downloadedCount allowedDLCount: $allowedDLCount")
|
||||
if (allowedDLCount > 0) {
|
||||
var queryString = "feedId == ${f.id} AND isAutoDownloadEnabled == true AND media != nil AND media.downloaded == false"
|
||||
when (f.preferences?.autoDLPolicy) {
|
||||
|
@ -225,8 +223,5 @@ object AutoDownloads {
|
|||
else Logd(TAG, "not auto downloaded networkShouldAutoDl: $networkShouldAutoDl powerShouldAutoDl $powerShouldAutoDl")
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
private val TAG: String = FeedBasedAutoDLAlgorithm::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
|
|||
import ac.mdiq.podcini.net.feed.LocalFeedUpdater.updateFeed
|
||||
import ac.mdiq.podcini.net.sync.model.EpisodeAction
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink.needSynch
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curState
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
|
||||
|
@ -99,7 +100,7 @@ object Episodes {
|
|||
return runOnIOScope {
|
||||
if (episode.media == null) return@runOnIOScope
|
||||
val episode_ = deleteMediaSync(context, episode)
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, episode_)
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, episode_)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -158,9 +159,11 @@ object Episodes {
|
|||
// Do full update of this feed to get rid of the episode
|
||||
if (episode.feed != null) updateFeed(episode.feed!!, context.applicationContext, null)
|
||||
} else {
|
||||
// Gpodder: queue delete action for synchronization
|
||||
val action = EpisodeAction.Builder(episode, EpisodeAction.DELETE).currentTimestamp().build()
|
||||
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, action)
|
||||
if (needSynch()) {
|
||||
// Gpodder: queue delete action for synchronization
|
||||
val action = EpisodeAction.Builder(episode, EpisodeAction.DELETE).currentTimestamp().build()
|
||||
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, action)
|
||||
}
|
||||
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode))
|
||||
}
|
||||
return episode
|
||||
|
@ -292,6 +295,6 @@ object Episodes {
|
|||
}
|
||||
|
||||
private fun shouldRemoveFromQueuesMarkPlayed(): Boolean {
|
||||
return appPrefs.getBoolean(UserPreferences.Prefs.prefRemoveFromQueueMarkedPlayed.name, true)
|
||||
return appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true)
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import ac.mdiq.podcini.BuildConfig
|
|||
import ac.mdiq.podcini.net.download.DownloadError
|
||||
import ac.mdiq.podcini.net.sync.model.EpisodeAction
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink.needSynch
|
||||
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDelete
|
||||
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDeleteLocal
|
||||
import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes
|
||||
|
@ -39,8 +40,11 @@ object Feeds {
|
|||
private val tags: MutableList<String> = mutableListOf()
|
||||
|
||||
@Synchronized
|
||||
fun getFeedList(fromDB: Boolean = true): List<Feed> {
|
||||
if (fromDB) return realm.query(Feed::class).find()
|
||||
fun getFeedList(queryString: String = "", fromDB: Boolean = true): List<Feed> {
|
||||
if (fromDB) {
|
||||
return if (queryString.isEmpty()) realm.query(Feed::class).find()
|
||||
else realm.query(Feed::class, queryString).find()
|
||||
}
|
||||
return feedMap.values.toList()
|
||||
}
|
||||
|
||||
|
@ -278,7 +282,7 @@ object Feeds {
|
|||
""".trimIndent()))
|
||||
oldItem.identifier = episode.identifier
|
||||
|
||||
if (oldItem.isPlayed() && oldItem.media != null) {
|
||||
if (needSynch() && oldItem.isPlayed() && oldItem.media != null) {
|
||||
val durs = oldItem.media!!.getDuration() / 1000
|
||||
val action = EpisodeAction.Builder(oldItem, EpisodeAction.PLAY)
|
||||
.currentTimestamp()
|
||||
|
|
|
@ -15,7 +15,6 @@ import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
|
|||
import ac.mdiq.podcini.util.Logd
|
||||
import ac.mdiq.podcini.util.event.EventFlow
|
||||
import ac.mdiq.podcini.util.event.FlowEvent
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
@ -29,6 +28,60 @@ object Queues {
|
|||
BACK, FRONT, AFTER_CURRENTLY_PLAYING, RANDOM
|
||||
}
|
||||
|
||||
var isQueueLocked: Boolean
|
||||
get() = appPrefs.getBoolean(UserPreferences.Prefs.prefQueueLocked.name, false)
|
||||
set(locked) {
|
||||
appPrefs.edit().putBoolean(UserPreferences.Prefs.prefQueueLocked.name, locked).apply()
|
||||
}
|
||||
|
||||
var isQueueKeepSorted: Boolean
|
||||
/**
|
||||
* Returns if the queue is in keep sorted mode.
|
||||
* @see .queueKeepSortedOrder
|
||||
*/
|
||||
get() = appPrefs.getBoolean(UserPreferences.Prefs.prefQueueKeepSorted.name, false)
|
||||
/**
|
||||
* Enables/disables the keep sorted mode of the queue.
|
||||
* @see .queueKeepSortedOrder
|
||||
*/
|
||||
set(keepSorted) {
|
||||
appPrefs.edit().putBoolean(UserPreferences.Prefs.prefQueueKeepSorted.name, keepSorted).apply()
|
||||
}
|
||||
|
||||
var queueKeepSortedOrder: EpisodeSortOrder?
|
||||
/**
|
||||
* Returns the sort order for the queue keep sorted mode.
|
||||
* Note: This value is stored independently from the keep sorted state.
|
||||
* @see .isQueueKeepSorted
|
||||
*/
|
||||
get() {
|
||||
val sortOrderStr = appPrefs.getString(UserPreferences.Prefs.prefQueueKeepSortedOrder.name, "use-default")
|
||||
return EpisodeSortOrder.parseWithDefault(sortOrderStr, EpisodeSortOrder.DATE_NEW_OLD)
|
||||
}
|
||||
/**
|
||||
* Sets the sort order for the queue keep sorted mode.
|
||||
* @see .setQueueKeepSorted
|
||||
*/
|
||||
set(sortOrder) {
|
||||
if (sortOrder == null) return
|
||||
appPrefs.edit().putString(UserPreferences.Prefs.prefQueueKeepSortedOrder.name, sortOrder.name).apply()
|
||||
}
|
||||
|
||||
var enqueueLocation: EnqueueLocation
|
||||
get() {
|
||||
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)
|
||||
return EnqueueLocation.BACK
|
||||
}
|
||||
}
|
||||
set(location) {
|
||||
appPrefs.edit().putString(UserPreferences.Prefs.prefEnqueueLocation.name, location.name).apply()
|
||||
}
|
||||
|
||||
fun getInQueueEpisodeIds(): Set<Long> {
|
||||
Logd(TAG, "getQueueIDList() called")
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
|
@ -89,24 +142,25 @@ object Queues {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun addToQueueSync(markAsUnplayed: Boolean, episode: Episode) {
|
||||
suspend fun addToQueueSync(markAsUnplayed: Boolean, episode: Episode, queue_: PlayQueue? = null) {
|
||||
Logd(TAG, "addToQueueSync( ... ) called")
|
||||
|
||||
val queue = queue_ ?: curQueue
|
||||
val currentlyPlaying = curMedia
|
||||
val positionCalculator = EnqueuePositionCalculator(enqueueLocation)
|
||||
var insertPosition = positionCalculator.calcPosition(curQueue.episodes, currentlyPlaying)
|
||||
var insertPosition = positionCalculator.calcPosition(queue.episodes, currentlyPlaying)
|
||||
|
||||
if (curQueue.episodeIds.contains(episode.id)) return
|
||||
if (queue.episodeIds.contains(episode.id)) return
|
||||
|
||||
curQueue.episodeIds.add(insertPosition, episode.id)
|
||||
curQueue.episodes.add(insertPosition, episode)
|
||||
queue.episodeIds.add(insertPosition, episode.id)
|
||||
queue.episodes.add(insertPosition, episode)
|
||||
insertPosition++
|
||||
curQueue.update()
|
||||
upsert(curQueue) {}
|
||||
queue.update()
|
||||
upsert(queue) {}
|
||||
|
||||
if (markAsUnplayed && episode.isNew) setPlayState(Episode.UNPLAYED, false, episode)
|
||||
|
||||
EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, insertPosition))
|
||||
if (queue_?.id == curQueue.id) EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, insertPosition))
|
||||
// if (performAutoDownload) autodownloadEpisodeMedia(context)
|
||||
}
|
||||
|
||||
|
@ -147,13 +201,11 @@ object Queues {
|
|||
|
||||
/**
|
||||
* Removes a Episode object from the queue.
|
||||
* @param context A context that is used for opening a database connection.
|
||||
* perform autodownloadEpisodeMedia only if context is not null
|
||||
* @param episodes FeedItems that should be removed.
|
||||
*/
|
||||
@OptIn(UnstableApi::class) @JvmStatic
|
||||
fun removeFromQueue(context: Context?, vararg episodes: Episode) : Job {
|
||||
return runOnIOScope { removeFromQueueSync(curQueue, context, *episodes) }
|
||||
fun removeFromQueue(vararg episodes: Episode) : Job {
|
||||
return runOnIOScope { removeFromQueueSync(curQueue, *episodes) }
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
|
@ -161,22 +213,21 @@ object Queues {
|
|||
Logd(TAG, "removeFromAllQueues called ")
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
for (q in queues) {
|
||||
if (q.id != curQueue.id) removeFromQueueSync(q, null, *episodes)
|
||||
if (q.id != curQueue.id) removeFromQueueSync(q, *episodes)
|
||||
}
|
||||
// ensure curQueue is last updated
|
||||
removeFromQueueSync(curQueue, null, *episodes)
|
||||
removeFromQueueSync(curQueue, *episodes)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param queue_ if null, use curQueue
|
||||
* @param context perform autodownloadEpisodeMedia only if context is not null and queue_ is curQueue
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun removeFromQueueSync(queue_: PlayQueue?, context: Context?, vararg episodes: Episode) {
|
||||
internal fun removeFromQueueSync(queue_: PlayQueue?, vararg episodes: Episode) {
|
||||
Logd(TAG, "removeFromQueueSync called ")
|
||||
if (episodes.isEmpty()) return
|
||||
|
||||
val queue = queue_ ?: curQueue
|
||||
var queue = queue_ ?: curQueue
|
||||
val events: MutableList<FlowEvent.QueueEvent> = ArrayList()
|
||||
val pos: MutableList<Int> = mutableListOf()
|
||||
val qItems = queue.episodes.toMutableList()
|
||||
|
@ -200,9 +251,6 @@ object Queues {
|
|||
}
|
||||
for (event in events) EventFlow.postEvent(event)
|
||||
} else Logd(TAG, "Queue was not modified by call to removeQueueItem")
|
||||
|
||||
// TODO: what's this for?
|
||||
// if (queue.id == curQueue.id && context != null) autodownloadEpisodeMedia(context)
|
||||
}
|
||||
|
||||
suspend fun removeFromAllQueuesQuiet(episodeIds: List<Long>) {
|
||||
|
@ -214,10 +262,11 @@ object Queues {
|
|||
eidsInQueues = q.episodeIds.intersect(episodeIds.toSet()).toMutableSet()
|
||||
if (eidsInQueues.isNotEmpty()) {
|
||||
val qeids = q.episodeIds.minus(eidsInQueues)
|
||||
q.episodeIds.clear()
|
||||
q.episodeIds.addAll(qeids)
|
||||
q.update()
|
||||
upsert(q) {}
|
||||
upsert(q) {
|
||||
it.episodeIds.clear()
|
||||
it.episodeIds.addAll(qeids)
|
||||
it.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
// ensure curQueue is last updated
|
||||
|
@ -225,10 +274,11 @@ object Queues {
|
|||
eidsInQueues = q.episodeIds.intersect(episodeIds.toSet()).toMutableSet()
|
||||
if (eidsInQueues.isNotEmpty()) {
|
||||
val qeids = q.episodeIds.minus(eidsInQueues)
|
||||
q.episodeIds.clear()
|
||||
q.episodeIds.addAll(qeids)
|
||||
q.update()
|
||||
upsert(q) {}
|
||||
upsert(q) {
|
||||
it.episodeIds.clear()
|
||||
it.episodeIds.addAll(qeids)
|
||||
it.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,60 +320,6 @@ object Queues {
|
|||
upsertBlk(curQueue) {}
|
||||
}
|
||||
|
||||
var isQueueLocked: Boolean
|
||||
get() = appPrefs.getBoolean(UserPreferences.Prefs.prefQueueLocked.name, false)
|
||||
set(locked) {
|
||||
appPrefs.edit().putBoolean(UserPreferences.Prefs.prefQueueLocked.name, locked).apply()
|
||||
}
|
||||
|
||||
var isQueueKeepSorted: Boolean
|
||||
/**
|
||||
* Returns if the queue is in keep sorted mode.
|
||||
* @see .queueKeepSortedOrder
|
||||
*/
|
||||
get() = appPrefs.getBoolean(UserPreferences.Prefs.prefQueueKeepSorted.name, false)
|
||||
/**
|
||||
* Enables/disables the keep sorted mode of the queue.
|
||||
* @see .queueKeepSortedOrder
|
||||
*/
|
||||
set(keepSorted) {
|
||||
appPrefs.edit().putBoolean(UserPreferences.Prefs.prefQueueKeepSorted.name, keepSorted).apply()
|
||||
}
|
||||
|
||||
var queueKeepSortedOrder: EpisodeSortOrder?
|
||||
/**
|
||||
* Returns the sort order for the queue keep sorted mode.
|
||||
* Note: This value is stored independently from the keep sorted state.
|
||||
* @see .isQueueKeepSorted
|
||||
*/
|
||||
get() {
|
||||
val sortOrderStr = appPrefs.getString(UserPreferences.Prefs.prefQueueKeepSortedOrder.name, "use-default")
|
||||
return EpisodeSortOrder.parseWithDefault(sortOrderStr, EpisodeSortOrder.DATE_NEW_OLD)
|
||||
}
|
||||
/**
|
||||
* Sets the sort order for the queue keep sorted mode.
|
||||
* @see .setQueueKeepSorted
|
||||
*/
|
||||
set(sortOrder) {
|
||||
if (sortOrder == null) return
|
||||
appPrefs.edit().putString(UserPreferences.Prefs.prefQueueKeepSortedOrder.name, sortOrder.name).apply()
|
||||
}
|
||||
|
||||
var enqueueLocation: EnqueueLocation
|
||||
get() {
|
||||
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)
|
||||
return EnqueueLocation.BACK
|
||||
}
|
||||
}
|
||||
set(location) {
|
||||
appPrefs.edit().putString(UserPreferences.Prefs.prefEnqueueLocation.name, location.name).apply()
|
||||
}
|
||||
|
||||
class EnqueuePositionCalculator(private val enqueueLocation: EnqueueLocation) {
|
||||
/**
|
||||
* Determine the position (0-based) that the item(s) should be inserted to the named queue.
|
||||
|
|
|
@ -4,38 +4,22 @@ import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
|||
import java.io.Serializable
|
||||
|
||||
class EpisodeFilter(vararg properties: String) : Serializable {
|
||||
|
||||
private val properties: Array<String> = arrayOf(*properties.filter { it.isNotEmpty() }.map {it.trim()}.toTypedArray())
|
||||
|
||||
@JvmField
|
||||
val showPlayed: Boolean = hasProperty(States.played.name)
|
||||
@JvmField
|
||||
val showUnplayed: Boolean = hasProperty(States.unplayed.name)
|
||||
@JvmField
|
||||
val showPaused: Boolean = hasProperty(States.paused.name)
|
||||
@JvmField
|
||||
val showNotPaused: Boolean = hasProperty(States.not_paused.name)
|
||||
@JvmField
|
||||
val showNew: Boolean = hasProperty(States.new.name)
|
||||
@JvmField
|
||||
val showQueued: Boolean = hasProperty(States.queued.name)
|
||||
@JvmField
|
||||
val showNotQueued: Boolean = hasProperty(States.not_queued.name)
|
||||
@JvmField
|
||||
val showDownloaded: Boolean = hasProperty(States.downloaded.name)
|
||||
@JvmField
|
||||
val showNotDownloaded: Boolean = hasProperty(States.not_downloaded.name)
|
||||
@JvmField
|
||||
val showAutoDownloadable: Boolean = hasProperty(States.auto_downloadable.name)
|
||||
@JvmField
|
||||
val showNotAutoDownloadable: Boolean = hasProperty(States.not_auto_downloadable.name)
|
||||
@JvmField
|
||||
val showHasMedia: Boolean = hasProperty(States.has_media.name)
|
||||
@JvmField
|
||||
val showNoMedia: Boolean = hasProperty(States.no_media.name)
|
||||
@JvmField
|
||||
val showIsFavorite: Boolean = hasProperty(States.is_favorite.name)
|
||||
@JvmField
|
||||
val showNotFavorite: Boolean = hasProperty(States.not_favorite.name)
|
||||
|
||||
constructor(properties: String) : this(*(properties.split(",").toTypedArray()))
|
||||
|
@ -115,6 +99,7 @@ class EpisodeFilter(vararg properties: String) : Serializable {
|
|||
return query.toString()
|
||||
}
|
||||
|
||||
@Suppress("EnumEntryName")
|
||||
enum class States {
|
||||
played,
|
||||
unplayed,
|
||||
|
|
|
@ -99,11 +99,11 @@ class FeedAutoDownloadFilter(val includeFilterRaw: String? = "", val excludeFilt
|
|||
}
|
||||
|
||||
fun hasIncludeFilter(): Boolean {
|
||||
return includeFilterRaw!!.isNotEmpty()
|
||||
return !includeFilterRaw.isNullOrEmpty()
|
||||
}
|
||||
|
||||
fun hasExcludeFilter(): Boolean {
|
||||
return excludeFilterRaw!!.isNotEmpty()
|
||||
return !excludeFilterRaw.isNullOrEmpty()
|
||||
}
|
||||
|
||||
fun hasMinimalDurationFilter(): Boolean {
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
package ac.mdiq.podcini.storage.model
|
||||
|
||||
import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.SPEED_USE_GLOBAL
|
||||
import java.io.Serializable
|
||||
|
||||
class FeedFilter(vararg properties: String) : Serializable {
|
||||
|
||||
private val properties: Array<String> = 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 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)
|
||||
|
||||
constructor(properties: String) : this(*(properties.split(",").toTypedArray()))
|
||||
|
||||
private fun hasProperty(property: String): Boolean {
|
||||
return listOf(*properties).contains(property)
|
||||
}
|
||||
|
||||
val values: Array<String>
|
||||
get() = properties.clone()
|
||||
|
||||
val valuesList: List<String>
|
||||
get() = listOf(*properties)
|
||||
|
||||
fun matches(feed: Feed): Boolean {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun queryString(): String {
|
||||
val statements: MutableList<String> = ArrayList()
|
||||
when {
|
||||
showKeepUpdated -> statements.add("preferences.keepUpdated == true ")
|
||||
showNotKeepUpdated -> statements.add(" preferences.keepUpdated == false ")
|
||||
}
|
||||
when {
|
||||
showGlobalPlaySpeed -> statements.add(" preferences.playSpeed == ${SPEED_USE_GLOBAL} ")
|
||||
showCustomPlaySpeed -> statements.add(" preferences.playSpeed != $SPEED_USE_GLOBAL ")
|
||||
}
|
||||
when {
|
||||
showHasSkips -> statements.add(" preferences.introSkip != 0 OR preferences.endingSkip != 0 ")
|
||||
showNoSkips -> statements.add(" preferences.introSkip == 0 AND preferences.endingSkip == 0 ")
|
||||
}
|
||||
when {
|
||||
showAlwaysAutoDelete -> statements.add(" preferences.autoDelete == ${FeedPreferences.AutoDeleteAction.ALWAYS.code} ")
|
||||
showNeverAutoDelete -> statements.add(" preferences.playSpeed != ${FeedPreferences.AutoDeleteAction.NEVER.code} ")
|
||||
}
|
||||
when {
|
||||
showAutoDownload -> statements.add(" preferences.autoDownload == true ")
|
||||
showNotAutoDownload -> statements.add(" preferences.autoDownload == false ")
|
||||
}
|
||||
|
||||
if (statements.isEmpty()) return "id > 0"
|
||||
|
||||
val query = StringBuilder(" (" + statements[0])
|
||||
for (r in statements.subList(1, statements.size)) {
|
||||
query.append(" AND ")
|
||||
query.append(r)
|
||||
}
|
||||
query.append(") ")
|
||||
|
||||
return query.toString()
|
||||
}
|
||||
|
||||
@Suppress("EnumEntryName")
|
||||
enum class States {
|
||||
keepUpdated,
|
||||
not_keepUpdated,
|
||||
global_playSpeed,
|
||||
custom_playSpeed,
|
||||
has_skips,
|
||||
no_skips,
|
||||
// global_auto_delete,
|
||||
always_auto_delete,
|
||||
never_auto_delete,
|
||||
autoDownload,
|
||||
not_autoDownload,
|
||||
|
||||
}
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun unfiltered(): FeedFilter {
|
||||
return FeedFilter("")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,19 +1,36 @@
|
|||
package ac.mdiq.podcini.ui.actions
|
||||
|
||||
import ac.mdiq.podcini.ui.activity.MainActivity
|
||||
import android.util.Log
|
||||
import androidx.annotation.PluralsRes
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.storage.model.Episode
|
||||
import ac.mdiq.podcini.databinding.SwitchQueueDialogBinding
|
||||
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
||||
import ac.mdiq.podcini.storage.database.Episodes
|
||||
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode
|
||||
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
|
||||
import ac.mdiq.podcini.storage.database.Queues
|
||||
import ac.mdiq.podcini.storage.database.Queues.addToQueueSync
|
||||
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet
|
||||
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.realm
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
import ac.mdiq.podcini.storage.model.Episode
|
||||
import ac.mdiq.podcini.storage.model.PlayQueue
|
||||
import ac.mdiq.podcini.ui.activity.MainActivity
|
||||
import ac.mdiq.podcini.ui.utils.LocalDeleteModal
|
||||
import ac.mdiq.podcini.util.event.EventFlow
|
||||
import ac.mdiq.podcini.util.event.FlowEvent
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
|
||||
@UnstableApi
|
||||
|
@ -26,6 +43,7 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
|
|||
R.id.add_to_favorite_batch -> markFavorite(items, true)
|
||||
R.id.remove_favorite_batch -> markFavorite(items, false)
|
||||
R.id.add_to_queue_batch -> queueChecked(items)
|
||||
R.id.put_to_queue_batch -> putToQueue(items)
|
||||
R.id.remove_from_queue_batch -> removeFromQueueChecked(items)
|
||||
R.id.mark_read_batch -> {
|
||||
setPlayState(Episode.PLAYED, false, *items.toTypedArray())
|
||||
|
@ -53,7 +71,7 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
|
|||
|
||||
private fun removeFromQueueChecked(items: List<Episode>) {
|
||||
val checkedIds = getSelectedIds(items)
|
||||
removeFromQueue(activity, *items.toTypedArray())
|
||||
removeFromQueue(*items.toTypedArray())
|
||||
showMessage(R.plurals.removed_from_queue_batch_label, checkedIds.size)
|
||||
}
|
||||
|
||||
|
@ -96,6 +114,59 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
|
|||
return checkedIds
|
||||
}
|
||||
|
||||
private fun putToQueue(items: List<Episode>) {
|
||||
PutToQueueDialog(activity as MainActivity, items).show()
|
||||
}
|
||||
|
||||
class PutToQueueDialog(activity: Activity, val items: List<Episode>) {
|
||||
private val activityRef: WeakReference<Activity> = WeakReference(activity)
|
||||
|
||||
fun show() {
|
||||
val activity = activityRef.get() ?: return
|
||||
val binding = SwitchQueueDialogBinding.inflate(LayoutInflater.from(activity))
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
val queueNames = queues.map { it.name }.toTypedArray()
|
||||
val adaptor = ArrayAdapter(activity, android.R.layout.simple_spinner_item, queueNames)
|
||||
adaptor.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
val catSpinner = binding.queueSpinner
|
||||
catSpinner.setAdapter(adaptor)
|
||||
catSpinner.setSelection(adaptor.getPosition(curQueue.name))
|
||||
var toQueue: PlayQueue = curQueue
|
||||
catSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
toQueue = unmanaged(queues[position])
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setView(binding.root)
|
||||
.setTitle(R.string.switch_queue_label)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
val toRemove = mutableSetOf<Long>()
|
||||
val toRemoveCur = mutableListOf<Episode>()
|
||||
items.forEach { e ->
|
||||
if (curQueue.isInQueue(e)) toRemoveCur.add(e)
|
||||
}
|
||||
items.forEach { e ->
|
||||
for (q in queues) {
|
||||
if (q.isInQueue(e)) {
|
||||
toRemove.add(e.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toRemove.isNotEmpty()) runBlocking { removeFromAllQueuesQuiet(toRemove.toList()) }
|
||||
if (toRemoveCur.isNotEmpty()) EventFlow.postEvent(FlowEvent.QueueEvent.removed(toRemoveCur))
|
||||
items.forEach { e ->
|
||||
runBlocking { addToQueueSync(false, e, toQueue) }
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel_label, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = EpisodeMultiSelectHandler::class.simpleName ?: "Anonymous"
|
||||
}
|
||||
|
|
|
@ -5,15 +5,15 @@ import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected
|
|||
import ac.mdiq.podcini.net.sync.SynchronizationSettings.wifiSyncEnabledKey
|
||||
import ac.mdiq.podcini.net.sync.model.EpisodeAction
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink.needSynch
|
||||
import ac.mdiq.podcini.playback.base.InTheatre
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curState
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
|
||||
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.ACTION_SHUTDOWN_PLAYBACK_SERVICE
|
||||
import ac.mdiq.podcini.receiver.MediaButtonReceiver
|
||||
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode
|
||||
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
|
||||
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
|
||||
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
|
||||
import ac.mdiq.podcini.storage.database.Queues.addToQueue
|
||||
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
|
||||
import ac.mdiq.podcini.storage.model.Episode
|
||||
|
@ -145,7 +145,7 @@ object EpisodeMenuHandler {
|
|||
if (selectedItem.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) {
|
||||
val media: EpisodeMedia? = selectedItem.media
|
||||
// not all items have media, Gpodder only cares about those that do
|
||||
if (media != null) {
|
||||
if (needSynch() && media != null) {
|
||||
val actionPlay: EpisodeAction = EpisodeAction.Builder(selectedItem, EpisodeAction.PLAY)
|
||||
.currentTimestamp()
|
||||
.started(media.getDuration() / 1000)
|
||||
|
@ -159,7 +159,7 @@ object EpisodeMenuHandler {
|
|||
R.id.mark_unread_item -> {
|
||||
selectedItem.setPlayed(false)
|
||||
setPlayState(Episode.UNPLAYED, false, selectedItem)
|
||||
if (selectedItem.feed?.isLocalFeed != true && selectedItem.media != null) {
|
||||
if (needSynch() && selectedItem.feed?.isLocalFeed != true && selectedItem.media != null) {
|
||||
val actionNew: EpisodeAction = EpisodeAction.Builder(selectedItem, EpisodeAction.NEW)
|
||||
.currentTimestamp()
|
||||
.build()
|
||||
|
@ -167,7 +167,7 @@ object EpisodeMenuHandler {
|
|||
}
|
||||
}
|
||||
R.id.add_to_queue_item -> addToQueue(true, selectedItem)
|
||||
R.id.remove_from_queue_item -> removeFromQueue(context, selectedItem)
|
||||
R.id.remove_from_queue_item -> removeFromQueue(selectedItem)
|
||||
R.id.add_to_favorites_item -> setFavorite(selectedItem, true)
|
||||
R.id.remove_from_favorites_item -> setFavorite(selectedItem, false)
|
||||
R.id.reset_position -> {
|
||||
|
|
|
@ -37,7 +37,7 @@ class RemoveFromQueueSwipeAction : SwipeAction {
|
|||
|
||||
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
|
||||
val position: Int = curQueue.episodes.indexOf(item)
|
||||
removeFromQueue(fragment.requireActivity(), item)
|
||||
removeFromQueue(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)) {
|
||||
|
|
|
@ -52,7 +52,7 @@ class TogglePlaybackStateSwipeAction : SwipeAction {
|
|||
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
|
||||
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
|
||||
item = deleteMediaSync(fragment.requireContext(), item)
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item) }
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) }
|
||||
}
|
||||
val playStateStringRes: Int = when (newState) {
|
||||
Episode.UNPLAYED -> if (item.playState == Episode.NEW) R.string.removed_inbox_label //was new
|
||||
|
@ -83,7 +83,7 @@ class TogglePlaybackStateSwipeAction : SwipeAction {
|
|||
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
|
||||
// deleteMediaOfEpisode(fragment.requireContext(), item)
|
||||
var item = deleteMediaSync(fragment.requireContext(), item)
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item) }
|
||||
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) }
|
||||
}
|
||||
|
||||
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
|
||||
|
|
|
@ -45,7 +45,7 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() {
|
|||
val layout = inflater.inflate(R.layout.filter_dialog, null, false)
|
||||
_binding = FilterDialogBinding.bind(layout)
|
||||
rows = binding.filterRows
|
||||
Logd("ItemFilterDialog", "fragment onCreateView")
|
||||
Logd("EpisodeFilterDialog", "fragment onCreateView")
|
||||
|
||||
//add filter rows
|
||||
for (item in FeedItemFilterGroup.entries) {
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
package ac.mdiq.podcini.ui.dialog
|
||||
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.FilterDialogBinding
|
||||
import ac.mdiq.podcini.databinding.FilterDialogRowBinding
|
||||
import ac.mdiq.podcini.storage.model.FeedFilter
|
||||
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion.TAG
|
||||
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion.feedsFilter
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import ac.mdiq.podcini.util.event.EventFlow
|
||||
import ac.mdiq.podcini.util.event.FlowEvent
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
class FeedFilterDialog : BottomSheetDialogFragment() {
|
||||
private lateinit var rows: LinearLayout
|
||||
private var _binding: FilterDialogBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
var filter: FeedFilter? = null
|
||||
private val buttonMap: MutableMap<String, Button> = mutableMapOf()
|
||||
|
||||
private val newFilterValues: Set<String>
|
||||
get() {
|
||||
val newFilterValues: MutableSet<String> = HashSet()
|
||||
for (i in 0 until rows.childCount) {
|
||||
if (rows.getChildAt(i) !is MaterialButtonToggleGroup) continue
|
||||
val group = rows.getChildAt(i) as MaterialButtonToggleGroup
|
||||
if (group.checkedButtonId == View.NO_ID) continue
|
||||
val tag = group.findViewById<View>(group.checkedButtonId).tag as? String ?: continue
|
||||
newFilterValues.add(tag)
|
||||
}
|
||||
return newFilterValues
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val layout = inflater.inflate(R.layout.filter_dialog, null, false)
|
||||
_binding = FilterDialogBinding.bind(layout)
|
||||
rows = binding.filterRows
|
||||
Logd("FeedFilterDialog", "fragment onCreateView")
|
||||
|
||||
//add filter rows
|
||||
for (item in FeedFilterGroup.entries) {
|
||||
// Logd("EpisodeFilterDialog", "FeedItemFilterGroup: ${item.values[0].filterId} ${item.values[1].filterId}")
|
||||
val rBinding = FilterDialogRowBinding.inflate(inflater)
|
||||
// rowBinding.root.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, _: Int, _: Boolean ->
|
||||
// onFilterChanged(newFilterValues)
|
||||
// }
|
||||
rBinding.filterButton1.setOnClickListener { onFilterChanged(newFilterValues) }
|
||||
rBinding.filterButton2.setOnClickListener { onFilterChanged(newFilterValues) }
|
||||
|
||||
rBinding.filterButton1.setText(item.values[0].displayName)
|
||||
rBinding.filterButton1.tag = item.values[0].filterId
|
||||
buttonMap[item.values[0].filterId] = rBinding.filterButton1
|
||||
rBinding.filterButton2.setText(item.values[1].displayName)
|
||||
rBinding.filterButton2.tag = item.values[1].filterId
|
||||
buttonMap[item.values[1].filterId] = rBinding.filterButton2
|
||||
rBinding.filterButton1.maxLines = 3
|
||||
rBinding.filterButton1.isSingleLine = false
|
||||
rBinding.filterButton2.maxLines = 3
|
||||
rBinding.filterButton2.isSingleLine = false
|
||||
rows.addView(rBinding.root, rows.childCount - 1)
|
||||
}
|
||||
|
||||
binding.confirmFiltermenu.setOnClickListener { dismiss() }
|
||||
binding.resetFiltermenu.setOnClickListener {
|
||||
onFilterChanged(emptySet())
|
||||
for (i in 0 until rows.childCount) {
|
||||
if (rows.getChildAt(i) is MaterialButtonToggleGroup) (rows.getChildAt(i) as MaterialButtonToggleGroup).clearChecked()
|
||||
}
|
||||
}
|
||||
|
||||
if (filter != null) {
|
||||
for (filterId in filter!!.values) {
|
||||
if (filterId.isNotEmpty()) {
|
||||
val button = buttonMap[filterId]
|
||||
if (button != null) (button.parent as MaterialButtonToggleGroup).check(button.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
dialog.setOnShowListener { dialogInterface: DialogInterface ->
|
||||
val bottomSheetDialog = dialogInterface as BottomSheetDialog
|
||||
setupFullHeight(bottomSheetDialog)
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
Logd(TAG, "onDestroyView")
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun setupFullHeight(bottomSheetDialog: BottomSheetDialog) {
|
||||
val bottomSheet = bottomSheetDialog.findViewById<View>(com.leinardi.android.speeddial.R.id.design_bottom_sheet) as? FrameLayout
|
||||
if (bottomSheet != null) {
|
||||
val behavior = BottomSheetBehavior.from(bottomSheet)
|
||||
val layoutParams = bottomSheet.layoutParams
|
||||
bottomSheet.layoutParams = layoutParams
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilterChanged(newFilterValues: Set<String>) {
|
||||
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)),
|
||||
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<ItemProperties> = 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -329,6 +329,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
|
|||
val media = event.media
|
||||
if (currentMedia?.getIdentifier() == null || media?.getIdentifier() != currentMedia?.getIdentifier()) {
|
||||
currentMedia = media
|
||||
playerUI?.updateUi(currentMedia)
|
||||
playerDetailsFragment?.setItem(curEpisode!!)
|
||||
}
|
||||
playerUI?.onPositionUpdate(event)
|
||||
|
|
|
@ -131,6 +131,7 @@ class FeedSettingsFragment : Fragment() {
|
|||
override fun onDestroyView() {
|
||||
Logd(TAG, "onDestroyView")
|
||||
feed = null
|
||||
feedPrefs = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
private fun setupFeedAutoSkipPreference() {
|
||||
|
|
|
@ -19,10 +19,8 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
|
|||
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.upsert
|
||||
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.database.RealmDB.upsertBlk
|
||||
import ac.mdiq.podcini.storage.model.*
|
||||
import ac.mdiq.podcini.storage.utils.EpisodeUtil
|
||||
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
|
||||
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
|
||||
|
@ -49,7 +47,10 @@ import android.content.SharedPreferences
|
|||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.CheckBox
|
||||
import android.widget.Spinner
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
@ -111,6 +112,23 @@ import java.util.*
|
|||
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
|
||||
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
|
||||
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
val queueNames = queues.map { it.name }.toTypedArray()
|
||||
val spinnerLayout = inflater.inflate(R.layout.queue_title_spinner, null)
|
||||
val spinner = spinnerLayout.findViewById<Spinner>(R.id.queue_spinner)
|
||||
toolbar.addView(spinnerLayout)
|
||||
val sAdaptor = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, queueNames)
|
||||
sAdaptor.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
spinner.adapter = sAdaptor
|
||||
spinner.setSelection(sAdaptor.getPosition(curQueue.name))
|
||||
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
curQueue = unmanaged(upsertBlk(queues[position]) { it.updated })
|
||||
loadItems(true)
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
|
||||
(activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow)
|
||||
toolbar.inflateMenu(R.menu.queue)
|
||||
refreshToolbarState()
|
||||
|
@ -324,7 +342,7 @@ import java.util.*
|
|||
for (downloadUrl in event.urls) {
|
||||
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(queueItems.toList(), downloadUrl)
|
||||
if (pos >= 0) {
|
||||
val item = queueItems[pos]
|
||||
val item = unmanaged(queueItems[pos])
|
||||
// item.media?.downloaded = true
|
||||
item.media?.setIsDownloaded()
|
||||
adapter?.notifyItemChangedCompat(pos)
|
||||
|
@ -535,7 +553,7 @@ import java.util.*
|
|||
info += DurationConverter.getDurationStringLocalized(requireActivity(), timeLeft)
|
||||
}
|
||||
binding.infoBar.text = info
|
||||
toolbar.title = "${getString(R.string.queue_label)}: ${curQueue.name}"
|
||||
// toolbar.title = "${getString(R.string.queue_label)}: ${curQueue.name}"
|
||||
}
|
||||
|
||||
private var loadItemsRunning = false
|
||||
|
|
|
@ -9,17 +9,13 @@ import ac.mdiq.podcini.storage.database.Feeds.getFeedList
|
|||
import ac.mdiq.podcini.storage.database.Feeds.getTags
|
||||
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.realm
|
||||
import ac.mdiq.podcini.storage.model.Episode
|
||||
import ac.mdiq.podcini.storage.model.Feed
|
||||
import ac.mdiq.podcini.storage.model.FeedPreferences
|
||||
import ac.mdiq.podcini.storage.model.FeedSortOrder
|
||||
import ac.mdiq.podcini.storage.model.*
|
||||
import ac.mdiq.podcini.ui.actions.menuhandler.FeedMenuHandler
|
||||
import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils
|
||||
import ac.mdiq.podcini.ui.activity.MainActivity
|
||||
import ac.mdiq.podcini.ui.adapter.SelectableAdapter
|
||||
import ac.mdiq.podcini.ui.dialog.FeedSortDialog
|
||||
import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog
|
||||
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog
|
||||
import ac.mdiq.podcini.ui.dialog.*
|
||||
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment.FeedEpisodeFilterDialog
|
||||
import ac.mdiq.podcini.ui.utils.CoverLoader
|
||||
import ac.mdiq.podcini.ui.utils.EmptyViewHandler
|
||||
import ac.mdiq.podcini.ui.utils.LiftOnScrollListener
|
||||
|
@ -134,7 +130,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
binding.categorySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
tagFilterIndex = position
|
||||
filterOnTag()
|
||||
// filterOnTag()
|
||||
loadSubscriptions()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
|
@ -224,24 +221,41 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
fun filterOnTag() {
|
||||
when (tagFilterIndex) {
|
||||
1 -> feedListFiltered = feedList // All feeds
|
||||
0 -> feedListFiltered = feedList.filter { // feeds without tag
|
||||
val tags = it.preferences?.tags
|
||||
tags.isNullOrEmpty() || (tags.size == 1 && tags.toList()[0] == "#root")
|
||||
}
|
||||
fun queryStringOfTags() : String {
|
||||
return when (tagFilterIndex) {
|
||||
1 -> "" // All feeds
|
||||
0 -> " preferences.tags.@count == 0 OR (preferences.tags.@count == 0 AND ALL preferences.tags == '#root' ) "
|
||||
else -> { // feeds with the chosen tag
|
||||
val tag = tags[tagFilterIndex]
|
||||
feedListFiltered = feedList.filter {
|
||||
it.preferences?.tags?.contains(tag) ?: false
|
||||
}
|
||||
" ANY preferences.tags == '$tag' "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filterOnTag() {
|
||||
feedListFiltered = feedList
|
||||
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
|
||||
adapter.setItems(feedListFiltered)
|
||||
}
|
||||
|
||||
// fun filterOnTag() {
|
||||
// when (tagFilterIndex) {
|
||||
// 1 -> feedListFiltered = feedList // All feeds
|
||||
// 0 -> feedListFiltered = feedList.filter { // feeds without tag
|
||||
// val tags = it.preferences?.tags
|
||||
// tags.isNullOrEmpty() || (tags.size == 1 && tags.toList()[0] == "#root")
|
||||
// }
|
||||
// else -> { // feeds with the chosen tag
|
||||
// val tag = tags[tagFilterIndex]
|
||||
// feedListFiltered = feedList.filter {
|
||||
// it.preferences?.tags?.contains(tag) ?: false
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
|
||||
// adapter.setItems(feedListFiltered)
|
||||
// }
|
||||
|
||||
private fun resetTags() {
|
||||
tags.clear()
|
||||
tags.add("Untagged")
|
||||
|
@ -263,6 +277,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
Logd(TAG, "Received event: ${event.TAG}")
|
||||
when (event) {
|
||||
is FlowEvent.FeedListEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions()
|
||||
is FlowEvent.FeedsFilterEvent -> loadSubscriptions()
|
||||
is FlowEvent.EpisodePlayedEvent -> loadSubscriptions()
|
||||
is FlowEvent.FeedTagsChangedEvent -> loadSubscriptions()
|
||||
// is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChangeEvent(event)
|
||||
|
@ -293,6 +308,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
@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.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance())
|
||||
R.id.subscriptions_sort -> FeedSortDialog().show(childFragmentManager, "FeedSortDialog")
|
||||
R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext())
|
||||
|
@ -318,7 +334,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
sortFeeds()
|
||||
filterAndSort()
|
||||
resetTags()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
|
@ -328,6 +344,15 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
binding.progressBar.visibility = View.GONE
|
||||
adapter.setItems(feedListFiltered)
|
||||
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
|
||||
if (feedsFilter.isNotEmpty()) {
|
||||
val filter = FeedFilter(feedsFilter)
|
||||
binding.txtvInformation.text = ("{gmo-info} " + getString(R.string.filtered_label))
|
||||
binding.txtvInformation.setOnClickListener {
|
||||
val dialog = FeedFilterDialog.newInstance(filter)
|
||||
dialog.show(childFragmentManager, null)
|
||||
}
|
||||
binding.txtvInformation.visibility = View.VISIBLE
|
||||
} else binding.txtvInformation.visibility = View.GONE
|
||||
emptyView.updateVisibility()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -339,13 +364,17 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
}
|
||||
}
|
||||
|
||||
private fun sortFeeds() {
|
||||
Logd(TAG, "sortFeeds() called")
|
||||
private fun filterAndSort() {
|
||||
val tagsQueryStr = queryStringOfTags()
|
||||
val fQueryStr = if (tagsQueryStr.isEmpty()) FeedFilter(feedsFilter).queryString() else FeedFilter(feedsFilter).queryString() + " AND " + tagsQueryStr
|
||||
Logd(TAG, "sortFeeds() called $feedsFilter $fQueryStr")
|
||||
val feedIds = getFeedList(fQueryStr).map { id }
|
||||
val feedOrder = feedOrderBy
|
||||
val dir = 1 - 2*feedOrderDir // get from 0, 1 to 1, -1
|
||||
val comparator: Comparator<Feed> = when (feedOrder) {
|
||||
FeedSortOrder.UNPLAYED_NEW_OLD.index -> {
|
||||
val episodes = realm.query(Episode::class).query("(playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED})").find()
|
||||
val queryString = "feedId IN $0 AND (playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED})"
|
||||
val episodes = realm.query(Episode::class).query(queryString, feedIds).find()
|
||||
val counterMap = counterMap(episodes)
|
||||
comparator(counterMap, dir)
|
||||
}
|
||||
|
@ -361,12 +390,14 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
}
|
||||
}
|
||||
FeedSortOrder.MOST_PLAYED.index -> {
|
||||
val episodes = realm.query(Episode::class).query("playState == ${Episode.PLAYED}").find()
|
||||
val queryString = "feedId IN $0 AND playState == ${Episode.PLAYED}"
|
||||
val episodes = realm.query(Episode::class).query(queryString, feedIds).find()
|
||||
val counterMap = counterMap(episodes)
|
||||
comparator(counterMap, dir)
|
||||
}
|
||||
FeedSortOrder.LAST_UPDATED_NEW_OLD.index -> {
|
||||
val episodes = realm.query(Episode::class).sort("pubDate", Sort.DESCENDING).find()
|
||||
val queryString = "feedId IN $0"
|
||||
val episodes = realm.query(Episode::class, queryString, feedIds).sort("pubDate", Sort.DESCENDING).find()
|
||||
val counterMap: MutableMap<Long, Long> = mutableMapOf()
|
||||
for (episode in episodes) {
|
||||
val feedId = episode.feedId ?: continue
|
||||
|
@ -376,7 +407,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
comparator(counterMap, dir)
|
||||
}
|
||||
FeedSortOrder.LAST_DOWNLOAD_NEW_OLD.index -> {
|
||||
val episodes = realm.query(Episode::class).sort("media.downloadTime", Sort.DESCENDING).find()
|
||||
val queryString = "feedId IN $0"
|
||||
val episodes = realm.query(Episode::class, queryString, feedIds).sort("media.downloadTime", Sort.DESCENDING).find()
|
||||
val counterMap: MutableMap<Long, Long> = mutableMapOf()
|
||||
for (episode in episodes) {
|
||||
val feedId = episode.feedId ?: continue
|
||||
|
@ -386,8 +418,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
comparator(counterMap, dir)
|
||||
}
|
||||
FeedSortOrder.LAST_UPDATED_UNPLAYED_NEW_OLD.index -> {
|
||||
val episodes = realm.query(Episode::class)
|
||||
.query("playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}").find()
|
||||
val queryString = "feedId IN $0 AND (playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED})"
|
||||
val episodes = realm.query(Episode::class).query(queryString, feedIds).find()
|
||||
val counterMap: MutableMap<Long, Long> = mutableMapOf()
|
||||
for (episode in episodes) {
|
||||
val feedId = episode.feedId ?: continue
|
||||
|
@ -397,24 +429,26 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
comparator(counterMap, dir)
|
||||
}
|
||||
FeedSortOrder.MOST_DOWNLOADED.index -> {
|
||||
val episodes = realm.query(Episode::class).query("media.downloaded == true").find()
|
||||
val queryString = "feedId IN $0 AND media.downloaded == true"
|
||||
val episodes = realm.query(Episode::class).query(queryString, feedIds).find()
|
||||
val counterMap = counterMap(episodes)
|
||||
comparator(counterMap, dir)
|
||||
}
|
||||
FeedSortOrder.MOST_DOWNLOADED_UNPLAYED.index -> {
|
||||
val episodes = realm.query(Episode::class)
|
||||
.query("(playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}) AND media.downloaded == true").find()
|
||||
val queryString = "feedId IN $0 AND (playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}) AND media.downloaded == true"
|
||||
val episodes = realm.query(Episode::class).query(queryString, feedIds).find()
|
||||
val counterMap = counterMap(episodes)
|
||||
comparator(counterMap, dir)
|
||||
}
|
||||
// doing FEED_ORDER_NEW
|
||||
else -> {
|
||||
val episodes = realm.query(Episode::class).query("playState == ${Episode.NEW}").find()
|
||||
val queryString = "feedId IN $0 AND playState == ${Episode.NEW}"
|
||||
val episodes = realm.query(Episode::class).query(queryString, feedIds).find()
|
||||
val counterMap = counterMap(episodes)
|
||||
comparator(counterMap, dir)
|
||||
}
|
||||
}
|
||||
val feedList_ = getFeedList().toMutableList()
|
||||
val feedList_ = getFeedList(fQueryStr).toMutableList()
|
||||
synchronized(feedList_) { feedList = feedList_.sortedWith(comparator).toMutableList() }
|
||||
}
|
||||
|
||||
|
@ -905,6 +939,12 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
|
|||
return value
|
||||
}
|
||||
|
||||
var feedsFilter: String
|
||||
get() = appPrefs.getString(UserPreferences.Prefs.prefFeedFilter.name, "")?:""
|
||||
set(filter) {
|
||||
appPrefs.edit().putString(UserPreferences.Prefs.prefFeedFilter.name, filter).apply()
|
||||
}
|
||||
|
||||
fun newInstance(folderTitle: String?): SubscriptionsFragment {
|
||||
val fragment = SubscriptionsFragment()
|
||||
val args = Bundle()
|
||||
|
|
|
@ -75,6 +75,9 @@ sealed class FlowEvent {
|
|||
fun removed(episode: Episode): QueueEvent {
|
||||
return QueueEvent(Action.REMOVED, listOf(episode), -1)
|
||||
}
|
||||
fun removed(episodes: List<Episode>): QueueEvent {
|
||||
return QueueEvent(Action.REMOVED, episodes, -1)
|
||||
}
|
||||
fun irreversibleRemoved(episode: Episode): QueueEvent {
|
||||
return QueueEvent(Action.IRREVERSIBLE_REMOVED, listOf(episode), -1)
|
||||
}
|
||||
|
@ -133,6 +136,8 @@ sealed class FlowEvent {
|
|||
|
||||
data class FeedsSortedEvent(val dummy: Unit = Unit) : FlowEvent()
|
||||
|
||||
data class FeedsFilterEvent(val filterValues: Set<String?>?) : FlowEvent()
|
||||
|
||||
// data class SkipIntroEndingChangedEvent(val skipIntro: Int, val skipEnding: Int, val feedId: Long) : FlowEvent()
|
||||
|
||||
// handled together in FeedPrefsChangeEvent
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:baselineAligned="false"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingStart="6dp"
|
||||
android:paddingLeft="6dp"
|
||||
android:paddingEnd="0dp"
|
||||
android:paddingRight="0dp"
|
||||
tools:ignore="UselessParent">
|
||||
|
@ -53,8 +53,8 @@
|
|||
android:layout_height="@dimen/thumbnail_length_queue_item"
|
||||
android:layout_marginBottom="@dimen/listitem_threeline_verticalpadding"
|
||||
android:layout_marginTop="@dimen/listitem_threeline_verticalpadding"
|
||||
android:layout_marginRight="@dimen/listitem_threeline_textleftpadding"
|
||||
android:layout_marginEnd="@dimen/listitem_threeline_textleftpadding"
|
||||
android:layout_marginRight="@dimen/listitem_threeline_textrightpadding"
|
||||
android:layout_marginEnd="@dimen/listitem_threeline_textrightpadding"
|
||||
app:cardBackgroundColor="@color/non_square_icon_background"
|
||||
app:cardCornerRadius="4dp"
|
||||
app:cardPreventCornerOverlap="false"
|
||||
|
|
|
@ -43,6 +43,24 @@
|
|||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
<com.mikepenz.iconics.view.IconicsTextView
|
||||
android:id="@+id/txtvInformation"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="2dp"
|
||||
android:background="?android:attr/colorBackground"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:visibility="gone"
|
||||
android:gravity="center"
|
||||
android:textColor="?attr/colorAccent"
|
||||
tools:visibility="visible"
|
||||
tools:text="(i) Information" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/count"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
@ -19,8 +19,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:attr/actionBarSize"
|
||||
app:navigationContentDescription="@string/toolbar_back_button_content_description"
|
||||
app:navigationIcon="?homeAsUpIndicator"
|
||||
app:title="@string/queue_label" />
|
||||
app:navigationIcon="?homeAsUpIndicator"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:id="@+id/queue_title_spinner">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/queue_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:dropDownWidth="100dp"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textSize="@dimen/text_size_large"
|
||||
android:textStyle="bold"
|
||||
android:spinnerMode="dropdown"/>
|
||||
|
||||
</LinearLayout>
|
|
@ -37,6 +37,11 @@
|
|||
android:icon="@drawable/ic_playlist_play"
|
||||
android:title="@string/add_to_queue_label" />
|
||||
|
||||
<item
|
||||
android:id="@+id/put_to_queue_batch"
|
||||
android:icon="@drawable/ic_playlist_play"
|
||||
android:title="@string/put_to_queue_label" />
|
||||
|
||||
<item
|
||||
android:id="@+id/add_to_favorite_batch"
|
||||
android:icon="@drawable/ic_star"
|
||||
|
|
|
@ -6,6 +6,12 @@
|
|||
android:icon="@drawable/ic_search"
|
||||
custom:showAsAction="always"
|
||||
android:title="@string/search_label"/>
|
||||
<item
|
||||
android:id="@+id/subscriptions_filter"
|
||||
android:icon="@drawable/ic_filter"
|
||||
android:menuCategory="container"
|
||||
android:title="@string/filter"
|
||||
custom:showAsAction="always"/>
|
||||
<item
|
||||
android:id="@+id/subscriptions_sort"
|
||||
android:title="@string/sort"
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
<dimen name="listitem_iconwithtext_textleftpadding">16dp</dimen>
|
||||
|
||||
<dimen name="listitem_threeline_textleftpadding">16dp</dimen>
|
||||
<dimen name="listitem_threeline_textrightpadding">8dp</dimen>
|
||||
<dimen name="listitem_threeline_verticalpadding">8dp</dimen>
|
||||
<dimen name="listitem_threeline_textrightpadding">6dp</dimen>
|
||||
<dimen name="listitem_threeline_verticalpadding">5dp</dimen>
|
||||
|
||||
<dimen name="list_vertical_padding">8dp</dimen>
|
||||
<dimen name="listitem_icon_leftpadding">16dp</dimen>
|
||||
|
|
|
@ -146,6 +146,8 @@
|
|||
<string name="feed_auto_download_newer">Newest unplayed</string>
|
||||
<string name="feed_auto_download_older">Oldest unplayed</string>
|
||||
|
||||
<string name="put_to_queue_label">Put to queue</string>
|
||||
|
||||
<string name="feed_new_episodes_action_nothing">Nothing</string>
|
||||
<string name="episode_cleanup_never">Never</string>
|
||||
<string name="episode_cleanup_except_favorite_removal">When not favorited</string>
|
||||
|
@ -854,6 +856,16 @@
|
|||
<string name="not_played">Not played</string>
|
||||
<string name="filename">File name</string>
|
||||
|
||||
<string name="not_keep_updated">Not keep updated</string>
|
||||
<string name="global_speed">Global play speed</string>
|
||||
<string name="custom_speed">Custom play speed</string>
|
||||
<string name="has_skips">Skips set</string>
|
||||
<string name="no_skips">No Skips set</string>
|
||||
<string name="always_auto_delete">Always auto delete</string>
|
||||
<string name="never_auto_delete">Never auto delete</string>
|
||||
<string name="auto_download">Auto download enabled</string>
|
||||
<string name="not_auto_download">Auto download disabled</string>
|
||||
|
||||
<!-- Share episode dialog -->
|
||||
<string name="share_playback_position_dialog_label">Include playback position</string>
|
||||
<string name="share_dialog_episode_website_label">Episode webpage</string>
|
||||
|
|
|
@ -693,7 +693,7 @@ class DbWriterTest {
|
|||
// adapter.close()
|
||||
|
||||
runBlocking {
|
||||
val job = removeFromQueue(null, item)
|
||||
val job = removeFromQueue(item)
|
||||
withTimeout(TIMEOUT*1000) { job.join() }
|
||||
}
|
||||
// adapter = getInstance()
|
||||
|
@ -732,25 +732,25 @@ class DbWriterTest {
|
|||
val itemIds = toItemIds(feed.episodes).toTypedArray<Long>()
|
||||
|
||||
runBlocking {
|
||||
val job = removeFromQueue(null, feed.episodes[1], feed.episodes[3])
|
||||
val job = removeFromQueue(feed.episodes[1], feed.episodes[3])
|
||||
withTimeout(TIMEOUT*1000) { job.join() }
|
||||
}
|
||||
assertQueueByItemIds("Average case - 2 items removed successfully", itemIds[0], itemIds[2])
|
||||
|
||||
runBlocking {
|
||||
val job = removeFromQueue(null)
|
||||
val job = removeFromQueue()
|
||||
withTimeout(TIMEOUT*1000) { job.join() }
|
||||
}
|
||||
assertQueueByItemIds("Boundary case - no items supplied. queue should see no change", itemIds[0], itemIds[2])
|
||||
|
||||
runBlocking {
|
||||
val job = removeFromQueue(null, feed.episodes[0], feed.episodes[4])
|
||||
val job = removeFromQueue( feed.episodes[0], feed.episodes[4])
|
||||
withTimeout(TIMEOUT*1000) { job.join() }
|
||||
}
|
||||
assertQueueByItemIds("Boundary case - items not in queue ignored", itemIds[2])
|
||||
|
||||
runBlocking {
|
||||
val job = removeFromQueue(null, feed.episodes[2])
|
||||
val job = removeFromQueue( feed.episodes[2])
|
||||
withTimeout(TIMEOUT*1000) { job.join() }
|
||||
}
|
||||
assertQueueByItemIds("Boundary case - invalid itemIds ignored") // the queue is empty
|
||||
|
|
13
changelog.md
13
changelog.md
|
@ -1,10 +1,19 @@
|
|||
# 6.1.2
|
||||
|
||||
* fixed crash issue when setting the inclusive or exclusive filters in feed auto-download setting
|
||||
* fixed player UI not updating on change of episode
|
||||
* changed title of Queues view to a spinner for easily switching queues
|
||||
* added "Put to queue" in multi-select menu putting selected episodes to a queue, this would also remove the episodes from any previous queues.
|
||||
* added condition checks for preparing enqueuing sync actions
|
||||
* in Subscriptions view added feeds filter based on feed preferences, in the same style as episodes filter
|
||||
|
||||
# 6.1.1
|
||||
|
||||
* fixed player UI not updating on change of episode
|
||||
* fixed the mal-function of restoring previously backed-up OPML
|
||||
* reduced reactions to PlaybackPositionEvent
|
||||
* tuned AutoCleanup a bit
|
||||
* tuned and fixed some some issues in audo-downloaded
|
||||
* tuned and fixed some some issues in auto-downloaded
|
||||
|
||||
# 6.1.0
|
||||
|
||||
|
@ -17,7 +26,7 @@
|
|||
* skipped concurrent calls for loading data in multiple views
|
||||
* toggle "Auto backup of OPML" in Settings will restart Podcini
|
||||
* automatically restoring backup of OPML upon new install is disabled. Instead, in AddFeed view, when subscription is empty and OPML backup is available, a dialog is shown to ask about restoring.
|
||||
* added audo downloadable to episodes filter
|
||||
* added auto downloadable to episodes filter
|
||||
* added download date to episodes sorting
|
||||
* added download date to feed sorting
|
||||
* auto download algorithm is changed to individual feed based.
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
Version 6.1.2 brings several changes:
|
||||
|
||||
* fixed crash issue when setting the inclusive or exclusive filters in feed auto-download setting
|
||||
* fixed player UI not updating on change of episode
|
||||
* changed title of Queues view to a spinner for easily switching queues
|
||||
* added "Put to queue" in multi-select menu putting selected episodes to a queue, this would also remove the episodes from any previous queues.
|
||||
* added condition checks for preparing enqueuing sync actions
|
||||
* in Subscriptions view added feeds filter based on feed preferences, in the same style as episodes filter
|
Loading…
Reference in New Issue