diff --git a/README.md b/README.md index db36c960..4bfd491b 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,12 @@ Compared to AntennaPod this project: 1. Migrated all media routines to `androidx.media3`, with `AudioOffloadMode` enabled, nicer to device battery, 2. Is purely `Kotlin` based and mono-modular, and targets Android 14, 3. Iron-age celebrity SQLite is replaced with modern object-base Realm DB -4. Outfits with Viewbinding, Coil replacing Glide, coroutines replacing RxJava and threads, and SharedFlow replacing EventBus, -5. Boasts new UI's including streamlined drawer, subscriptions view and player controller, -6. Accepts podcast as well as plain RSS and YouTube feeds, -7. Offers Readability and Text-to-Speech for RSS contents, -8. Features `instant sync` across devices without a server. +4. Removed the need for support libraries and jetifier +5. Outfits with Viewbinding, Coil replacing Glide, coroutines replacing RxJava and threads, and SharedFlow replacing EventBus, +6. Boasts new UI's including streamlined drawer, subscriptions view and player controller, +7. Accepts podcast as well as plain RSS and YouTube feeds, +8. Offers Readability and Text-to-Speech for RSS contents, +9. Features `instant sync` across devices without a server. The project aims to profit from modern frameworks, improve efficiency and provide more useful and user-friendly features. @@ -75,16 +76,27 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * Played episodes have clearer markings * Sort dialog no longer dims the main 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) -* Subscriptions view has sorting by "Unread publication date" +* Long-press filter button in FeedEpisode view enables/disables filters without changing filter settings +* Subscriptions view has various explicit measures for sorting +* in Subscriptions view, click on cover image of a feed opens the FeedInfo view (not FeedEpisodes view) * History view shows time of last play, and allows filters and sorts +* 5 queues are provided by default: Default queue, and Queues 1-4 + * all queue operations are on the curQueue, which can be set in all episodes list views + * on app startup, the most recently updated queue is set to curQueue +* queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played +* on action bar of FeedEpisodes view there is a direct access to Queue + ### Podcast/Episode * New share notes menu option on various episode views * Feed info view offers a link for direct search of feeds related to author +* FeedInfo view has button showing number of episodes to open the FeedEpisodes view +* in EpisodeInfo view, "mark played/unplayed", "add to/remove from queue", and "favoraite/unfovorite" are at the action bar * New episode home view with two display modes: webpage or reader * In episode, in addition to "description" there is a new "transcript" field to save text (if any) fetched from the episode's website * RSS feeds with no playable media can be subscribed and read/listened (via TTS) +* deleting feeds is performed promptly ### Online feed @@ -107,6 +119,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * Disabled `usesCleartextTraffic`, so that all content transmission is more private and secure * Settings/Preferences can now be exported and imported * Play history/progress can be separately exported/imported as Json files +* There is a setting to disable/enable auto backup OPML files to Google For more details of the changes, see the [Changelog](changelog.md) diff --git a/app/build.gradle b/app/build.gradle index 752c9de0..25b667d5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -108,21 +108,27 @@ android { // start of the app build.gradle android { namespace "ac.mdiq.podcini" - lintOptions { - disable 'ObsoleteLintCustomCheck', 'CheckResult', 'UnusedAttribute', 'BatteryLife', 'InflateParams', + lint { + lintConfig = file("lint.xml") +// checkOnly += ['NewApi', 'InlinedApi'] + checkOnly += ['NewApi', 'InlinedApi', 'UnusedResources', 'ObsoleteSdkInt', + 'Performance', 'ViewId', 'MissingTranslation', + 'Deprecation', 'DuplicateIds', 'UseSparseArrays'] + + disable += ['TypographyDashes', 'TypographyQuotes', 'ObsoleteLintCustomCheck', 'CheckResult', 'UnusedAttribute', 'BatteryLife', 'InflateParams', 'RestrictedApi', 'TrustAllX509TrustManager', 'ExportedReceiver', 'AllowBackup', 'VectorDrawableCompat', 'StaticFieldLeak', 'UseCompoundDrawables', 'NestedWeights', 'Overdraw', 'UselessParent', 'TextFields', 'AlwaysShowAction', 'Autofill', 'ClickableViewAccessibility', 'ContentDescription', 'KeyboardInaccessibleWidget', 'LabelFor', 'SetTextI18n', 'HardcodedText', 'RelativeOverlap', 'RtlCompat', 'RtlHardcoded', 'MissingMediaBrowserServiceIntentFilter', 'VectorPath', - 'InvalidPeriodicWorkRequestInterval', 'NotifyDataSetChanged', 'RtlEnabled' + 'InvalidPeriodicWorkRequestInterval', 'NotifyDataSetChanged', 'RtlEnabled'] } buildFeatures { buildConfig true } defaultConfig { - versionCode 3020200 - versionName "6.0.0" + versionCode 3020201 + versionName "6.0.1" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/SyncService.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/SyncService.kt index e993367e..405771c7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/SyncService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/SyncService.kt @@ -22,6 +22,7 @@ import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl import ac.mdiq.podcini.storage.database.Episodes.getEpisodes import ac.mdiq.podcini.storage.database.Episodes.persistEpisodes import ac.mdiq.podcini.storage.database.Feeds.deleteFeed +import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedListDownloadUrls import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.database.Queues.removeFromQueue @@ -161,7 +162,7 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont private fun removeFeedWithDownloadUrl(context: Context, downloadUrl: String) { Logd(TAG, "removeFeedWithDownloadUrl called") var feedID: Long? = null - val feeds = realm.query(Feed::class).find() + val feeds = getFeedList() for (f in feeds) { val url = f.downloadUrl if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) feedID = f.id diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt index 6c603f1c..8b7b8147 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt @@ -12,8 +12,8 @@ import ac.mdiq.podcini.storage.database.Episodes.getEpisodes import ac.mdiq.podcini.storage.database.Episodes.persistEpisode import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.utils.EpisodeFilter -import ac.mdiq.podcini.storage.utils.SortOrder import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded +import ac.mdiq.podcini.storage.utils.SortOrder import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt index 43fe95c1..9c855b3f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt @@ -27,10 +27,10 @@ object InTheatre { } var curMedia: Playable? = null - get() { - if (field == null) field = loadPlayableFromPreferences() - return field - } +// get() { +// if (field == null) field = loadPlayableFromPreferences() +// return field +// } set(value) { field = value if (field is EpisodeMedia) { @@ -83,6 +83,7 @@ object InTheatre { copyToRealm(curState_) } } + loadPlayableFromPreferences() } // val curState_ = realm.query(CurrentState::class).first() // val job = CoroutineScope(Dispatchers.Default).launch { @@ -114,7 +115,7 @@ object InTheatre { * depending on the type of playable that was restored. * @return The restored Playable object */ - fun loadPlayableFromPreferences(): Playable? { + fun loadPlayableFromPreferences() { Logd(TAG, "loadPlayableFromPreferences currentlyPlayingType: $curState.curMediaType") if (curState.curMediaType != NO_MEDIA_PLAYING) { val type = curState.curMediaType.toInt() @@ -124,13 +125,8 @@ object InTheatre { curMedia = getEpisodeMedia(mediaId) if (curEpisode != null) curEpisode = (curMedia as EpisodeMedia).episode } - return curMedia - } else { - Log.e(TAG, "Could not restore Playable object from preferences") - return null - } + } else Log.e(TAG, "Could not restore Playable object from preferences") } - return null } @OptIn(UnstableApi::class) @JvmStatic diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt index b10559dd..1d0fe46b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt @@ -230,7 +230,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP * @param prepareImmediately Set to true if the method should also prepare the episode for playback. */ override fun playMediaObject(playable: Playable, stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { - Logd(TAG, "playMediaObject $forceReset $stream $startWhenPrepared $prepareImmediately $status ${curMedia?.getEpisodeTitle()} ") + Logd(TAG, "playMediaObject $forceReset $stream $startWhenPrepared $prepareImmediately $status ${playable.getEpisodeTitle()} ") if (curMedia != null) { if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { // episode is already playing -> ignore method call @@ -255,10 +255,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP setPlayerStatus(PlayerStatus.INDETERMINATE, null) } } -// else { -// Log.e(TAG, "playMediaObject curMedia is null") -// return -// } curMedia = playable this.isStreaming = stream diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index e49f28fa..3cb41ba9 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -54,6 +54,7 @@ import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded +import ac.mdiq.podcini.storage.utils.EpisodeUtil.indexOfItemWithId import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter @@ -68,6 +69,7 @@ import android.annotation.SuppressLint import android.app.NotificationManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.bluetooth.BluetoothA2dp import android.content.* import android.content.Intent.EXTRA_KEY_EVENT @@ -259,7 +261,10 @@ class PlaybackService : MediaSessionService() { recreateMediaPlayer() if (LocalMediaPlayer.exoPlayer == null) LocalMediaPlayer.createStaticPlayer(applicationContext) + val intent = packageManager.getLaunchIntentForPackage(packageName) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) mediaSession = MediaSession.Builder(applicationContext, LocalMediaPlayer.exoPlayer!!) + .setSessionActivity(pendingIntent) .setCallback(MyCallback()) .setCustomLayout(notificationCustomButtons) .build() @@ -845,7 +850,7 @@ class PlaybackService : MediaSessionService() { return nextItem.media } - fun getNextInQueue(episode: Episode): Episode? { + private fun getNextInQueue(episode: Episode): Episode? { Logd(TAG, "getNextInQueue() with: itemId ${episode.id}") if (curQueue.episodes.isEmpty()) return null diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index f61fd272..64e8bb68 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -4,7 +4,6 @@ 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.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.feedOrder import ac.mdiq.podcini.storage.database.Episodes.EpisodeDuplicateGuesser import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus @@ -28,7 +27,6 @@ import android.app.backup.BackupManager import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile -import io.realm.kotlin.query.RealmResults import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import java.io.File @@ -37,29 +35,29 @@ import java.util.concurrent.ExecutionException object Feeds { private val TAG: String = Feeds::class.simpleName ?: "Anonymous" - internal val feeds: MutableList = mutableListOf() +// internal val feeds: MutableList = mutableListOf() + private val feedMap: MutableMap = mutableMapOf() private val tags: MutableList = mutableListOf() fun getFeedList(): List { - return feeds -// return realm.query(Feed::class).find() + return feedMap.values.toList() } fun getTags(): List { return tags } - fun updateFeedList() { - Logd(TAG, "updateFeedList called") + fun updateFeedMap() { + Logd(TAG, "updateFeedMap called") val feeds_ = realm.query(Feed::class).find() - feeds.clear() - feeds.addAll(feeds_) + feedMap.clear() + feedMap.putAll(feeds_.associateBy { it.id }) buildTags() } fun buildTags() { val tagsSet = mutableSetOf() - val feedsCopy = feeds.toList() + val feedsCopy = feedMap.values for (feed in feedsCopy) { if (feed.preferences != null) { for (tag in feed.preferences!!.tags) { @@ -75,58 +73,36 @@ object Feeds { fun getFeedListDownloadUrls(): List { Logd(TAG, "getFeedListDownloadUrls called") val result: MutableList = mutableListOf() - val feeds = realm.query(Feed::class).find() - for (f in feeds) { +// val feeds = realm.query(Feed::class).find() + for (f in feedMap.values) { val url = f.downloadUrl if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) result.add(url) } return result } - fun getFeed(feedId: Long): Feed? { - Logd(TAG, "getFeed() called with: $feedId") - val f = realm.query(Feed::class).query("id == $0", feedId).first().find() - return if (f != null) realm.copyFromRealm(f) else null +// TODO: some callers don't need to copy + fun getFeed(feedId: Long, copy: Boolean = true): Feed? { +// Logd(TAG, "getFeed() called with: $feedId") +// val f = realm.query(Feed::class).query("id == $0", feedId).first().find() +// return if (f != null && f.isManaged()) realm.copyFromRealm(f) else null + val f = feedMap[feedId] + return if (f != null) { + if (copy) realm.copyFromRealm(f) + else f + } else null } private fun searchFeedByIdentifyingValueOrID(feed: Feed): Feed? { Logd(TAG, "searchFeedByIdentifyingValueOrID called") if (feed.id != 0L) return getFeed(feed.id) val feeds = getFeedList() - for (f in feeds.toList()) { - if (f.identifyingValue == feed.identifyingValue) { -// f.episodes.clear() -// f.episodes.addAll(getFeedItemList(f)) - return f - } + for (f in feeds) { + if (f.identifyingValue == feed.identifyingValue) return f } return null } - private fun counterMap(episodes: RealmResults): Map { - val counterMap: MutableMap = mutableMapOf() - for (episode in episodes) { - val feedId = episode.feedId ?: continue - val count = counterMap[feedId] ?: 0 - counterMap[feedId] = count + 1 - } - return counterMap - } - - private fun comparator(counterMap: Map): Comparator { - return Comparator { lhs: Feed, rhs: Feed -> - val counterLhs = counterMap[lhs.id]?:0 - val counterRhs = counterMap[rhs.id]?:0 - when { - // reverse natural order: podcast with most unplayed episodes first - counterLhs > counterRhs -> -1 - counterLhs == counterRhs -> lhs.title?.compareTo(rhs.title!!, ignoreCase = true) ?: -1 - else -> 1 - } - } - } - - // ------------------ writer ---------------------- /** @@ -186,8 +162,7 @@ object Feeds { val possibleDuplicate = searchEpisodeGuessDuplicate(newFeed.episodes, episode) if (!newFeed.isLocalFeed && possibleDuplicate != null && episode !== possibleDuplicate) { // Canonical episode is the first one returned (usually oldest) - addDownloadStatus(DownloadResult(savedFeed.id, - episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false, + addDownloadStatus(DownloadResult(savedFeed.id, episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false, """ The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it. @@ -235,7 +210,7 @@ object Feeds { else { Logd(TAG, "Found new episode: ${episode.title}") episode.feed = savedFeed - episode.id = idLong + episode.id = idLong++ episode.feedId = savedFeed.id if (episode.media != null) episode.media!!.id = episode.id @@ -247,7 +222,7 @@ object Feeds { Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate") episode.setNew() } - idLong += 1 +// idLong += 1 } } @@ -278,7 +253,7 @@ object Feeds { } else { persistFeedsSync(savedFeed) } - updateFeedList() + updateFeedMap() if (removeUnlistedItems) runBlocking { deleteEpisodes(context, unlistedItems).join() } } catch (e: InterruptedException) { e.printStackTrace() @@ -309,7 +284,6 @@ object Feeds { */ private fun searchEpisodeGuessDuplicate(episodes: List?, searchItem: Episode): Episode? { if (episodes.isNullOrEmpty()) return null - for (episode in episodes) { if (EpisodeDuplicateGuesser.sameAndNotEmpty(episode.identifier, searchItem.identifier)) return episode } @@ -330,75 +304,6 @@ object Feeds { """.trimIndent())) } - fun sortFeeds() { - Logd(TAG, "sortFeeds() called") - val feedOrder = feedOrder - val comparator: Comparator = when (feedOrder) { - UserPreferences.FEED_ORDER_UNPLAYED -> { - val episodes = realm.query(Episode::class).query("(playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED})").find() - val counterMap = counterMap(episodes) - comparator(counterMap) - } - UserPreferences.FEED_ORDER_ALPHABETICAL -> { - Comparator { lhs: Feed, rhs: Feed -> - val t1 = lhs.title - val t2 = rhs.title - when { - t1 == null -> 1 - t2 == null -> -1 - else -> t1.compareTo(t2, ignoreCase = true) - } - } - } - UserPreferences.FEED_ORDER_MOST_PLAYED -> { - val episodes = realm.query(Episode::class).query("playState == ${Episode.PLAYED}").find() - val counterMap = counterMap(episodes) - comparator(counterMap) - } - UserPreferences.FEED_ORDER_LAST_UPDATED -> { - val episodes = realm.query(Episode::class).find() - val counterMap: MutableMap = mutableMapOf() - for (episode in episodes) { - val feedId = episode.feedId ?: continue - val pDateOld = counterMap[feedId] ?: 0 - if (pDateOld < episode.pubDate) counterMap[feedId] = episode.pubDate - } - comparator(counterMap) - } - UserPreferences.FEED_ORDER_LAST_UNREAD_UPDATED -> { - val episodes = realm.query(Episode::class) - .query("playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}").find() - val counterMap: MutableMap = mutableMapOf() - for (episode in episodes) { - val feedId = episode.feedId ?: continue - val pDateOld = counterMap[feedId] ?: 0 - if (pDateOld < episode.pubDate) counterMap[feedId] = episode.pubDate - } - comparator(counterMap) - } - UserPreferences.FEED_ORDER_DOWNLOADED -> { - val episodes = realm.query(Episode::class).query("media.downloaded == 1").find() - val counterMap = counterMap(episodes) - comparator(counterMap) - } - UserPreferences.FEED_ORDER_DOWNLOADED_UNPLAYED -> { - val episodes = realm.query(Episode::class) - .query("(playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}) AND media.downloaded == 1").find() - val counterMap = counterMap(episodes) - comparator(counterMap) - } - // doing FEED_ORDER_NEW - else -> { - val episodes = realm.query(Episode::class).query("playState == ${Episode.NEW}").find() - val counterMap = counterMap(episodes) - comparator(counterMap) - } - } - synchronized(feeds) { - feeds.sortWith(comparator) - } - } - fun persistFeedLastUpdateFailed(feed: Feed, lastUpdateFailed: Boolean) : Job { Logd(TAG, "persistFeedLastUpdateFailed called") return runOnIOScope { @@ -408,9 +313,6 @@ object Feeds { } } - /** - * Updates download URL of a feed - */ fun updateFeedDownloadURL(original: String, updated: String) : Job { Logd(TAG, "updateFeedDownloadURL(original: $original, updated: $updated)") return runOnIOScope { @@ -436,11 +338,11 @@ object Feeds { Logd(TAG, "feed.episodes: ${feed.episodes.size}") for (episode in feed.episodes) { - episode.id = idLong + episode.id = idLong++ episode.feedId = feed.id if (episode.media != null) episode.media!!.id = episode.id // copyToRealm(episode) // no need if episodes is a relation of feed, otherwise yes. - idLong += 1 +// idLong += 1 } copyToRealm(feed) } @@ -478,28 +380,7 @@ object Feeds { * @param context A context that is used for opening a database connection. * @param feedId ID of the Feed that should be deleted. */ -// fun deleteFeed0(context: Context, feedId: Long) : Job { -// Logd(TAG, "deleteFeed called") -// return runOnDbThread { -// var feed = getFeed(feedId) -// if (feed != null) { -// deleteEpisodesSync(context, feed.episodes) -// realm.write { -// val feed_ = query(Feed::class).query("id == $0", feedId).first().find() -// if (feed_ != null) delete(feed_) -// } -// if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!) -// EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feed)) -// } -// } -// } - - /** - * Deletes a Feed and all downloaded files of its components like images and downloaded episodes. - * @param context A context that is used for opening a database connection. - * @param feedId ID of the Feed that should be deleted. - */ - fun deleteFeed(context: Context, feedId: Long) : Job { + fun deleteFeed(context: Context, feedId: Long, postEvent: Boolean = true) : Job { Logd(TAG, "deleteFeed called") return runOnIOScope { val feed = getFeed(feedId) @@ -519,7 +400,7 @@ object Feeds { } } if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!) - EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feed)) + if (postEvent) EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feed)) } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt index 7475cbef..bbada2fd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt @@ -25,7 +25,7 @@ object LogsAndStats { Logd(TAG, "addDownloadStatus called") return runOnIOScope { if (status != null) { - if (status.id == 0L) status.id = Date().time + if (status.id == 0L) status.setId() realm.write { copyToRealm(status) } @@ -48,7 +48,7 @@ object LogsAndStats { val result = StatisticsResult() result.oldestDate = Long.MAX_VALUE for (fid in groupdMedias.keys) { - val feed = getFeed(fid) ?: continue + val feed = getFeed(fid, false) ?: continue val episodes = feed.episodes.size.toLong() var feedPlayedTime = 0L var feedTotalTime = 0L diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/DownloadResult.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/DownloadResult.kt index 5211734f..3a4cc69a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/DownloadResult.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/DownloadResult.kt @@ -34,6 +34,7 @@ class DownloadResult( var reasonDetailed: String) : RealmObject { @PrimaryKey var id: Long = 0L + private set @Ignore private val completionDate = completionDate.clone() as Date @@ -43,13 +44,18 @@ class DownloadResult( /** * Constructor for creating new completed downloads. */ - constructor(id: Long, title: String, reason: DownloadError, successful: Boolean, reasonDetailed: String) - : this(title, id, EpisodeMedia.FEEDFILETYPE_FEEDMEDIA, successful, reason, Date(), reasonDetailed) + constructor(feedId: Long, title: String, reason: DownloadError, successful: Boolean, reasonDetailed: String) + : this(title, feedId, EpisodeMedia.FEEDFILETYPE_FEEDMEDIA, successful, reason, Date(), reasonDetailed) override fun toString(): String { return ("DownloadStatus [id=$id, title=$title, reason=$reason, reasonDetailed=$reasonDetailed, successful=$isSuccessful, completionDate=$completionDate, feedfileId=$feedfileId, feedfileType=$feedfileType]") } + fun setId() { + if (idCounter < 0) idCounter = Date().time + id = idCounter++ + } + fun getCompletionDate(): Date { return completionDate.clone() as Date } @@ -76,5 +82,7 @@ class DownloadResult( * so that the listadapters etc. can react properly. */ const val SIZE_UNKNOWN: Int = -1 + + var idCounter: Long = -1 } } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt index ddb0da78..9114b6c2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt @@ -198,7 +198,6 @@ class Episode : RealmObject { if (other.podcastIndexChapterUrl != null) podcastIndexChapterUrl = other.podcastIndexChapterUrl } - @JvmName("getPubDateFunction") fun getPubDate(): Date? { return if (pubDate > 0) Date(pubDate) else null @@ -220,7 +219,6 @@ class Episode : RealmObject { this.media = media } - fun setNew() { playState = NEW } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index e693c447..fab3719c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -19,7 +19,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.defaultPage import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent import ac.mdiq.podcini.receiver.PlayerWidget -import ac.mdiq.podcini.storage.database.Feeds.updateFeedList +import ac.mdiq.podcini.storage.database.Feeds.updateFeedMap import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter @@ -129,7 +129,7 @@ class MainActivity : CastEnabledActivity() { NavDrawerFragment.getSharedPrefs(this@MainActivity) SwipeActions.getSharedPrefs(this@MainActivity) QueueFragment.getSharedPrefs(this@MainActivity) - updateFeedList() + updateFeedMap() // InTheatre.apply { } PlayerDetailsFragment.getSharedPrefs(this@MainActivity) PlayerWidget.getSharedPrefs(this@MainActivity) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/DownloadLogDetailsDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/DownloadLogDetailsDialog.kt index 6c7b9d37..05ad7a02 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/DownloadLogDetailsDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/DownloadLogDetailsDialog.kt @@ -27,7 +27,7 @@ class DownloadLogDetailsDialog(context: Context, status: DownloadResult) : Mater if (media != null) url = media.downloadUrl?:"" } Feed.FEEDFILETYPE_FEED -> { - val feed = getFeed(status.feedfileId) + val feed = getFeed(status.feedfileId, false) if (feed != null) url = feed.downloadUrl?:"" } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt index e9ddfa8c..57b5243c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt @@ -4,6 +4,8 @@ import ac.mdiq.podcini.R import ac.mdiq.podcini.storage.database.Feeds.deleteFeed import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.event.EventFlow +import ac.mdiq.podcini.util.event.FlowEvent import android.app.ProgressDialog import android.content.Context import android.content.DialogInterface @@ -31,7 +33,6 @@ object RemoveFeedDialog { val dialog: ConfirmationDialog = object : ConfirmationDialog(context, R.string.remove_feed_label, message) { @OptIn(UnstableApi::class) override fun onConfirmButtonPressed(clickedDialog: DialogInterface) { callback?.run() - clickedDialog.dismiss() val progressDialog = ProgressDialog(context) @@ -46,8 +47,9 @@ object RemoveFeedDialog { withContext(Dispatchers.IO) { for (feed in feeds) { // runBlocking { deleteFeed(context, feed.id).join() } - deleteFeed(context, feed.id) + deleteFeed(context, feed.id, false) } + EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feeds)) } withContext(Dispatchers.Main) { Logd(TAG, "Feed(s) deleted") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 0f47a830..88fe708a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -15,6 +15,7 @@ import ac.mdiq.podcini.playback.PlaybackController.Companion.position import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive import ac.mdiq.podcini.playback.base.InTheatre.curMedia +import ac.mdiq.podcini.playback.base.InTheatre.loadPlayableFromPreferences import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.PlayerStatus @@ -96,8 +97,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar private var playerFragment2: InternalPlayerFragment? = null private var playerFragment: InternalPlayerFragment? = null - private lateinit var playerView1: View - private lateinit var playerView2: View + private var playerView1: View? = null + private var playerView2: View? = null private lateinit var cardViewSeek: CardView private lateinit var txtvSeek: TextView @@ -140,14 +141,14 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar .replace(R.id.playerFragment1, playerFragment1!!, "InternalPlayerFragment1") .commit() playerView1 = binding.root.findViewById(R.id.playerFragment1) - playerView1.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) + playerView1?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) playerFragment2 = InternalPlayerFragment.newInstance(controller!!) childFragmentManager.beginTransaction() .replace(R.id.playerFragment2, playerFragment2!!, "InternalPlayerFragment2") .commit() playerView2 = binding.root.findViewById(R.id.playerFragment2) - playerView2.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) + playerView2?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) onCollaped() @@ -463,8 +464,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar fun fadePlayerToToolbar(slideOffset: Float) { val playerFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.2f).toDouble())) / 0.2f).toFloat() val player = playerView1 - player.alpha = 1 - playerFadeProgress - player.visibility = if (playerFadeProgress > 0.99f) View.INVISIBLE else View.VISIBLE + player?.alpha = 1 - playerFadeProgress + player?.visibility = if (playerFadeProgress > 0.99f) View.INVISIBLE else View.VISIBLE val toolbarFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.6f).toDouble())) / 0.2f).toFloat() toolbar.setAlpha(toolbarFadeProgress) toolbar.visibility = if (toolbarFadeProgress < 0.01f) View.INVISIBLE else View.VISIBLE 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 eba04a9e..00c8768a 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 @@ -38,6 +38,7 @@ import ac.mdiq.podcini.util.sorting.EpisodesPermutors.getPermutor import android.app.Activity import android.content.Context import android.content.res.Configuration +import android.graphics.Color import android.os.Bundle import android.speech.tts.TextToSpeech import android.util.Log @@ -85,6 +86,8 @@ import java.util.concurrent.Semaphore private var feed: Feed? = null private var episodes: MutableList = mutableListOf() + private var enableFilter: Boolean = true + private val ioScope = CoroutineScope(Dispatchers.IO) override fun onCreate(savedInstanceState: Bundle?) { @@ -337,13 +340,17 @@ import java.util.concurrent.Semaphore adapter.updateItems(episodes) } FlowEvent.EpisodesFilterOrSortEvent.Action.FILTER_CHANGED -> { - feed!!.preferences?.filterString = event.feed.preferences?.filterString ?: "" - val episodes_ = feed!!.episodes.filter { feed!!.episodeFilter.matches(it) } episodes.clear() - episodes.addAll(episodes_) + if (enableFilter) { + feed!!.preferences?.filterString = event.feed.preferences?.filterString ?: "" + val episodes_ = feed!!.episodes.filter { feed!!.episodeFilter.matches(it) } + episodes.addAll(episodes_) + } else { + episodes.addAll(feed!!.episodes) + } val sortOrder = fromCode(feed!!.preferences?.sortOrderCode ?: 0) if (sortOrder != null) getPermutor(sortOrder).reorder(episodes) - binding.header.counts.text = episodes_.size.toString() + binding.header.counts.text = episodes.size.toString() adapter.updateItems(episodes) } } @@ -596,12 +603,22 @@ import java.util.concurrent.Semaphore } } binding.header.butFilter.setOnClickListener { - if (feed != null) { + if (enableFilter && feed != null) { val dialog = FeedEpisodeFilterDialog(feed) dialog.filter = feed!!.episodeFilter dialog.show(childFragmentManager, null) } } + binding.header.butFilter.setOnLongClickListener { + if (feed != null) { + enableFilter = !enableFilter + if (enableFilter) binding.header.butFilter.setColorFilter(Color.WHITE) + else binding.header.butFilter.setColorFilter(Color.RED) + onEpisodesFilterSortEvent(FlowEvent.EpisodesFilterOrSortEvent(FlowEvent.EpisodesFilterOrSortEvent.Action.FILTER_CHANGED, feed!!)) + } + true + } + binding.header.txtvFailure.setOnClickListener { showErrorDetails() } binding.header.counts.text = adapter.itemCount.toString() headerCreated = true diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index 4860532e..608129d1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -11,10 +11,8 @@ import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory import ac.mdiq.podcini.storage.database.Episodes.getEpisodesCount -import ac.mdiq.podcini.storage.database.Feeds.feeds -import ac.mdiq.podcini.storage.database.RealmDB.realm +import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.model.DatasetStats -import ac.mdiq.podcini.storage.model.PlayQueue import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.storage.utils.EpisodeFilter.Companion.unfiltered import ac.mdiq.podcini.ui.activity.MainActivity @@ -55,7 +53,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel -import io.realm.kotlin.query.Sort import kotlinx.coroutines.* import org.apache.commons.lang3.ArrayUtils import org.apache.commons.lang3.StringUtils @@ -408,7 +405,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { Logd(TAG, "getNavDrawerData() called") val numDownloadedItems = getEpisodesCount(EpisodeFilter(EpisodeFilter.DOWNLOADED)) val numItems = getEpisodesCount(unfiltered()) - val numFeeds = feeds.size + val numFeeds = getFeedList().size while (curQueue.name.isEmpty()) runBlocking { delay(100) } val queueSize = curQueue.episodeIds.size // if (queueSize == 0) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueueFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueueFragment.kt index 44ded812..0bf96c4d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueueFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueueFragment.kt @@ -132,7 +132,7 @@ import java.util.* binding.leftActionIcon.setOnClickListener { swipeActions.showDialog() } binding.rightActionIcon.setOnClickListener { swipeActions.showDialog() } - adapter = QueueRecyclerAdapter(activity as MainActivity, swipeActions) + adapter = QueueRecyclerAdapter() adapter?.setOnSelectModeListener(this) recyclerView.adapter = adapter @@ -260,7 +260,7 @@ import java.util.* for (e in event.episodes) { val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id) if (pos >= 0) { - Logd(TAG, "removing episode $pos ${queueItems[pos]} ${e}") + Logd(TAG, "removing episode $pos ${queueItems[pos]} $e") queueItems.removeAt(pos) adapter?.notifyItemRemoved(pos) } else { @@ -661,7 +661,7 @@ import java.util.* } } - private inner class QueueRecyclerAdapter(mainActivity: MainActivity, private val swipeActions: SwipeActions) : EpisodesAdapter(mainActivity) { + private inner class QueueRecyclerAdapter : EpisodesAdapter(activity as MainActivity) { private var dragDropEnabled: Boolean init { 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 658c5f33..f55a0070 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 @@ -3,11 +3,14 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.* import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.preferences.UserPreferences +import ac.mdiq.podcini.preferences.UserPreferences.feedOrder 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.Feeds.sortFeeds -import ac.mdiq.podcini.storage.database.Feeds.updateFeedList +import ac.mdiq.podcini.storage.database.Feeds.updateFeedMap +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.ui.actions.menuhandler.FeedMenuHandler @@ -33,7 +36,6 @@ import android.view.* import android.view.inputmethod.EditorInfo import android.widget.* import androidx.annotation.OptIn -import androidx.annotation.PluralsRes import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar import androidx.cardview.widget.CardView @@ -51,12 +53,12 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialView +import io.realm.kotlin.query.RealmResults import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.lang.ref.WeakReference import java.text.NumberFormat import java.util.* @@ -69,7 +71,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec private val binding get() = _binding!! private lateinit var subscriptionRecycler: RecyclerView - private lateinit var subscriptionAdapter: SubscriptionsAdapter + private lateinit var listAdapter: ListAdapter private lateinit var emptyView: EmptyViewHandler private lateinit var feedsInfoMsg: LinearLayout private lateinit var feedsFilteredMsg: TextView @@ -83,7 +85,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec private var displayedFolder: String = "" private var displayUpArrow = false - private var feedList: List = mutableListOf() + private var feedList: MutableList = mutableListOf() private var feedListFiltered: List = mutableListOf() override fun onCreate(savedInstanceState: Bundle?) { @@ -125,12 +127,12 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec // } // } // } - subscriptionAdapter = SubscriptionsAdapter(activity as MainActivity) + listAdapter = ListAdapter() val gridLayoutManager = GridLayoutManager(context, 1, RecyclerView.VERTICAL, false) subscriptionRecycler.layoutManager = gridLayoutManager - subscriptionAdapter.setOnSelectModeListener(this) - subscriptionRecycler.adapter = subscriptionAdapter + listAdapter.setOnSelectModeListener(this) + subscriptionRecycler.adapter = listAdapter setupEmptyView() resetTags() @@ -155,7 +157,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec val resultList = feedListFiltered.filter { it.title?.lowercase(Locale.getDefault())?.contains(text)?:false || it.author?.lowercase(Locale.getDefault())?.contains(text)?:false } - subscriptionAdapter.setItems(resultList) + listAdapter.setItems(resultList) true } else false } @@ -195,7 +197,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec override fun onToggleChanged(isOpen: Boolean) {} }) speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> - FeedMultiSelectActionHandler(activity as MainActivity, subscriptionAdapter.selectedItems.filterIsInstance()).handleAction(actionItem.id) + FeedMultiSelectActionHandler(activity as MainActivity, listAdapter.selectedItems.filterIsInstance()).handleAction(actionItem.id) true } @@ -211,7 +213,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec override fun onStop() { super.onStop() - subscriptionAdapter.endSelectMode() + listAdapter.endSelectMode() cancelFlowEvents() } @@ -240,7 +242,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } } feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() - subscriptionAdapter.setItems(feedListFiltered) + listAdapter.setItems(feedListFiltered) } private var eventSink: Job? = null @@ -300,16 +302,16 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec try { val result = withContext(Dispatchers.IO) { sortFeeds() - val feeds: List = getFeedList() - feeds + val fList: List = getFeedList() + fList } withContext(Dispatchers.Main) { // We have fewer items. This can result in items being selected that are no longer visible. - if ( feedListFiltered.size > result.size) subscriptionAdapter.endSelectMode() - feedList = result + if ( feedListFiltered.size > result.size) listAdapter.endSelectMode() + feedList = result.toMutableList() filterOnTag() progressBar.visibility = View.GONE - subscriptionAdapter.setItems(feedListFiltered) + listAdapter.setItems(feedListFiltered) feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() emptyView.updateVisibility() } @@ -322,26 +324,116 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec // else feedsFilteredMsg.visibility = View.GONE } + private fun sortFeeds() { + Logd(TAG, "sortFeeds() called") + val feedOrder = feedOrder + val comparator: Comparator = when (feedOrder) { + UserPreferences.FEED_ORDER_UNPLAYED -> { + val episodes = realm.query(Episode::class).query("(playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED})").find() + val counterMap = counterMap(episodes) + comparator(counterMap) + } + UserPreferences.FEED_ORDER_ALPHABETICAL -> { + Comparator { lhs: Feed, rhs: Feed -> + val t1 = lhs.title + val t2 = rhs.title + when { + t1 == null -> 1 + t2 == null -> -1 + else -> t1.compareTo(t2, ignoreCase = true) + } + } + } + UserPreferences.FEED_ORDER_MOST_PLAYED -> { + val episodes = realm.query(Episode::class).query("playState == ${Episode.PLAYED}").find() + val counterMap = counterMap(episodes) + comparator(counterMap) + } + UserPreferences.FEED_ORDER_LAST_UPDATED -> { + val episodes = realm.query(Episode::class).find() + val counterMap: MutableMap = mutableMapOf() + for (episode in episodes) { + val feedId = episode.feedId ?: continue + val pDateOld = counterMap[feedId] ?: 0 + if (pDateOld < episode.pubDate) counterMap[feedId] = episode.pubDate + } + comparator(counterMap) + } + UserPreferences.FEED_ORDER_LAST_UNREAD_UPDATED -> { + val episodes = realm.query(Episode::class) + .query("playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}").find() + val counterMap: MutableMap = mutableMapOf() + for (episode in episodes) { + val feedId = episode.feedId ?: continue + val pDateOld = counterMap[feedId] ?: 0 + if (pDateOld < episode.pubDate) counterMap[feedId] = episode.pubDate + } + comparator(counterMap) + } + UserPreferences.FEED_ORDER_DOWNLOADED -> { + val episodes = realm.query(Episode::class).query("media.downloaded == 1").find() + val counterMap = counterMap(episodes) + comparator(counterMap) + } + UserPreferences.FEED_ORDER_DOWNLOADED_UNPLAYED -> { + val episodes = realm.query(Episode::class) + .query("(playState == ${Episode.NEW} OR playState == ${Episode.UNPLAYED}) AND media.downloaded == 1").find() + val counterMap = counterMap(episodes) + comparator(counterMap) + } + // doing FEED_ORDER_NEW + else -> { + val episodes = realm.query(Episode::class).query("playState == ${Episode.NEW}").find() + val counterMap = counterMap(episodes) + comparator(counterMap) + } + } + feedList.sortWith(comparator) + } + + private fun counterMap(episodes: RealmResults): Map { + val counterMap: MutableMap = mutableMapOf() + for (episode in episodes) { + val feedId = episode.feedId ?: continue + val count = counterMap[feedId] ?: 0 + counterMap[feedId] = count + 1 + } + return counterMap + } + + private fun comparator(counterMap: Map): Comparator { + return Comparator { lhs: Feed, rhs: Feed -> + val counterLhs = counterMap[lhs.id]?:0 + val counterRhs = counterMap[rhs.id]?:0 + when { + // reverse natural order: podcast with most unplayed episodes first + counterLhs > counterRhs -> -1 + counterLhs == counterRhs -> lhs.title?.compareTo(rhs.title!!, ignoreCase = true) ?: -1 + else -> 1 + } + } + } + override fun onContextItemSelected(item: MenuItem): Boolean { - val feed: Feed = subscriptionAdapter.getSelectedItem() ?: return false + val feed: Feed = listAdapter.getSelectedItem() ?: return false val itemId = item.itemId if (itemId == R.id.multi_select) { speedDialView.visibility = View.VISIBLE - return subscriptionAdapter.onContextItemSelected(item) + return listAdapter.onContextItemSelected(item) } // TODO: this appears not called return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() } } private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent?) { - updateFeedList() + updateFeedMap() loadSubscriptions() } override fun onEndSelectMode() { speedDialView.close() speedDialView.visibility = View.GONE - subscriptionAdapter.setItems(feedListFiltered) + listAdapter.setItems(feedListFiltered) } override fun onStartSelectMode() { @@ -350,12 +442,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec for (item in feedListFiltered) { feedsOnly.add(item) } - subscriptionAdapter.setItems(feedsOnly) + listAdapter.setItems(feedsOnly) } @UnstableApi class FeedMultiSelectActionHandler(private val activity: MainActivity, private val selectedItems: List) { - fun handleAction(id: Int) { when (id) { R.id.remove_feed -> RemoveFeedDialog.show(activity, selectedItems) @@ -366,26 +457,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec R.id.autodownload -> autoDownloadPrefHandler() R.id.autoDeleteDownload -> autoDeleteEpisodesPrefHandler() R.id.playback_speed -> playbackSpeedPrefHandler() - R.id.edit_tags -> editFeedPrefTags() + R.id.edit_tags -> TagSettingsDialog.newInstance(selectedItems).show(activity.supportFragmentManager, TAG) else -> Log.e(TAG, "Unrecognized speed dial action item. Do nothing. id=$id") } } - -// private fun notifyNewEpisodesPrefHandler() { -// val preferenceSwitchDialog = PreferenceSwitchDialog(activity, -// activity.getString(R.string.episode_notification), -// activity.getString(R.string.episode_notification_summary)) -// -// preferenceSwitchDialog.setOnPreferenceChangedListener(object: PreferenceSwitchDialog.OnPreferenceChangedListener { -// @UnstableApi override fun preferenceChanged(enabled: Boolean) { -// saveFeedPreferences { feedPreferences: FeedPreferences -> -// feedPreferences.showEpisodeNotification = enabled -// } -// } -// }) -// preferenceSwitchDialog.openDialog() -// } - private fun autoDownloadPrefHandler() { val preferenceSwitchDialog = PreferenceSwitchDialog(activity, activity.getString(R.string.auto_download_settings_label), activity.getString(R.string.auto_download_label)) preferenceSwitchDialog.setOnPreferenceChangedListener(@UnstableApi object: PreferenceSwitchDialog.OnPreferenceChangedListener { @@ -395,7 +470,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec }) preferenceSwitchDialog.openDialog() } - @UnstableApi private fun playbackSpeedPrefHandler() { val viewBinding = PlaybackSpeedFeedSettingDialogBinding.inflate(activity.layoutInflater) viewBinding.seekBar.setProgressChangedListener { speed: Float? -> @@ -420,7 +494,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec .setNegativeButton(R.string.cancel_label, null) .show() } - private fun autoDeleteEpisodesPrefHandler() { val preferenceListDialog = PreferenceListDialog(activity, activity.getString(R.string.auto_delete_label)) val items: Array = activity.resources.getStringArray(R.array.spnAutoDeleteItems) @@ -434,7 +507,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } }) } - private fun keepUpdatedPrefHandler() { val preferenceSwitchDialog = PreferenceSwitchDialog(activity, activity.getString(R.string.kept_updated), activity.getString(R.string.keep_updated_summary)) preferenceSwitchDialog.setOnPreferenceChangedListener(object: PreferenceSwitchDialog.OnPreferenceChangedListener { @@ -446,11 +518,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec }) preferenceSwitchDialog.openDialog() } - - @UnstableApi private fun showMessage(@PluralsRes msgId: Int, numItems: Int) { - activity.showSnackbarAbovePlayer(activity.resources.getQuantityString(msgId, numItems, numItems), Snackbar.LENGTH_LONG) - } - @UnstableApi private fun saveFeedPreferences(preferencesConsumer: Consumer) { for (feed in selectedItems) { if (feed.preferences == null) continue @@ -458,22 +525,18 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec persistFeedPreferences(feed) // EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feed.preferences!!.feedID)) } - showMessage(R.plurals.updated_feeds_batch_label, selectedItems.size) + val numItems = selectedItems.size + activity.showSnackbarAbovePlayer(activity.resources.getQuantityString(R.plurals.updated_feeds_batch_label, numItems, numItems), Snackbar.LENGTH_LONG) } - - private fun editFeedPrefTags() { - TagSettingsDialog.newInstance(selectedItems).show(activity.supportFragmentManager, TAG) - } - companion object { private val TAG: String = FeedMultiSelectActionHandler::class.simpleName ?: "Anonymous" } } - private inner class SubscriptionsAdapter(mainActivity: MainActivity) - : SelectableAdapter(mainActivity), View.OnCreateContextMenuListener { + @OptIn(UnstableApi::class) + private inner class ListAdapter + : SelectableAdapter(activity as MainActivity), View.OnCreateContextMenuListener { - private val mainActivityRef: WeakReference = WeakReference(mainActivity) private var feedList: List private var selectedItem: Feed? = null private var longPressedPosition: Int = 0 // used to init actionMode @@ -499,11 +562,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec fun getSelectedItem(): Feed? { return selectedItem } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { - val itemView: View = LayoutInflater.from(mainActivityRef.get()).inflate(R.layout.subscription_item, parent, false) - return SubscriptionViewHolder(itemView) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val itemView: View = LayoutInflater.from(activity).inflate(R.layout.subscription_item, parent, false) + return ViewHolder(itemView) } - @UnstableApi override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) { + @UnstableApi override fun onBindViewHolder(holder: ViewHolder, position: Int) { val feed: Feed = feedList[position] holder.bind(feed) if (inActionMode()) { @@ -524,14 +587,14 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition)) else { val fragment: Fragment = FeedInfoFragment.newInstance(feed) - mainActivityRef.get()?.loadChildFragment(fragment) + (activity as MainActivity).loadChildFragment(fragment) } } holder.infoCard.setOnClickListener { if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition)) else { val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id) - mainActivityRef.get()?.loadChildFragment(fragment) + (activity as MainActivity).loadChildFragment(fragment) } } // holder.infoCard.setOnCreateContextMenuListener(this) @@ -565,9 +628,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec if (position >= feedList.size) return RecyclerView.NO_ID // Dummy views return feedList[position].id } + @OptIn(UnstableApi::class) override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { if (selectedItem == null) return - val mainActRef = mainActivityRef.get() ?: return + val mainActRef = (activity as MainActivity) val inflater: MenuInflater = mainActRef.menuInflater if (inActionMode()) { // inflater.inflate(R.menu.multi_select_context_popup, menu) @@ -593,7 +657,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec notifyDataSetChanged() } - private inner class SubscriptionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val binding = SubscriptionItemBinding.bind(itemView) private val title = binding.titleLabel private val producer = binding.producerLabel @@ -619,8 +683,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec count.text = NumberFormat.getInstance().format(counter.toLong()) + " episodes" count.visibility = View.VISIBLE - val mainActRef = mainActivityRef.get() ?: return - + val mainActRef = (activity as MainActivity) val coverLoader = CoverLoader(mainActRef) val feed: Feed = drawerItem coverLoader.withUri(feed.imageUrl) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/viewholder/EpisodeViewHolder.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/viewholder/EpisodeViewHolder.kt index e8e75d24..165298ab 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/viewholder/EpisodeViewHolder.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/viewholder/EpisodeViewHolder.kt @@ -110,7 +110,7 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro container.alpha = if (item.isPlayed()) 0.75f else 1.0f val newButton = EpisodeActionButton.forItem(item) - Logd(TAG, "Trying to bind button ${actionButton?.TAG} ${newButton.TAG} ${item.title}") +// Logd(TAG, "Trying to bind button ${actionButton?.TAG} ${newButton.TAG} ${item.title}") // not using a new button to ensure valid progress values, for TTS audio generation if (!(actionButton?.TAG == TTSActionButton::class.simpleName && newButton.TAG == TTSActionButton::class.simpleName)) { actionButton = newButton diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/event/FlowEvent.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/event/FlowEvent.kt index 5cc441dd..3eb4c2cd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/event/FlowEvent.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/event/FlowEvent.kt @@ -127,7 +127,7 @@ sealed class FlowEvent { data class FeedListUpdateEvent(val feedIds: List = emptyList()) : FlowEvent() { constructor(feed: Feed) : this(listOf(feed.id)) constructor(feedId: Long) : this(listOf(feedId)) - constructor(feeds: List, junkInfo: String = "") : this(feeds.map { it.id }) + constructor(feeds: List, junk: String = "") : this(feeds.map { it.id }) fun contains(feed: Feed): Boolean { return feedIds.contains(feed.id) diff --git a/changelog.md b/changelog.md index fd7d455d..5aa28dfa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,13 @@ +## 6.0.1 + +* removing a list of feeds is speedier +* fixed issue of not starting the next in queue +* queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played +* fixed crash issue in AudioPlayer due to view uninitialized +* improved efficiency of getFeed +* Tapping the media playback notification opens Podcini +* Long-press filter button in FeedEpisode view enables/disables filters without changing filter settings + ## 6.0.0 * complete overhaul of database and routines, ditched the iron-age celebrity SQLite and entrusted the modern object-based Realm diff --git a/fastlane/metadata/android/en-US/changelogs/3020201.txt b/fastlane/metadata/android/en-US/changelogs/3020201.txt new file mode 100644 index 00000000..7a366994 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020201.txt @@ -0,0 +1,10 @@ + +Version 6.0.1 brings several changes: + +* removing a list of feeds is speedier +* fixed issue of not starting the next in queue +* queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played +* fixed crash issue in AudioPlayer due to view uninitialized +* improved efficiency of getFeed +* Tapping the media playback notification opens Podcini +* Long-press filter button in FeedEpisode view enables/disables filters without changing filter settings