diff --git a/app/build.gradle b/app/build.gradle index 7d9af90b..e6a5a25e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -125,8 +125,8 @@ android { buildConfig true } defaultConfig { - versionCode 3020204 - versionName "6.0.4" + versionCode 3020205 + versionName "6.0.5" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/download/HttpDownloaderTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/download/HttpDownloaderTest.kt index 8b5f120f..7ec37769 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/download/HttpDownloaderTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/download/HttpDownloaderTest.kt @@ -6,7 +6,6 @@ import ac.mdiq.podcini.net.download.service.HttpDownloader import ac.mdiq.podcini.net.download.serviceinterface.DownloadRequest import ac.mdiq.podcini.preferences.UserPreferences.init import ac.mdiq.podcini.util.Logd -import ac.test.podcini.service.download.FeedComponent import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import de.test.podcini.util.service.download.HTTPBin @@ -245,3 +244,48 @@ abstract class FeedFile(@JvmField var file_url: String? = null, this.downloaded = downloaded } } + +/** + * Represents every possible component of a feed + * + * @author daniel + */ +// only used in test +abstract class FeedComponent internal constructor() { + open var id: Long = 0 + + /** + * Update this FeedComponent's attributes with the attributes from another + * FeedComponent. This method should only update attributes which where read from + * the feed. + */ + fun updateFromOther(other: FeedComponent?) {} + + /** + * Compare's this FeedComponent's attribute values with another FeedComponent's + * attribute values. This method will only compare attributes which were + * read from the feed. + * + * @return true if attribute values are different, false otherwise + */ + fun compareWithOther(other: FeedComponent?): Boolean { + return false + } + + /** + * Should return a non-null, human-readable String so that the item can be + * identified by the user. Can be title, download-url, etc. + */ + abstract fun getHumanReadableIdentifier(): String? + + override fun equals(o: Any?): Boolean { + if (this === o) return true + if (o !is FeedComponent) return false + + return id == o.id + } + + override fun hashCode(): Int { + return (id xor (id ushr 32)).toInt() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt index a9c2278b..b36b595c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt @@ -38,6 +38,7 @@ import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.apache.commons.io.FileUtils import java.io.File import java.io.IOException @@ -114,7 +115,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() { .addTag(WORK_TAG) .addTag(WORK_TAG_EPISODE_URL + item.media!!.downloadUrl) if (UserPreferences.enqueueDownloadedEpisodes()) { - Queues.addToQueue(false, item) + runBlocking { Queues.addToQueueSync(false, item) } workRequest.addTag(WORK_DATA_WAS_QUEUED) } workRequest.setInputData(Data.Builder().putLong(WORK_DATA_MEDIA_ID, item.media!!.id).build()) @@ -388,10 +389,11 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() { if (item != null) { item.disableAutoDownload() Logd(TAG, "persisting episode downloaded ${item.title} ${item.media?.fileUrl} ${item.media?.downloaded}") - // setFeedItem() signals (via EventBus) that the item has been updated, + // setFeedItem() signals that the item has been updated, // so we do it after the enclosing media has been updated above, // to ensure subscribers will get the updated EpisodeMedia as well Episodes.persistEpisode(item) +// TODO: should use different event? if (broadcastUnreadStateUpdate) EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(item)) } } catch (e: InterruptedException) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt index bd21f870..4badef84 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt @@ -15,10 +15,9 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi import ac.mdiq.podcini.net.utils.NetworkUtils.networkAvailable import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia -import ac.mdiq.podcini.storage.database.Episodes import ac.mdiq.podcini.storage.database.Feeds import ac.mdiq.podcini.storage.database.LogsAndStats -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.DownloadResult import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting @@ -206,7 +205,7 @@ object FeedUpdateManager { while (toUpdate.isNotEmpty()) { if (isStopped) return notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate)) - val feed = unmanagedCopy(toUpdate[0]) + val feed = unmanaged(toUpdate[0]) try { Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}") if (feed.isLocalFeed) LocalFeedUpdater.updateFeed(feed, applicationContext, null) 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 75187141..08570124 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 @@ -21,7 +21,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileSync 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.deleteFeedSync import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedListDownloadUrls import ac.mdiq.podcini.storage.database.Feeds.updateFeed @@ -44,11 +44,8 @@ import androidx.core.app.NotificationCompat import androidx.media3.common.util.UnstableApi import androidx.work.* import androidx.work.Constraints.Builder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch import org.apache.commons.lang3.StringUtils import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit @@ -168,7 +165,10 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont } if (feedID != null) { try { - deleteFeed(context, feedID) + runBlocking { + deleteFeedSync(context, feedID) + EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feedID)) + } } catch (e: InterruptedException) { e.printStackTrace() } catch (e: ExecutionException) { @@ -185,7 +185,7 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont EventFlow.stickyEvents.collectLatest { event -> Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { - is FlowEvent.FeedUpdateRunningEvent -> if (!event.isFeedUpdateRunning) return@collectLatest + is FlowEvent.FeedUpdatingEvent -> if (!event.isRunning) return@collectLatest else -> {} } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt index 8ddcc253..97f6ae78 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt @@ -9,6 +9,7 @@ import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.service.PlaybackService.Companion.EXTRA_ALLOW_STREAM_THIS_TIME +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow @@ -44,8 +45,12 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl fun start() { Logd("PlaybackServiceStarter", "starting PlaybackService") if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END)) - curMedia = media - if (media is EpisodeMedia) curEpisode = media.episode + if (media is EpisodeMedia) { + curMedia = media +// curEpisode = if (media.episode != null) unmanaged(media.episode!!) else null + curEpisode = media.episode +// curMedia = curEpisode?.media + } else curMedia = media if (PlaybackService.isRunning && !callEvenIfRunning) return ContextCompat.startForegroundService(context, intent) 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 655a9c16..f31a3f30 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 @@ -18,15 +18,15 @@ import kotlinx.coroutines.* object InTheatre { val TAG: String = InTheatre::class.simpleName ?: "Anonymous" - var curQueue: PlayQueue + var curQueue: PlayQueue // unmanaged - var curEpisode: Episode? = null + var curEpisode: Episode? = null // unmanged set(value) { field = value if (curMedia != field?.media) curMedia = field?.media } - var curMedia: Playable? = null + var curMedia: Playable? = null // unmanged if EpisodeMedia set(value) { field = value if (field is EpisodeMedia) { @@ -35,7 +35,7 @@ object InTheatre { } } - var curState: CurrentState + var curState: CurrentState // unmanaged init { curQueue = PlayQueue() @@ -60,9 +60,7 @@ object InTheatre { curQueue_.id = i.toLong() curQueue_.name = "Queue $i" } - realm.write { - copyToRealm(curQueue_) - } + upsert(curQueue_) {} } curQueue.update() upsert(curQueue) {} @@ -75,9 +73,7 @@ object InTheatre { Logd(TAG, "creating new curState") curState_ = CurrentState() curState = curState_ - realm.write { - copyToRealm(curState_) - } + upsert(curState_) {} } loadPlayableFromPreferences() } 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 a264778f..a987f83a 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 @@ -238,8 +238,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP } // stop playback of this episode if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop() - if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) - callback.onPostPlayback(prevMedia, ended = false, skipped = false, true) +// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) +// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true) prevMedia = curMedia setPlayerStatus(PlayerStatus.INDETERMINATE, null) } 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 29c73aa5..ab37346a 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 @@ -32,18 +32,20 @@ import ac.mdiq.podcini.preferences.UserPreferences.isPersistNotify import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnBluetoothReconnect import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnHeadsetReconnect import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs +import ac.mdiq.podcini.preferences.UserPreferences.shouldAutoDeleteItem +import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue import ac.mdiq.podcini.preferences.UserPreferences.shouldFavoriteKeepEpisode import ac.mdiq.podcini.preferences.UserPreferences.shouldSkipKeepEpisode import ac.mdiq.podcini.receiver.MediaButtonReceiver import ac.mdiq.podcini.storage.database.Episodes.addToHistory -import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode +import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl -import ac.mdiq.podcini.storage.database.Episodes.markPlayed +import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync import ac.mdiq.podcini.storage.database.Episodes.persistEpisode -import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItemsOnFeed import ac.mdiq.podcini.storage.database.Queues.addToQueue +import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.CurrentState.Companion.NO_MEDIA_PLAYING import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_OTHER @@ -292,7 +294,7 @@ class PlaybackService : MediaSessionService() { // TODO: test // return } - val item = (playable as? EpisodeMedia)?.episode ?: currentitem + var item = (playable as? EpisodeMedia)?.episode ?: currentitem val smartMarkAsPlayed = hasAlmostEnded(playable) if (!ended && smartMarkAsPlayed) Logd(TAG, "smart mark as played") @@ -311,22 +313,21 @@ class PlaybackService : MediaSessionService() { } } if (item != null) { - if (ended || smartMarkAsPlayed || autoSkipped || (skipped && !shouldSkipKeepEpisode())) { - Logd(TAG, "onPostPlayback ended: $ended smartMarkAsPlayed: $smartMarkAsPlayed autoSkipped: $autoSkipped skipped: $skipped") - // only mark the item as played if we're not keeping it anyways - markPlayed(Episode.PLAYED, ended || (skipped && smartMarkAsPlayed), item) - // don't know if it actually matters to not autodownload when smart mark as played is triggered -// removeFromQueue(this@PlaybackService, ended, item) - // Delete episode if enabled - val action = item.feed?.preferences?.currentAutoDelete - val shouldAutoDelete = (action == AutoDeleteAction.ALWAYS - || (action == AutoDeleteAction.GLOBAL && item.feed != null && shouldAutoDeleteItemsOnFeed(item.feed!!))) - if (playable is EpisodeMedia && shouldAutoDelete && (!item.isFavorite || !shouldFavoriteKeepEpisode())) { - deleteMediaOfEpisode(this@PlaybackService, item) - Logd(TAG, "Episode Deleted") + runOnIOScope { + if (ended || smartMarkAsPlayed || autoSkipped || (skipped && !shouldSkipKeepEpisode())) { + Logd(TAG, "onPostPlayback ended: $ended smartMarkAsPlayed: $smartMarkAsPlayed autoSkipped: $autoSkipped skipped: $skipped") + // only mark the item as played if we're not keeping it anyways + item = setPlayStateSync(Episode.PLAYED, ended || (skipped && smartMarkAsPlayed), item!!) + val action = item?.feed?.preferences?.currentAutoDelete + val shouldAutoDelete = (action == AutoDeleteAction.ALWAYS || + (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 (playable is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item!!) } - if (playable is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item) } } @@ -425,9 +426,8 @@ class PlaybackService : MediaSessionService() { fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) { Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}") - if (playable == null) { - writeNoMediaPlaying() - } else { + if (playable == null) writeNoMediaPlaying() + else { curState.curMediaType = playable.getPlayableType().toLong() curState.curIsVideo = playable.getMediaType() == MediaType.VIDEO if (playable is EpisodeMedia) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemeSwitcher.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemeSwitcher.kt index 33dbffb2..821fee5e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemeSwitcher.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ThemeSwitcher.kt @@ -24,12 +24,9 @@ object ThemeSwitcher { val dynamic = UserPreferences.isThemeColorTinted return when (readThemeValue(context)) { UserPreferences.ThemePreference.DARK -> if (dynamic) R.style.Theme_Podcini_Dynamic_Dark_NoTitle else R.style.Theme_Podcini_Dark_NoTitle - UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_NoTitle - else R.style.Theme_Podcini_TrueBlack_NoTitle - UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle - else R.style.Theme_Podcini_Light_NoTitle - else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle - else R.style.Theme_Podcini_Light_NoTitle + UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_NoTitle else R.style.Theme_Podcini_TrueBlack_NoTitle + UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle else R.style.Theme_Podcini_Light_NoTitle + else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle else R.style.Theme_Podcini_Light_NoTitle } } @@ -38,14 +35,10 @@ object ThemeSwitcher { fun getTranslucentTheme(context: Context): Int { val dynamic = UserPreferences.isThemeColorTinted return when (readThemeValue(context)) { - UserPreferences.ThemePreference.DARK -> if (dynamic) R.style.Theme_Podcini_Dynamic_Dark_Translucent - else R.style.Theme_Podcini_Dark_Translucent - UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_Translucent - else R.style.Theme_Podcini_TrueBlack_Translucent - UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent - else R.style.Theme_Podcini_Light_Translucent - else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent - else R.style.Theme_Podcini_Light_Translucent + UserPreferences.ThemePreference.DARK -> if (dynamic) R.style.Theme_Podcini_Dynamic_Dark_Translucent else R.style.Theme_Podcini_Dark_Translucent + UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_Translucent else R.style.Theme_Podcini_TrueBlack_Translucent + UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent else R.style.Theme_Podcini_Light_Translucent + else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent else R.style.Theme_Podcini_Light_Translucent } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt index 9250b610..5380ba6a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -1,5 +1,6 @@ package ac.mdiq.podcini.preferences +import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.ProxyConfig import ac.mdiq.podcini.storage.utils.SortOrder import ac.mdiq.podcini.storage.utils.MediaType @@ -66,6 +67,7 @@ object UserPreferences { private const val PREF_HARDWARE_PREVIOUS_BUTTON: String = "prefHardwarePreviousButton" const val PREF_FOLLOW_QUEUE: String = "prefFollowQueue" const val PREF_SKIP_KEEPS_EPISODE: String = "prefSkipKeepsEpisode" + const val PREF_REMOVDE_FROM_QUEUE_MARKED_PLAYED: String = "prefRemoveFromQueueMarkedPlayed" private const val PREF_FAVORITE_KEEPS_EPISODE = "prefFavoriteKeepsEpisode" private const val PREF_AUTO_DELETE = "prefAutoDelete" private const val PREF_AUTO_DELETE_LOCAL = "prefAutoDeleteLocal" @@ -139,18 +141,6 @@ object UserPreferences { private lateinit var context: Context lateinit var appPrefs: SharedPreferences - /** - * Sets up the UserPreferences class. - * - * @throws IllegalArgumentException if context is null - */ - fun init(context: Context) { - Logd(TAG, "Creating new instance of UserPreferences") - UserPreferences.context = context.applicationContext - appPrefs = PreferenceManager.getDefaultSharedPreferences(context) - - createNoMediaFile() - } var theme: ThemePreference get() = when (appPrefs.getString(PREF_THEME, "system")) { @@ -203,30 +193,6 @@ object UserPreferences { val isAutoBackupOPML: Boolean get() = appPrefs.getBoolean(PREF_OPML_BACKUP, true) - /** - * Helper function to return whether the specified button should be shown on full - * notifications. - * - * @param buttonId Either NOTIFICATION_BUTTON_REWIND, NOTIFICATION_BUTTON_FAST_FORWARD, - * NOTIFICATION_BUTTON_SKIP, NOTIFICATION_BUTTON_PLAYBACK_SPEED - * or NOTIFICATION_BUTTON_NEXT_CHAPTER. - * @return `true` if button should be shown, `false` otherwise - */ - private fun showButtonOnFullNotification(buttonId: Int): Boolean { - return fullNotificationButtons.contains(buttonId) - } - - fun showSkipOnFullNotification(): Boolean { - return showButtonOnFullNotification(NOTIFICATION_BUTTON_SKIP) - } - - fun showNextChapterOnFullNotification(): Boolean { - return showButtonOnFullNotification(NOTIFICATION_BUTTON_NEXT_CHAPTER) - } - - fun showPlaybackSpeedOnFullNotification(): Boolean { - return showButtonOnFullNotification(NOTIFICATION_BUTTON_PLAYBACK_SPEED) - } val feedOrder: Int get() { @@ -234,12 +200,6 @@ object UserPreferences { return value!!.toInt() } - fun setFeedOrder(selected: String?) { - appPrefs.edit() - .putString(PREF_DRAWER_FEED_ORDER, selected) - .apply() - } - val useGridLayout: Boolean get() = appPrefs.getBoolean(PREF_FEED_GRID_LAYOUT, false) @@ -249,24 +209,6 @@ object UserPreferences { val useEpisodeCoverSetting: Boolean get() = appPrefs.getBoolean(PREF_USE_EPISODE_COVER, true) - /** - * @return `true` if we should show remaining time or the duration - */ - fun shouldShowRemainingTime(): Boolean { - return appPrefs.getBoolean(PREF_SHOW_TIME_LEFT, false) - } - - /** - * Sets the preference for whether we show the remain time, if not show the duration. This will - * send out events so the current playing screen, queue and the episode list would refresh - * - * @return `true` if we should show remaining time or the duration - */ - - fun setShowRemainTimeSetting(showRemain: Boolean?) { - appPrefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain!!).apply() - } - /** * @return `true` if notifications are persistent, `false` otherwise */ @@ -279,10 +221,6 @@ object UserPreferences { val showDownloadReportRaw: Boolean get() = appPrefs.getBoolean(PREF_SHOW_DOWNLOAD_REPORT, true) - fun enqueueDownloadedEpisodes(): Boolean { - return appPrefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true) - } - var enqueueLocation: EnqueueLocation get() { val valStr = appPrefs.getString(PREF_ENQUEUE_LOCATION, EnqueueLocation.BACK.name) @@ -323,14 +261,6 @@ object UserPreferences { appPrefs.edit().putBoolean(PREF_FOLLOW_QUEUE, value).apply() } - fun shouldSkipKeepEpisode(): Boolean { - return appPrefs.getBoolean(PREF_SKIP_KEEPS_EPISODE, true) - } - - fun shouldFavoriteKeepEpisode(): Boolean { - return appPrefs.getBoolean(PREF_FAVORITE_KEEPS_EPISODE, true) - } - val isAutoDelete: Boolean get() = appPrefs.getBoolean(PREF_AUTO_DELETE, false) @@ -340,14 +270,6 @@ object UserPreferences { val smartMarkAsPlayedSecs: Int get() = appPrefs.getString(PREF_SMART_MARK_AS_PLAYED_SECS, "30")!!.toInt() - fun shouldDeleteRemoveFromQueue(): Boolean { - return appPrefs.getBoolean(PREF_DELETE_REMOVES_FROM_QUEUE, false) - } - - fun getPlaybackSpeed(mediaType: MediaType): Float { - return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed - } - private val audioPlaybackSpeed: Float get() { try { @@ -405,23 +327,12 @@ object UserPreferences { appPrefs.edit().putString(PREF_PLAYBACK_SPEED_ARRAY, jsonArray.toString()).apply() } - fun shouldPauseForFocusLoss(): Boolean { - return appPrefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true) - } - val updateInterval: Long get() = appPrefs.getString(PREF_UPDATE_INTERVAL, "12")!!.toInt().toLong() val isAutoUpdateDisabled: Boolean get() = updateInterval == 0L - private fun isAllowMobileFor(type: String): Boolean { - val defaultValue = HashSet() - defaultValue.add("images") - val allowed = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue) - return allowed!!.contains(type) - } - var isAllowMobileFeedRefresh: Boolean get() = isAllowMobileFor("feed_refresh") set(allow) { @@ -458,17 +369,6 @@ object UserPreferences { setAllowMobileFor("images", allow) } - private fun setAllowMobileFor(type: String, allow: Boolean) { - val defaultValue = HashSet() - defaultValue.add("images") - val getValueStringSet = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue) - val allowed: MutableSet = HashSet(getValueStringSet!!) - if (allow) allowed.add(type) - else allowed.remove(type) - - appPrefs.edit().putStringSet(PREF_MOBILE_UPDATE, allowed).apply() - } - /** * Returns the capacity of the episode cache. This method will return the * negative integer EPISODE_CACHE_SIZE_UNLIMITED if the cache size is set to @@ -569,6 +469,203 @@ object UserPreferences { appPrefs.edit().putBoolean(PREF_QUEUE_LOCKED, locked).apply() } + /** + * Used for migration of the preference to system notification channels. + */ + val gpodnetNotificationsEnabledRaw: Boolean + get() = appPrefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true) + + var episodeCleanupValue: Int + get() = appPrefs.getString(PREF_EPISODE_CLEANUP, "" + EPISODE_CLEANUP_NULL)!!.toInt() + set(episodeCleanupValue) { + appPrefs.edit().putString(PREF_EPISODE_CLEANUP, episodeCleanupValue.toString()).apply() + } + + var defaultPage: String? + get() = appPrefs.getString(PREF_DEFAULT_PAGE, "SubscriptionsFragment") + set(defaultPage) { + appPrefs.edit().putString(PREF_DEFAULT_PAGE, defaultPage).apply() + } + + var isStreamOverDownload: Boolean + get() = appPrefs.getBoolean(PREF_STREAM_OVER_DOWNLOAD, false) + set(stream) { + appPrefs.edit().putBoolean(PREF_STREAM_OVER_DOWNLOAD, stream).apply() + } + + var isQueueKeepSorted: Boolean + /** + * Returns if the queue is in keep sorted mode. + * @see .queueKeepSortedOrder + */ + get() = appPrefs.getBoolean(PREF_QUEUE_KEEP_SORTED, false) + /** + * Enables/disables the keep sorted mode of the queue. + * @see .queueKeepSortedOrder + */ + set(keepSorted) { + appPrefs.edit().putBoolean(PREF_QUEUE_KEEP_SORTED, keepSorted).apply() + } + + var queueKeepSortedOrder: SortOrder? + /** + * 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(PREF_QUEUE_KEEP_SORTED_ORDER, "use-default") + return SortOrder.parseWithDefault(sortOrderStr, SortOrder.DATE_NEW_OLD) + } + /** + * Sets the sort order for the queue keep sorted mode. + * @see .setQueueKeepSorted + */ + set(sortOrder) { + if (sortOrder == null) return + appPrefs.edit().putString(PREF_QUEUE_KEEP_SORTED_ORDER, sortOrder.name).apply() + } + +// the sort order for the downloads. + var downloadsSortedOrder: SortOrder? + get() { + val sortOrderStr = appPrefs.getString(PREF_DOWNLOADS_SORTED_ORDER, "" + SortOrder.DATE_NEW_OLD.code) + return SortOrder.fromCodeString(sortOrderStr) + } + set(sortOrder) { + appPrefs.edit().putString(PREF_DOWNLOADS_SORTED_ORDER, "" + sortOrder!!.code).apply() + } + + var allEpisodesSortOrder: SortOrder? + get() = SortOrder.fromCodeString(appPrefs.getString(PREF_SORT_ALL_EPISODES, "" + SortOrder.DATE_NEW_OLD.code)) + set(s) { + appPrefs.edit().putString(PREF_SORT_ALL_EPISODES, "" + s!!.code).apply() + } + + var prefFilterAllEpisodes: String + get() = appPrefs.getString(PREF_FILTER_ALL_EPISODES, "")?:"" + set(filter) { + appPrefs.edit().putString(PREF_FILTER_ALL_EPISODES, filter).apply() + } + + /** + * Sets up the UserPreferences class. + * @throws IllegalArgumentException if context is null + */ + fun init(context: Context) { + Logd(TAG, "Creating new instance of UserPreferences") + UserPreferences.context = context.applicationContext + appPrefs = PreferenceManager.getDefaultSharedPreferences(context) + + createNoMediaFile() + } + + /** + * Helper function to return whether the specified button should be shown on full + * notifications. + * @param buttonId Either NOTIFICATION_BUTTON_REWIND, NOTIFICATION_BUTTON_FAST_FORWARD, + * NOTIFICATION_BUTTON_SKIP, NOTIFICATION_BUTTON_PLAYBACK_SPEED + * or NOTIFICATION_BUTTON_NEXT_CHAPTER. + * @return `true` if button should be shown, `false` otherwise + */ + private fun showButtonOnFullNotification(buttonId: Int): Boolean { + return fullNotificationButtons.contains(buttonId) + } + + @JvmStatic + fun shouldAutoDeleteItem(feed: Feed): Boolean { + if (!isAutoDelete) return false + return !feed.isLocalFeed || isAutoDeleteLocal + } + + fun showSkipOnFullNotification(): Boolean { + return showButtonOnFullNotification(NOTIFICATION_BUTTON_SKIP) + } + + fun showNextChapterOnFullNotification(): Boolean { + return showButtonOnFullNotification(NOTIFICATION_BUTTON_NEXT_CHAPTER) + } + + fun showPlaybackSpeedOnFullNotification(): Boolean { + return showButtonOnFullNotification(NOTIFICATION_BUTTON_PLAYBACK_SPEED) + } + + /** + * @return `true` if we should show remaining time or the duration + */ + fun shouldShowRemainingTime(): Boolean { + return appPrefs.getBoolean(PREF_SHOW_TIME_LEFT, false) + } + + fun setFeedOrder(selected: String?) { + appPrefs.edit() + .putString(PREF_DRAWER_FEED_ORDER, selected) + .apply() + } + + fun enqueueDownloadedEpisodes(): Boolean { + return appPrefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true) + } + + /** + * Sets the preference for whether we show the remain time, if not show the duration. This will + * send out events so the current playing screen, queue and the episode list would refresh + * @return `true` if we should show remaining time or the duration + */ + fun setShowRemainTimeSetting(showRemain: Boolean?) { + appPrefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain!!).apply() + } + + fun shouldSkipKeepEpisode(): Boolean { + return appPrefs.getBoolean(PREF_SKIP_KEEPS_EPISODE, true) + } + + fun shouldRemoveFromQueuesMarkPlayed(): Boolean { + return appPrefs.getBoolean(PREF_REMOVDE_FROM_QUEUE_MARKED_PLAYED, true) + } + + fun shouldFavoriteKeepEpisode(): Boolean { + return appPrefs.getBoolean(PREF_FAVORITE_KEEPS_EPISODE, true) + } + + fun shouldDeleteRemoveFromQueue(): Boolean { + return appPrefs.getBoolean(PREF_DELETE_REMOVES_FROM_QUEUE, false) + } + + fun getPlaybackSpeed(mediaType: MediaType): Float { + return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed + } + + fun shouldPauseForFocusLoss(): Boolean { + return appPrefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true) + } + + private fun isAllowMobileFor(type: String): Boolean { + val defaultValue = HashSet() + defaultValue.add("images") + val allowed = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue) + return allowed!!.contains(type) + } + + private fun setAllowMobileFor(type: String, allow: Boolean) { + val defaultValue = HashSet() + defaultValue.add("images") + val getValueStringSet = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue) + val allowed: MutableSet = HashSet(getValueStringSet!!) + if (allow) allowed.add(type) + else allowed.remove(type) + + appPrefs.edit().putStringSet(PREF_MOBILE_UPDATE, allowed).apply() + } + + fun backButtonOpensDrawer(): Boolean { + return appPrefs.getBoolean(PREF_BACK_OPENS_DRAWER, false) + } + + fun timeRespectsSpeed(): Boolean { + return appPrefs.getBoolean(PREF_TIME_RESPECTS_SPEED, false) + } + fun setPlaybackSpeed(speed: Float) { appPrefs.edit().putString(PREF_PLAYBACK_SPEED, speed.toString()).apply() } @@ -580,18 +677,12 @@ object UserPreferences { fun setAutodownloadSelectedNetworks(value: Array?) { appPrefs.edit().putString(PREF_AUTODL_SELECTED_NETWORKS, value!!.joinToString()).apply() } - + fun gpodnetNotificationsEnabled(): Boolean { if (Build.VERSION.SDK_INT >= 26) return true // System handles notification preferences return appPrefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true) } - /** - * Used for migration of the preference to system notification channels. - */ - val gpodnetNotificationsEnabledRaw: Boolean - get() = appPrefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true) - fun setGpodnetNotificationsEnabled() { appPrefs.edit().putBoolean(PREF_GPODNET_NOTIFICATIONS, true).apply() } @@ -614,16 +705,9 @@ object UserPreferences { return mutableListOf(1.0f, 1.25f, 1.5f) } - var episodeCleanupValue: Int - get() = appPrefs.getString(PREF_EPISODE_CLEANUP, "" + EPISODE_CLEANUP_NULL)!!.toInt() - set(episodeCleanupValue) { - appPrefs.edit().putString(PREF_EPISODE_CLEANUP, episodeCleanupValue.toString()).apply() - } - /** * Return the folder where the app stores all of its data. This method will * return the standard data folder if none has been set by the user. - * * @param type The name of the folder inside the data folder. May be null * when accessing the root of the data folder. * @return The data folder that has been requested or null if the folder could not be created. @@ -680,83 +764,6 @@ object UserPreferences { } } - var defaultPage: String? - get() = appPrefs.getString(PREF_DEFAULT_PAGE, "SubscriptionsFragment") - set(defaultPage) { - appPrefs.edit().putString(PREF_DEFAULT_PAGE, defaultPage).apply() - } - - fun backButtonOpensDrawer(): Boolean { - return appPrefs.getBoolean(PREF_BACK_OPENS_DRAWER, false) - } - - fun timeRespectsSpeed(): Boolean { - return appPrefs.getBoolean(PREF_TIME_RESPECTS_SPEED, false) - } - - var isStreamOverDownload: Boolean - get() = appPrefs.getBoolean(PREF_STREAM_OVER_DOWNLOAD, false) - set(stream) { - appPrefs.edit().putBoolean(PREF_STREAM_OVER_DOWNLOAD, stream).apply() - } - - var isQueueKeepSorted: Boolean - /** - * Returns if the queue is in keep sorted mode. - * - * @see .queueKeepSortedOrder - */ - get() = appPrefs.getBoolean(PREF_QUEUE_KEEP_SORTED, false) - /** - * Enables/disables the keep sorted mode of the queue. - * - * @see .queueKeepSortedOrder - */ - set(keepSorted) { - appPrefs.edit().putBoolean(PREF_QUEUE_KEEP_SORTED, keepSorted).apply() - } - - var queueKeepSortedOrder: SortOrder? - /** - * 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(PREF_QUEUE_KEEP_SORTED_ORDER, "use-default") - return SortOrder.parseWithDefault(sortOrderStr, SortOrder.DATE_NEW_OLD) - } - /** - * Sets the sort order for the queue keep sorted mode. - * @see .setQueueKeepSorted - */ - set(sortOrder) { - if (sortOrder == null) return - appPrefs.edit().putString(PREF_QUEUE_KEEP_SORTED_ORDER, sortOrder.name).apply() - } - -// the sort order for the downloads. - var downloadsSortedOrder: SortOrder? - get() { - val sortOrderStr = appPrefs.getString(PREF_DOWNLOADS_SORTED_ORDER, "" + SortOrder.DATE_NEW_OLD.code) - return SortOrder.fromCodeString(sortOrderStr) - } - set(sortOrder) { - appPrefs.edit().putString(PREF_DOWNLOADS_SORTED_ORDER, "" + sortOrder!!.code).apply() - } - - var allEpisodesSortOrder: SortOrder? - get() = SortOrder.fromCodeString(appPrefs.getString(PREF_SORT_ALL_EPISODES, "" + SortOrder.DATE_NEW_OLD.code)) - set(s) { - appPrefs.edit().putString(PREF_SORT_ALL_EPISODES, "" + s!!.code).apply() - } - - var prefFilterAllEpisodes: String - get() = appPrefs.getString(PREF_FILTER_ALL_EPISODES, "")?:"" - set(filter) { - appPrefs.edit().putString(PREF_FILTER_ALL_EPISODES, filter).apply() - } - enum class ThemePreference { LIGHT, DARK, BLACK, SYSTEM } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt index bbbaba4c..699a47eb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/DownloadsPreferencesFragment.kt @@ -146,10 +146,7 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere handlerFunc.accept(path) } recyclerView.adapter = adapter - - if (adapter.itemCount != 0) { - dialog.show() - } + if (adapter.itemCount != 0) dialog.show() } } @@ -161,14 +158,24 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere private lateinit var etUsername: EditText private lateinit var etPassword: EditText private lateinit var txtvMessage: TextView - private var testSuccessful = false + private val port: Int + get() { + val port = etPort.text.toString() + if (port.isNotEmpty()) { + try { + return port.toInt() + } catch (e: NumberFormatException) { + // ignore + } + } + return 0 + } fun show(): Dialog { val content = View.inflate(context, R.layout.proxy_settings, null) val binding = ProxySettingsBinding.bind(content) spType = binding.spType - dialog = MaterialAlertDialogBuilder(context) .setTitle(R.string.pref_proxy_title) .setView(content) @@ -187,7 +194,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere reinit() dialog.dismiss() } - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { etHost.text.clear() etPort.text.clear() @@ -195,12 +201,10 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere etPassword.text.clear() setProxyConfig() } - val types: MutableList = ArrayList() types.add(Proxy.Type.DIRECT.name) types.add(Proxy.Type.HTTP.name) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) types.add(Proxy.Type.SOCKS.name) - val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, types) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) spType.setAdapter(adapter) @@ -208,19 +212,15 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere spType.setSelection(adapter.getPosition(proxyConfig.type.name)) etHost = binding.etHost if (!proxyConfig.host.isNullOrEmpty()) etHost.setText(proxyConfig.host) - etHost.addTextChangedListener(requireTestOnChange) etPort = binding.etPort if (proxyConfig.port > 0) etPort.setText(proxyConfig.port.toString()) - etPort.addTextChangedListener(requireTestOnChange) etUsername = binding.etUsername if (!proxyConfig.username.isNullOrEmpty()) etUsername.setText(proxyConfig.username) - etUsername.addTextChangedListener(requireTestOnChange) etPassword = binding.etPassword if (!proxyConfig.password.isNullOrEmpty()) etPassword.setText(proxyConfig.password) - etPassword.addTextChangedListener(requireTestOnChange) if (proxyConfig.type == Proxy.Type.DIRECT) { enableSettings(false) @@ -232,7 +232,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere enableSettings(position > 0) setTestRequired(position > 0) } - override fun onNothingSelected(parent: AdapterView<*>?) { enableSettings(false) } @@ -241,52 +240,40 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere checkValidity() return dialog } - private fun setProxyConfig() { val type = spType.selectedItem as String val typeEnum = Proxy.Type.valueOf(type) val host = etHost.text.toString() val port = etPort.text.toString() - var username: String? = etUsername.text.toString() if (username.isNullOrEmpty()) username = null - var password: String? = etPassword.text.toString() if (password.isNullOrEmpty()) password = null - var portValue = 0 if (port.isNotEmpty()) portValue = port.toInt() - val config = ProxyConfig(typeEnum, host, portValue, username, password) proxyConfig = config PodciniHttpClient.setProxyConfig(config) } - private val requireTestOnChange: TextWatcher = object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable) { setTestRequired(true) } } - private fun enableSettings(enable: Boolean) { etHost.isEnabled = enable etPort.isEnabled = enable etUsername.isEnabled = enable etPassword.isEnabled = enable } - private fun checkValidity(): Boolean { var valid = true if (spType.selectedItemPosition > 0) valid = checkHost() - valid = valid and checkPort() return valid } - private fun checkHost(): Boolean { val host = etHost.text.toString() if (host.isEmpty()) { @@ -299,7 +286,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere } return true } - private fun checkPort(): Boolean { val port = port if (port < 0 || port > 65535) { @@ -308,20 +294,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere } return true } - - private val port: Int - get() { - val port = etPort.text.toString() - if (port.isNotEmpty()) { - try { - return port.toInt() - } catch (e: NumberFormatException) { - // ignore - } - } - return 0 - } - private fun setTestRequired(required: Boolean) { if (required) { testSuccessful = false @@ -332,7 +304,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere } dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true } - private fun test() { if (!checkValidity()) { setTestRequired(true) @@ -345,7 +316,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere txtvMessage.setTextColor(textColorPrimary) txtvMessage.text = "{faw_circle_o_notch spin} $checking" txtvMessage.visibility = View.VISIBLE - val coroutineScope = CoroutineScope(Dispatchers.Main) coroutineScope.launch(Dispatchers.IO) { try { @@ -356,7 +326,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere val password = etPassword.text.toString() var portValue = 8080 if (port.isNotEmpty()) portValue = port.toInt() - val address: SocketAddress = InetSocketAddress.createUnresolved(host, portValue) val proxyType = Proxy.Type.valueOf(type.uppercase()) val builder: OkHttpClient.Builder = newBuilder() @@ -393,12 +362,10 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere setTestRequired(true) } } - } } - class DataFolderAdapter(context: Context, selectionHandler: Consumer) : RecyclerView.Adapter() { - + private class DataFolderAdapter(context: Context, selectionHandler: Consumer) : RecyclerView.Adapter() { private val selectionHandler: Consumer private val currentPath: String? private val entries: List @@ -410,19 +377,16 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere this.selectionHandler = selectionHandler this.freeSpaceString = context.getString(R.string.choose_data_directory_available_space) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val inflater = LayoutInflater.from(parent.context) val entryView = inflater.inflate(R.layout.choose_data_folder_dialog_entry, parent, false) return ViewHolder(entryView) } - override fun onBindViewHolder(holder: ViewHolder, position: Int) { val storagePath = entries[position] val context = holder.root.context val freeSpace = Formatter.formatShortFileSize(context, storagePath.availableSpace) val totalSpace = Formatter.formatShortFileSize(context, storagePath.totalSpace) - holder.path.text = storagePath.shortPath holder.size.text = String.format(freeSpaceString, freeSpace, totalSpace) holder.progressBar.progress = storagePath.usagePercentage @@ -431,19 +395,15 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere } holder.root.setOnClickListener(selectListener) holder.radioButton.setOnClickListener(selectListener) - if (storagePath.fullPath == currentPath) holder.radioButton.toggle() } - override fun getItemCount(): Int { return entries.size } - private fun getCurrentPath(): String? { val dataFolder = getDataFolder(null) return dataFolder?.absolutePath } - private fun getStorageEntries(context: Context): List { val mediaDirs = context.getExternalFilesDirs(null) val entries: MutableList = ArrayList(mediaDirs.size) @@ -454,12 +414,10 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere if (entries.isEmpty() && isWritable(context.filesDir)) entries.add(StoragePath(context.filesDir.absolutePath)) return entries } - private fun isWritable(dir: File?): Boolean { return dir != null && dir.exists() && dir.canRead() && dir.canWrite() } - - class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val binding = ChooseDataFolderDialogEntryBinding.bind(itemView) val root: View = binding.root val path: TextView = binding.path @@ -467,20 +425,16 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere val radioButton: RadioButton = binding.radioButton val progressBar: ProgressBar = binding.usedSpace } - - internal class StoragePath(val fullPath: String) { + private class StoragePath(val fullPath: String) { val shortPath: String get() { val prefixIndex = fullPath.indexOf("Android") return if ((prefixIndex > 0)) fullPath.substring(0, prefixIndex) else fullPath } - val availableSpace: Long get() = getFreeSpaceAvailable(fullPath) - val totalSpace: Long get() = getTotalSpaceAvailable(fullPath) - val usagePercentage: Int get() = 100 - (100 * availableSpace / totalSpace.toFloat()).toInt() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt index d83c6d13..7a6ca189 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt @@ -34,6 +34,7 @@ import android.os.Bundle import android.os.ParcelFileDescriptor import android.text.format.Formatter import android.util.Log +import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -66,22 +67,30 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.chooseOpmlExportPathResult(result) } + private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.chooseHtmlExportPathResult(result) } + private val chooseFavoritesExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.chooseFavoritesExportPathResult(result) } + private val chooseProgressExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.chooseProgressExportPathResult(result) } + private val restoreProgressLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.restoreProgressResult(result) } + private val restoreDatabaseLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.restoreDatabaseResult(result) } + private val backupDatabaseLauncher = registerForActivityResult(BackupDatabase()) { uri: Uri? -> this.backupDatabaseResult(uri) } + private val chooseOpmlImportPathLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> this.chooseOpmlImportPathResult(uri) } private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> this.restorePreferencesResult(result) } + private val backupPreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { val data: Uri? = it.data?.data @@ -221,7 +230,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { private fun importDatabase() { // setup the alert builder val builder = MaterialAlertDialogBuilder(requireActivity()) - builder.setTitle(R.string.database_import_label) + builder.setTitle(R.string.realm_database_import_label) builder.setMessage(R.string.database_import_warning) // add a button @@ -229,6 +238,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.setType("*/*") + intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream")) + intent.addCategory(Intent.CATEGORY_OPENABLE) restoreDatabaseLauncher.launch(intent) } @@ -270,7 +281,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { builder.setNegativeButton(R.string.no, null) builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - intent.setType("*/*") + intent.setType("application/octet-stream") + intent.addCategory(Intent.CATEGORY_OPENABLE) restoreProgressLauncher.launch(intent) } // create and show the alert dialog @@ -304,44 +316,70 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { private fun restoreProgressResult(result: ActivityResult) { if (result.resultCode != RESULT_OK || result.data?.data == null) return val uri = result.data!!.data!! - progressDialog!!.show() - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri) - val reader = BufferedReader(InputStreamReader(inputStream)) - EpisodeProgressReader.readDocument(reader) - reader.close() + uri?.let { + if (isJsonFile(uri)) { + progressDialog!!.show() + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri) + val reader = BufferedReader(InputStreamReader(inputStream)) + EpisodeProgressReader.readDocument(reader) + reader.close() + } + withContext(Dispatchers.Main) { + showDatabaseImportSuccessDialog() + progressDialog!!.dismiss() + } + } catch (e: Throwable) { + showExportErrorDialog(e) + } } - withContext(Dispatchers.Main) { - showDatabaseImportSuccessDialog() - progressDialog!!.dismiss() - } - } catch (e: Throwable) { - showExportErrorDialog(e) + } else { + val context = requireContext() + val message = context.getString(R.string.import_file_type_toast) + ".json" + showExportErrorDialog(Throwable(message)) } } } + private fun isJsonFile(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.endsWith(".json", ignoreCase = true) + } + private fun restoreDatabaseResult(result: ActivityResult) { if (result.resultCode != RESULT_OK || result.data == null) return val uri = result.data!!.data - progressDialog!!.show() - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - DatabaseTransporter.importBackup(uri, requireContext()) + uri?.let { + if (isRealmFile(uri)) { + progressDialog!!.show() + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + DatabaseTransporter.importBackup(uri, requireContext()) + } + withContext(Dispatchers.Main) { + showDatabaseImportSuccessDialog() + progressDialog!!.dismiss() + } + } catch (e: Throwable) { + showExportErrorDialog(e) + } } - withContext(Dispatchers.Main) { - showDatabaseImportSuccessDialog() - progressDialog!!.dismiss() - } - } catch (e: Throwable) { - showExportErrorDialog(e) + } else { + val context = requireContext() + val message = context.getString(R.string.import_file_type_toast) + ".realm" + showExportErrorDialog(Throwable(message)) } } } + private fun isRealmFile(uri: Uri): Boolean { + val fileName = uri.lastPathSegment ?: return false + return fileName.endsWith(".realm", ignoreCase = true) + } + private fun restorePreferencesResult(result: ActivityResult) { if (result.resultCode != RESULT_OK || result.data?.data == null) return val uri = result.data!!.data!! @@ -459,7 +497,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { val success = output.delete() Logd(TAG, "Overwriting previously exported file: $success") } - var writer: OutputStreamWriter? = null try { writer = OutputStreamWriter(FileOutputStream(output), Charset.forName("UTF-8")) @@ -619,9 +656,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { val newDstSize = dst.size() if (newDstSize != srcSize) throw IOException(String.format("Unable to write entire database. Expected to write %s, but wrote %s.", Formatter.formatShortFileSize(context, srcSize), Formatter.formatShortFileSize(context, newDstSize))) - } else { - throw IOException("Can not access current database") - } + } else throw IOException("Can not access current database") } catch (e: IOException) { Log.e(TAG, Log.getStackTraceString(e)) throw e @@ -659,33 +694,18 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { fun readDocument(reader: Reader) { val jsonString = reader.readText() val jsonArray = JSONArray(jsonString) - val remoteActions = mutableListOf() for (i in 0 until jsonArray.length()) { val jsonAction = jsonArray.getJSONObject(i) Logd(TAG, "Loaded EpisodeActions message: $i $jsonAction") val action = readFromJsonObject(jsonAction) ?: continue - remoteActions.add(action) - } - if (remoteActions.isEmpty()) return - val updatedItems: MutableList = ArrayList() - for (action in remoteActions) { Logd(TAG, "processing action: $action") val result = processEpisodeAction(action) ?: continue - updatedItems.add(result.second) + upsertBlk(result.second) {} } -// loadAdditionalFeedItemListData(updatedItems) -// need to do it the sync way - for (episode in updatedItems) upsertBlk(episode) {} - Logd(TAG, "Parsing finished.") - return } private fun processEpisodeAction(action: EpisodeAction): Pair? { val guid = if (isValidGuid(action.guid)) action.guid else null - val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") - if (feedItem == null) { - Logd(TAG, "Unknown feed item: $action") - return null - } + val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") ?: return null if (feedItem.media == null) { Logd(TAG, "Feed item has no media: $action") return null @@ -763,7 +783,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) override fun writeDocument(feeds: List?, writer: Writer?, context: Context) { Logd(TAG, "Starting to write document") - val templateStream = context!!.assets.open("html-export-template.html") + val templateStream = context.assets.open("html-export-template.html") var template = IOUtils.toString(templateStream, UTF_8) template = template.replace("\\{TITLE\\}".toRegex(), "Favorites") val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() @@ -840,12 +860,10 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) override fun writeDocument(feeds: List?, writer: Writer?, context: Context) { Logd(TAG, "Starting to write document") - - val templateStream = context!!.assets.open("html-export-template.html") + val templateStream = context.assets.open("html-export-template.html") var template = IOUtils.toString(templateStream, "UTF-8") template = template.replace("\\{TITLE\\}".toRegex(), "Subscriptions") val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - writer!!.append(templateParts[0]) for (feed in feeds!!) { writer.append("
  • (PREF_PLAYBACK_SPEED_LAUNCHER)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { VariableSpeedDialog.newInstance(booleanArrayOf(false, false, true),2)?.show(childFragmentManager, null) true @@ -84,7 +83,6 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { findPreference(UserPreferences.PREF_UNPAUSE_ON_HEADSET_RECONNECT)!!.isVisible = false findPreference(UserPreferences.PREF_UNPAUSE_ON_BLUETOOTH_RECONNECT)!!.isVisible = false } - buildEnqueueLocationPreference() } @@ -101,7 +99,6 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { val pref = requirePreference(UserPreferences.PREF_ENQUEUE_LOCATION) pref.summary = res.getString(R.string.pref_enqueue_location_sum, options[pref.value]) - pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any? -> if (newValue !is String) return@OnPreferenceChangeListener false pref.summary = res.getString(R.string.pref_enqueue_location_sum, options[newValue]) @@ -111,8 +108,7 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { private fun requirePreference(key: CharSequence): T { // Possibly put it to a common method in abstract base class - val result = findPreference(key) ?: throw IllegalArgumentException("Preference with key '$key' is not found") - return result + return findPreference(key) ?: throw IllegalArgumentException("Preference with key '$key' is not found") } private fun buildSmartMarkAsPlayedPreference() { @@ -140,12 +136,10 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { @UnstableApi class EditFallbackSpeedDialog(activity: Activity) { val TAG = this::class.simpleName ?: "Anonymous" - private val activityRef = WeakReference(activity) fun show() { val activity = activityRef.get() ?: return - val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity)) binding.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL binding.editText.text = Editable.Factory.getInstance().newEditable(fallbackSpeed.toString()) @@ -168,16 +162,13 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { @UnstableApi class EditForwardSpeedDialog(activity: Activity) { val TAG = this::class.simpleName ?: "Anonymous" - private val activityRef = WeakReference(activity) fun show() { val activity = activityRef.get() ?: return - val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity)) binding.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL binding.editText.text = Editable.Factory.getInstance().newEditable(speedforwardSpeed.toString()) - MaterialAlertDialogBuilder(activity) .setView(binding.root) .setTitle(R.string.edit_fast_forward_speed) @@ -192,7 +183,6 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { .setNegativeButton(R.string.cancel_label, null) .show() } - } object VideoModeDialog { @@ -200,11 +190,9 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() { val dialog = MaterialAlertDialogBuilder(context) dialog.setTitle(context.getString(R.string.pref_playback_video_mode)) dialog.setNegativeButton(android.R.string.cancel) { d: DialogInterface, _: Int -> d.dismiss() } - val selected = videoPlayMode val entryValues = listOf(*context.resources.getStringArray(R.array.video_mode_options_values)) val selectedIndex = entryValues.indexOf("" + selected) - val items = context.resources.getStringArray(R.array.video_mode_options) dialog.setSingleChoiceItems(items, selectedIndex) { d: DialogInterface, which: Int -> if (selectedIndex != which) setVideoMode(entryValues[which].toInt()) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt index 4bd6ec68..e94be5fd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt @@ -10,7 +10,8 @@ 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.preferences.UserPreferences.shouldDeleteRemoveFromQueue -import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueues +import ac.mdiq.podcini.preferences.UserPreferences.shouldRemoveFromQueuesMarkPlayed +import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesSync import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope @@ -94,21 +95,16 @@ object Episodes { fun deleteMediaOfEpisode(context: Context, episode: Episode) : Job { Logd(TAG, "deleteMediaOfEpisode called ${episode.title}") return runOnIOScope { - val media = episode.media ?: return@runOnIOScope - val result = deleteMediaSync(context, episode) - if (media.downloadUrl.isNullOrEmpty()) { - episode.media = null - upsert(episode) {} - EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode)) - } - if (result && shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, episode) + if (episode.media == null) return@runOnIOScope + val episode_ = deleteMediaSync(context, episode) + if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, episode_) } } @OptIn(UnstableApi::class) - private fun deleteMediaSync(context: Context, episode: Episode): Boolean { + fun deleteMediaSync(context: Context, episode: Episode): Episode { Logd(TAG, "deleteMediaSync called") - val media = episode.media ?: return false + val media = episode.media ?: return episode Logd(TAG, String.format(Locale.US, "Requested to delete EpisodeMedia [id=%d, title=%s, downloaded=%s", media.id, media.getEpisodeTitle(), media.downloaded)) var localDelete = false val url = media.fileUrl @@ -118,10 +114,12 @@ object Episodes { val documentFile = DocumentFile.fromSingleUri(context, Uri.parse(media.fileUrl)) if (documentFile == null || !documentFile.exists() || !documentFile.delete()) { EventFlow.postEvent(FlowEvent.MessageEvent(context.getString(R.string.delete_local_failed))) - return false + return episode + } + upsertBlk(episode) { + it.media?.fileUrl = null + if (media.downloadUrl.isNullOrEmpty()) it.media = null } - episode.media?.fileUrl = null - upsertBlk(episode) {} localDelete = true } url != null -> { @@ -131,19 +129,20 @@ object Episodes { Log.e(TAG, "delete media file failed: $url") val evt = FlowEvent.MessageEvent(context.getString(R.string.delete_failed)) EventFlow.postEvent(evt) - return false + return episode + } + upsertBlk(episode) { + it.media?.downloaded = false + it.media?.fileUrl = null + it.media?.hasEmbeddedPicture = false + if (media.downloadUrl.isNullOrEmpty()) it.media = null } - episode.media?.downloaded = false - episode.media?.fileUrl = null - episode.media?.hasEmbeddedPicture = false - upsertBlk(episode) {} } } if (media.id == curState.curMediaId) { writeNoMediaPlaying() sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE) - val nm = NotificationManagerCompat.from(context) nm.cancel(R.id.notification_playing) } @@ -157,7 +156,7 @@ object Episodes { SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, action) EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode)) } - return true + return episode } /** @@ -166,44 +165,34 @@ object Episodes { */ fun deleteEpisodes(context: Context, episodes: List) : Job { return runOnIOScope { - deleteEpisodesSync(context, episodes) - } - } - - /** - * Remove the listed episodes and their EpisodeMedia entries. - * Deleting media also removes the download log entries. - */ - @OptIn(UnstableApi::class) - internal fun deleteEpisodesSync(context: Context, episodes: List) { - Logd(TAG, "deleteEpisodesSync called") - val removedFromQueue: MutableList = ArrayList() - val queueItems = curQueue.episodes.toMutableList() - for (episode in episodes) { - if (queueItems.remove(episode)) removedFromQueue.add(episode) - if (episode.media != null) { - if (episode.media?.id == curState.curMediaId) { - // Applies to both downloaded and streamed media - writeNoMediaPlaying() - sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE) - } - if (episode.feed != null && !episode.feed!!.isLocalFeed) { - DownloadServiceInterface.get()?.cancel(context, episode.media!!) - if (episode.media!!.downloaded) deleteMediaSync(context, episode) + val removedFromQueue: MutableList = ArrayList() + val queueItems = curQueue.episodes.toMutableList() + for (episode in episodes) { + if (queueItems.remove(episode)) removedFromQueue.add(episode) + if (episode.media != null) { + if (episode.media?.id == curState.curMediaId) { + // Applies to both downloaded and streamed media + writeNoMediaPlaying() + sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE) + } + if (episode.feed != null && !episode.feed!!.isLocalFeed) { + DownloadServiceInterface.get()?.cancel(context, episode.media!!) + if (episode.media!!.downloaded) deleteMediaSync(context, episode) + } } } + if (removedFromQueue.isNotEmpty()) removeFromAllQueuesSync(*removedFromQueue.toTypedArray()) + + for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode)) + + // we assume we also removed download log entries for the feed or its media files. + // especially important if download or refresh failed, as the user should not be able + // to retry these + EventFlow.postEvent(FlowEvent.DownloadLogEvent()) + + val backupManager = BackupManager(context) + backupManager.dataChanged() } - if (removedFromQueue.isNotEmpty()) removeFromAllQueues(*removedFromQueue.toTypedArray()) - - for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode)) - - // we assume we also removed download log entries for the feed or its media files. - // especially important if download or refresh failed, as the user should not be able - // to retry these - EventFlow.postEvent(FlowEvent.DownloadLogEvent()) - - val backupManager = BackupManager(context) - backupManager.dataChanged() } fun persistEpisodes(episodes: List) : Job { @@ -274,17 +263,24 @@ object Episodes { * @param resetMediaPosition true if this method should also reset the position of the Episode's EpisodeMedia object. */ @OptIn(UnstableApi::class) - fun markPlayed(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job { - Logd(TAG, "markPlayed called") + fun setPlayState(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job { + Logd(TAG, "setPlayState called") return runOnIOScope { for (episode in episodes) { - val result = upsert(episode) { - it.playState = played - if (resetMediaPosition) it.media?.setPosition(0) - } - if (played == Episode.PLAYED) removeFromAllQueues(episode) - EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(result)) + setPlayStateSync(played, resetMediaPosition, episode) } } } + + @OptIn(UnstableApi::class) + suspend fun setPlayStateSync(played: Int, resetMediaPosition: Boolean, episode: Episode) : Episode { + Logd(TAG, "setPlayStateSync called resetMediaPosition: $resetMediaPosition") + val result = upsert(episode) { + it.playState = played + if (resetMediaPosition) it.media?.setPosition(0) + } + if (played == Episode.PLAYED && shouldRemoveFromQueuesMarkPlayed()) removeFromAllQueuesSync(result) + EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(result)) + return result + } } \ No newline at end of file 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 071846c6..203f0f61 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 @@ -3,7 +3,6 @@ package ac.mdiq.podcini.storage.database 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.storage.database.Episodes.deleteEpisodes import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet @@ -37,7 +36,8 @@ object Feeds { private val tags: MutableList = mutableListOf() @Synchronized - fun getFeedList(): List { + fun getFeedList(fromDB: Boolean = true): List { + if (fromDB) return realm.query(Feed::class).find() return feedMap.values.toList() } @@ -67,7 +67,7 @@ object Feeds { fun buildTags() { val tagsSet = mutableSetOf() - val feedsCopy = synchronized(feedMap) { feedMap.values.toList() } + val feedsCopy = getFeedList() for (feed in feedsCopy) { if (feed.preferences != null) tagsSet.addAll(feed.preferences!!.tags.filter { it != TAG_ROOT }) } @@ -94,7 +94,7 @@ object Feeds { changes.insertions.isNotEmpty() -> { for (i in changes.insertions) { Logd(TAG, "monitorFeeds inserted feed: ${changes.list[i].title}") - updateFeedMap(listOf(changes.list[i])) +// updateFeedMap(listOf(changes.list[i])) monitorFeed(changes.list[i]) } EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED)) @@ -106,7 +106,8 @@ object Feeds { // } changes.deletions.isNotEmpty() -> { Logd(TAG, "monitorFeeds feed deleted: ${changes.deletions.size}") - updateFeedMap(changes.list, true) +// updateFeedMap(changes.list, true) + buildTags() } } } @@ -125,7 +126,7 @@ object Feeds { when (changes) { is UpdatedObject -> { Logd(TAG, "monitorFeed UpdatedObject0 ${changes.obj.title} ${changes.changedFields.joinToString()}") - updateFeedMap(listOf(changes.obj)) +// updateFeedMap(listOf(changes.obj)) if (changes.isFieldChanged("preferences")) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(changes.obj)) } else -> {} @@ -138,13 +139,13 @@ object Feeds { when (changes) { is UpdatedObject -> { Logd(TAG, "monitorFeed UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}") - updateFeedMap(listOf(changes.obj)) +// updateFeedMap(listOf(changes.obj)) if (changes.isFieldChanged("preferences")) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(changes.obj)) } - is DeletedObject -> { - Logd(TAG, "monitorFeed DeletedObject ${feed.title}") - EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id)) - } +// is DeletedObject -> { +// Logd(TAG, "monitorFeed DeletedObject ${feed.title}") +// EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id)) +// } else -> {} } } @@ -154,15 +155,17 @@ object Feeds { fun getFeedListDownloadUrls(): List { Logd(TAG, "getFeedListDownloadUrls called") val result: MutableList = mutableListOf() - for (f in feedMap.values) { + val feeds = getFeedList() + for (f in feeds) { val url = f.downloadUrl if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) result.add(url) } return result } - fun getFeed(feedId: Long, copy: Boolean = false): Feed? { - val f = feedMap[feedId] + fun getFeed(feedId: Long, copy: Boolean = false, fromDB: Boolean = true): Feed? { + Logd(TAG, "getFeed called fromDB: $fromDB") + val f = if (fromDB) realm.query(Feed::class, "id == $feedId").first().find() else feedMap[feedId] return if (f != null) { if (copy) realm.copyFromRealm(f) else f @@ -173,8 +176,9 @@ object Feeds { Logd(TAG, "searchFeedByIdentifyingValueOrID called") if (feed.id != 0L) return getFeed(feed.id, copy) val feeds = getFeedList() + val feedId = feed.identifyingValue for (f in feeds) { - if (f.identifyingValue == feed.identifyingValue) return if (copy) realm.copyFromRealm(f) else f + if (f.identifyingValue == feedId) return if (copy) realm.copyFromRealm(f) else f } return null } @@ -229,7 +233,7 @@ object Feeds { for (idx in newFeed.episodes.indices) { val episode = newFeed.episodes[idx] - val possibleDuplicate = searchEpisodeGuessDuplicate(newFeed.episodes, episode) + val possibleDuplicate = EpisodeAssistant.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, @@ -237,17 +241,17 @@ object Feeds { The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it. Original episode: - ${duplicateEpisodeDetails(episode)} + ${EpisodeAssistant.duplicateEpisodeDetails(episode)} Second episode that is also in the feed: - ${duplicateEpisodeDetails(possibleDuplicate)} + ${EpisodeAssistant.duplicateEpisodeDetails(possibleDuplicate)} """.trimIndent())) continue } - var oldItem = searchEpisodeByIdentifyingValue(savedFeed.episodes, episode) + var oldItem = EpisodeAssistant.searchEpisodeByIdentifyingValue(savedFeed.episodes, episode) if (!newFeed.isLocalFeed && oldItem == null) { - oldItem = searchEpisodeGuessDuplicate(savedFeed.episodes, episode) + oldItem = EpisodeAssistant.searchEpisodeGuessDuplicate(savedFeed.episodes, episode) if (oldItem != null) { Logd(TAG, "Repaired duplicate: $oldItem, $episode") addDownloadStatus(DownloadResult(savedFeed.id, @@ -256,10 +260,10 @@ object Feeds { The podcast host changed the ID of an existing episode instead of just updating the episode itself. Podcini still refreshed the feed and attempted to repair it. Original episode: - ${duplicateEpisodeDetails(oldItem)} + ${EpisodeAssistant.duplicateEpisodeDetails(oldItem)} Now the feed contains: - ${duplicateEpisodeDetails(episode)} + ${EpisodeAssistant.duplicateEpisodeDetails(episode)} """.trimIndent())) oldItem.identifier = episode.identifier @@ -300,7 +304,7 @@ object Feeds { val it = savedFeed.episodes.toMutableList().iterator() while (it.hasNext()) { val feedItem = it.next() - if (searchEpisodeByIdentifyingValue(newFeed.episodes, feedItem) == null) { + if (EpisodeAssistant.searchEpisodeByIdentifyingValue(newFeed.episodes, feedItem) == null) { unlistedItems.add(feedItem) it.remove() } @@ -334,43 +338,6 @@ object Feeds { return resultFeed } - /** - * Get an episode by its identifying value in the given list - */ - private fun searchEpisodeByIdentifyingValue(episodes: List?, searchItem: Episode): Episode? { - if (episodes.isNullOrEmpty()) return null - for (episode in episodes) { - if (episode.identifyingValue == searchItem.identifyingValue) return episode - } - return null - } - - /** - * Guess if one of the episodes could actually mean the searched episode, even if it uses another identifying value. - * This is to work around podcasters breaking their GUIDs. - */ - 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 - } - for (episode in episodes) { - if (EpisodeDuplicateGuesser.seemDuplicates(episode, searchItem)) return episode - } - return null - } - - private fun duplicateEpisodeDetails(episode: Episode): String { - return (""" - Title: ${episode.title} - ID: ${episode.identifier} - """.trimIndent() - + (if ((episode.media == null)) "" else """ - - URL: ${episode.media!!.downloadUrl} - """.trimIndent())) - } - fun persistFeedLastUpdateFailed(feed: Feed, lastUpdateFailed: Boolean) : Job { Logd(TAG, "persistFeedLastUpdateFailed called") return runOnIOScope { @@ -383,11 +350,10 @@ object Feeds { fun updateFeedDownloadURL(original: String, updated: String) : Job { Logd(TAG, "updateFeedDownloadURL(original: $original, updated: $updated)") return runOnIOScope { - realm.write { - val feed = query(Feed::class).query("downloadUrl == $0", original).first().find() - if (feed != null) { - feed.downloadUrl = updated -// upsert(feed) {} + val feed = realm.query(Feed::class).query("downloadUrl == $0", original).first().find() + if (feed != null) { + upsert(feed) { + it.downloadUrl = updated } } } @@ -434,17 +400,10 @@ object Feeds { return runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() if (feed_ != null) { - realm.write { - findLatest(feed_)?.let { - it.preferences = feed.preferences -// updateFeedMap(listOf(it)) - } + upsert(feed_) { + it.preferences = feed.preferences } - } else { - upsert(feed) {} -// updateFeedMap(listOf(feed)) - } -// if (feed.preferences != null) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(feed.preferences!!)) + } else upsert(feed) {} } } @@ -453,72 +412,39 @@ 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 deleteFeed(context: Context, feedId: Long, postEvent: Boolean = true) : Job { + suspend fun deleteFeedSync(context: Context, feedId: Long, postEvent: Boolean = true) { Logd(TAG, "deleteFeed called") - return runOnIOScope { - val feed = getFeed(feedId) - if (feed != null) { - val eids = feed.episodes.map { it.id } + val feed = getFeed(feedId) + if (feed != null) { + val eids = feed.episodes.map { it.id } // remove from queues - removeFromAllQueuesQuiet(eids) + removeFromAllQueuesQuiet(eids) // remove media files -// deleteMediaFilesQuiet(context, feed.episodes) - realm.write { - val feed_ = query(Feed::class).query("id == $0", feedId).first().find() - if (feed_ != null) { - val episodes = feed_.episodes.toList() - if (episodes.isNotEmpty()) episodes.forEach { episode -> - val url = episode.media?.fileUrl - when { - // Local feed - url != null && url.startsWith("content://") -> DocumentFile.fromSingleUri(context, Uri.parse(url))?.delete() - url != null -> File(url).delete() - } - delete(episode) - } - val feedToDelete = findLatest(feed_) - if (feedToDelete != null) { - delete(feedToDelete) - feedMap.remove(feedId) + realm.write { + val feed_ = query(Feed::class).query("id == $0", feedId).first().find() + if (feed_ != null) { + val episodes = feed_.episodes.toList() + if (episodes.isNotEmpty()) episodes.forEach { episode -> + val url = episode.media?.fileUrl + when { + // Local feed + url != null && url.startsWith("content://") -> DocumentFile.fromSingleUri(context, Uri.parse(url))?.delete() + url != null -> File(url).delete() } + delete(episode) + } + val feedToDelete = findLatest(feed_) + if (feedToDelete != null) { + delete(feedToDelete) + feedMap.remove(feedId) } } - if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!) -// if (postEvent) EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id)) } + if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!) +// if (postEvent) EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id)) } } -// private fun deleteMediaFilesQuiet(context: Context, episodes: List) { -// for (episode in episodes) { -// val media = episode.media ?: continue -// val url = media.fileUrl -// when { -// url != null && url.startsWith("content://") -> { -// // Local feed -// val documentFile = DocumentFile.fromSingleUri(context, Uri.parse(media.fileUrl)) -// documentFile?.delete() -//// episode.media?.fileUrl = null -// } -// url != null -> { -// // delete downloaded media file -// val mediaFile = File(url) -// mediaFile.delete() -//// since deleting entire feed, these are not necessary -//// episode.media?.downloaded = false -//// episode.media?.fileUrl = null -//// episode.media?.hasEmbeddedPicture = false -// } -// } -// } -// } - - @JvmStatic - fun shouldAutoDeleteItemsOnFeed(feed: Feed): Boolean { - if (!UserPreferences.isAutoDelete) return false - return !feed.isLocalFeed || UserPreferences.isAutoDeleteLocal - } - /** * Compares the pubDate of two FeedItems for sorting in reverse order */ @@ -528,6 +454,39 @@ object Feeds { } } + private object EpisodeAssistant { + fun searchEpisodeByIdentifyingValue(episodes: List?, searchItem: Episode): Episode? { + if (episodes.isNullOrEmpty()) return null + for (episode in episodes) { + if (episode.identifyingValue == searchItem.identifyingValue) return episode + } + return null + } + /** + * Guess if one of the episodes could actually mean the searched episode, even if it uses another identifying value. + * This is to work around podcasters breaking their GUIDs. + */ + 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 + } + for (episode in episodes) { + if (EpisodeDuplicateGuesser.seemDuplicates(episode, searchItem)) return episode + } + return null + } + fun duplicateEpisodeDetails(episode: Episode): String { + return (""" + Title: ${episode.title} + ID: ${episode.identifier} + """.trimIndent() + if (episode.media == null) "" else """ + + URL: ${episode.media!!.downloadUrl} + """.trimIndent()) + } + } + /** * Publishers sometimes mess up their feed by adding episodes twice or by changing the ID of existing episodes. * This class tries to guess if publishers actually meant another episode, @@ -536,50 +495,39 @@ object Feeds { object EpisodeDuplicateGuesser { fun seemDuplicates(item1: Episode, item2: Episode): Boolean { if (sameAndNotEmpty(item1.identifier, item2.identifier)) return true - val media1 = item1.media val media2 = item2.media if (media1 == null || media2 == null) return false - if (sameAndNotEmpty(media1.getStreamUrl(), media2.getStreamUrl())) return true - return (titlesLookSimilar(item1, item2) && datesLookSimilar(item1, item2) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2)) } - fun sameAndNotEmpty(string1: String?, string2: String?): Boolean { if (string1.isNullOrEmpty() || string2.isNullOrEmpty()) return false return string1 == string2 } - private fun datesLookSimilar(item1: Episode, item2: Episode): Boolean { if (item1.getPubDate() == null || item2.getPubDate() == null) return false - val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) // MM/DD/YY val dateOriginal = dateFormat.format(item2.getPubDate()!!) val dateNew = dateFormat.format(item1.getPubDate()!!) return dateOriginal == dateNew // Same date; time is ignored. } - private fun durationsLookSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean { return abs((media1.getDuration() - media2.getDuration()).toDouble()) < 10 * 60L * 1000L } - private fun mimeTypeLooksSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean { var mimeType1 = media1.mimeType var mimeType2 = media2.mimeType if (mimeType1 == null || mimeType2 == null) return true - if (mimeType1.contains("/") && mimeType2.contains("/")) { mimeType1 = mimeType1.substring(0, mimeType1.indexOf("/")) mimeType2 = mimeType2.substring(0, mimeType2.indexOf("/")) } return (mimeType1 == mimeType2) } - private fun titlesLookSimilar(item1: Episode, item2: Episode): Boolean { return sameAndNotEmpty(canonicalizeTitle(item1.title), canonicalizeTitle(item2.title)) } - private fun canonicalizeTitle(title: String?): String { if (title == null) return "" return title @@ -590,5 +538,4 @@ object Feeds { .replace('—', '-') } } - } \ No newline at end of file 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 9ba6f984..d482c3df 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 @@ -3,13 +3,13 @@ package ac.mdiq.podcini.storage.database import ac.mdiq.podcini.storage.database.Feeds.getFeed import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.sorting.DownloadResultComparator import kotlinx.coroutines.Job -import java.util.* object LogsAndStats { private val TAG: String = LogsAndStats::class.simpleName ?: "Anonymous" @@ -26,9 +26,7 @@ object LogsAndStats { return runOnIOScope { if (status != null) { if (status.id == 0L) status.setId() - realm.write { - copyToRealm(status) - } + upsert(status) {} EventFlow.postEvent(FlowEvent.DownloadLogEvent()) } } @@ -45,30 +43,36 @@ object LogsAndStats { val groupdMedias = medias.groupBy { it.episode?.feedId ?: 0L } val result = StatisticsResult() result.oldestDate = Long.MAX_VALUE - for (fid in groupdMedias.keys) { + for ((fid, feedMedias) in groupdMedias) { val feed = getFeed(fid, false) ?: continue - val episodes = feed.episodes.size.toLong() + val numEpisodes = feed.episodes.size.toLong() var feedPlayedTime = 0L var feedTotalTime = 0L var episodesStarted = 0L var totalDownloadSize = 0L var episodesDownloadCount = 0L - for (m in groupdMedias[fid]!!) { + for (m in feedMedias) { if (m.lastPlayedTime > 0 && m.lastPlayedTime < result.oldestDate) result.oldestDate = m.lastPlayedTime feedTotalTime += m.duration - if (m.lastPlayedTime in timeFilterFrom.. 0 && m.playedDuration > 0) || m.episode?.playState == Episode.PLAYED || m.position > 0) - episodesStarted += 1 - } else { - if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1 + if (m.lastPlayedTime in timeFilterFrom.. 0 && m.playedDuration > 0) || m.episode?.playState == Episode.PLAYED || m.position > 0) { + episodesStarted += 1 + feedPlayedTime += m.duration + } + } else { + feedPlayedTime += m.playedDuration + if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1 + } } if (m.downloaded) { episodesDownloadCount += 1 totalDownloadSize += m.size } - result.feedTime.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, episodes, episodesStarted, totalDownloadSize, episodesDownloadCount)) } + feedPlayedTime /= 1000 + feedTotalTime /= 1000 + result.statsItems.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, numEpisodes, episodesStarted, totalDownloadSize, episodesDownloadCount)) } return result } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt index 953a0d54..04699d3d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt @@ -8,7 +8,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation import ac.mdiq.podcini.preferences.UserPreferences.isQueueKeepSorted import ac.mdiq.podcini.preferences.UserPreferences.queueKeepSortedOrder import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia -import ac.mdiq.podcini.storage.database.Episodes.markPlayed +import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.upsert @@ -48,7 +48,7 @@ object Queues { * @param markAsUnplayed true if the episodes should be marked as unplayed when enqueueing * @param episodes the Episode objects that should be added to the queue. */ - @UnstableApi @JvmStatic + @UnstableApi @JvmStatic @Synchronized fun addToQueue(markAsUnplayed: Boolean, vararg episodes: Episode) : Job { Logd(TAG, "addToQueue( ... ) called") return runOnIOScope { @@ -59,7 +59,6 @@ object Queues { val events: MutableList = ArrayList() val updatedItems: MutableList = ArrayList() val positionCalculator = EnqueuePositionCalculator(enqueueLocation) -// val currentlyPlaying = loadPlayableFromPreferences() val currentlyPlaying = curMedia var insertPosition = positionCalculator.calcPosition(curQueue.episodes, currentlyPlaying) @@ -67,9 +66,7 @@ object Queues { val items_ = episodes.toList() for (episode in items_) { if (curQueue.episodeIds.contains(episode.id)) continue -// episode.isInAnyQueue = true - if (episode.isNew) markPlayed(Episode.UNPLAYED, false, episode) - upsert(episode) {} + events.add(FlowEvent.QueueEvent.added(episode, insertPosition)) curQueue.episodeIds.add(insertPosition, episode.id) updatedItems.add(episode) @@ -89,12 +86,33 @@ object Queues { EventFlow.postEvent(event) } // EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(updatedItems)) - if (markAsUnplayed && markAsUnplayeds.size > 0) markPlayed(Episode.UNPLAYED, false, *markAsUnplayeds.toTypedArray()) + if (markAsUnplayed && markAsUnplayeds.size > 0) setPlayState(Episode.UNPLAYED, false, *markAsUnplayeds.toTypedArray()) // if (performAutoDownload) autodownloadEpisodeMedia(context) } } } + suspend fun addToQueueSync(markAsUnplayed: Boolean, episode: Episode) { + Logd(TAG, "addToQueueSync( ... ) called") + + val currentlyPlaying = curMedia + val positionCalculator = EnqueuePositionCalculator(enqueueLocation) + var insertPosition = positionCalculator.calcPosition(curQueue.episodes, currentlyPlaying) + + if (curQueue.episodeIds.contains(episode.id)) return + + curQueue.episodeIds.add(insertPosition, episode.id) + curQueue.episodes.add(insertPosition, episode) + insertPosition++ + curQueue.update() + upsert(curQueue) {} + + if (markAsUnplayed && episode.isNew) setPlayState(Episode.UNPLAYED, false, episode) + + EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, insertPosition)) +// if (performAutoDownload) autodownloadEpisodeMedia(context) + } + /** * Sorts the queue depending on the configured sort order. * If the queue is not in keep sorted mode, nothing happens. @@ -142,7 +160,7 @@ object Queues { } @OptIn(UnstableApi::class) - fun removeFromAllQueues(vararg episodes: Episode) { + fun removeFromAllQueuesSync(vararg episodes: Episode) { Logd(TAG, "removeFromAllQueues called ") val queues = realm.query(PlayQueue::class).find() for (q in queues) { @@ -163,7 +181,6 @@ object Queues { val queue = queue_ ?: curQueue val events: MutableList = ArrayList() - val updatedItems: MutableList = ArrayList() val pos: MutableList = mutableListOf() val qItems = queue.episodes.toMutableList() for (i in qItems.indices) { @@ -171,7 +188,6 @@ object Queues { if (episodes.contains(episode)) { Logd(TAG, "removing from queue: ${episode.id} ${episode.title}") pos.add(i) - updatedItems.add(episode) if (queue.id == curQueue.id) events.add(FlowEvent.QueueEvent.removed(episode)) } } @@ -260,53 +276,47 @@ object Queues { class EnqueuePositionCalculator(private val enqueueLocation: EnqueueLocation) { /** * Determine the position (0-based) that the item(s) should be inserted to the named queue. - * @param curQueue the queue to which the item is to be inserted + * @param queueItems the queue to which the item is to be inserted * @param currentPlaying the currently playing media */ - fun calcPosition(curQueue: List, currentPlaying: Playable?): Int { + fun calcPosition(queueItems: List, currentPlaying: Playable?): Int { when (enqueueLocation) { - EnqueueLocation.BACK -> return curQueue.size + EnqueueLocation.BACK -> return queueItems.size EnqueueLocation.FRONT -> // Return not necessarily 0, so that when a list of items are downloaded and enqueued // in succession of calls (e.g., users manually tapping download one by one), // the items enqueued are kept the same order. // Simply returning 0 will reverse the order. - return getPositionOfFirstNonDownloadingItem(0, curQueue) + return getPositionOfFirstNonDownloadingItem(0, queueItems) EnqueueLocation.AFTER_CURRENTLY_PLAYING -> { - val currentlyPlayingPosition = getCurrentlyPlayingPosition(curQueue, currentPlaying) - return getPositionOfFirstNonDownloadingItem(currentlyPlayingPosition + 1, curQueue) + val currentlyPlayingPosition = getCurrentlyPlayingPosition(queueItems, currentPlaying) + return getPositionOfFirstNonDownloadingItem(currentlyPlayingPosition + 1, queueItems) } EnqueueLocation.RANDOM -> { val random = Random() - return random.nextInt(curQueue.size + 1) + return random.nextInt(queueItems.size + 1) } else -> throw AssertionError("calcPosition() : unrecognized enqueueLocation option: $enqueueLocation") } } - - private fun getPositionOfFirstNonDownloadingItem(startPosition: Int, curQueue: List): Int { - val curQueueSize = curQueue.size + private fun getPositionOfFirstNonDownloadingItem(startPosition: Int, queueItems: List): Int { + val curQueueSize = queueItems.size for (i in startPosition until curQueueSize) { - if (!isItemAtPositionDownloading(i, curQueue)) return i + if (!isItemAtPositionDownloading(i, queueItems)) return i } return curQueueSize } - - private fun isItemAtPositionDownloading(position: Int, curQueue: List): Boolean { - val curItem = try { curQueue[position] } catch (e: IndexOutOfBoundsException) { null } + private fun isItemAtPositionDownloading(position: Int, queueItems: List): Boolean { + val curItem = try { queueItems[position] } catch (e: IndexOutOfBoundsException) { null } if (curItem?.media?.downloadUrl == null) return false return curItem.media != null && DownloadServiceInterface.get()?.isDownloadingEpisode(curItem.media!!.downloadUrl!!)?:false } - - companion object { - private fun getCurrentlyPlayingPosition(curQueue: List, currentPlaying: Playable?): Int { - if (currentPlaying !is EpisodeMedia) return -1 - - val curPlayingItemId = currentPlaying.episode!!.id - for (i in curQueue.indices) { - if (curPlayingItemId == curQueue[i].id) return i - } - return -1 + private fun getCurrentlyPlayingPosition(queueItems: List, currentPlaying: Playable?): Int { + if (currentPlaying !is EpisodeMedia) return -1 + val curPlayingItemId = currentPlaying.episode!!.id + for (i in queueItems.indices) { + if (curPlayingItemId == queueItems[i].id) return i } + return -1 } } } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt index c2ec6232..569fed20 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt @@ -41,7 +41,7 @@ object RealmDB { realm = Realm.open(config) } - fun unmanagedCopy(entity: T) : T { + fun unmanaged(entity: T) : T { if (BuildConfig.DEBUG) { val stackTrace = Thread.currentThread().stackTrace val caller = if (stackTrace.size > 3) stackTrace[3] else null @@ -70,19 +70,21 @@ object RealmDB { suspend fun update(entity: T, block: MutableRealm.(T) -> Unit) : T { return realm.write { - findLatest(entity)?.let { + val result: T = findLatest(entity)?.let { block(it) - } - entity + it + } ?: entity + result } } suspend fun update(entity: T, block: MutableRealm.(T) -> Unit) : T { return realm.write { - findLatest(entity)?.let { + val result: T = findLatest(entity)?.let { block(it) - } - entity + it + } ?: entity + result } } @@ -121,21 +123,24 @@ object RealmDB { Logd(TAG, "${caller?.className}.${caller?.methodName} upsertBlk: ${entity.javaClass.simpleName}") } return realm.writeBlocking { + var result: T = entity if (entity.isManaged()) { - findLatest(entity)?.let { + result = findLatest(entity)?.let { block(it) - } + it + } ?: entity } else { try { - copyToRealm(entity, UpdatePolicy.ALL).let { + result = copyToRealm(entity, UpdatePolicy.ALL).let { block(it) + it } } catch (e: Exception) { Log.e(TAG, "copyToRealm error: ${e.message}") showStackTrace() } } - entity + result } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt index 47999d9f..0fd22dff 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt @@ -30,10 +30,6 @@ class Chapter : EmbeddedRealmObject { this.imageUrl = imageUrl } - fun getHumanReadableIdentifier(): String? { - return title - } - override fun toString(): String { return "ID3Chapter [title=$title, start=$start, url=$link]" } 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 50fce0ed..6a165660 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 @@ -16,7 +16,6 @@ import java.util.* /** * Episode within a feed. - * */ class Episode : RealmObject { @@ -52,7 +51,7 @@ class Episode : RealmObject { @Ignore var feed: Feed? = null get() { - if (field == null && feedId != null) field = getFeed(feedId!!) + if (field == null && feedId != null) field = getFeed(feedId!!, fromDB = true) return field } @@ -64,14 +63,6 @@ class Episode : RealmObject { var paymentLink: String? = null - /** - * Is true if the database contains any chapters that belong to this item. This attribute is only - * written once by DBReader on initialization. - * The FeedItem might still have a non-null chapters value. In this case, the list of chapters - * has not been saved in the database yet. - */ -// private var hasChapters: Boolean - /** * Returns the image of this item, as specified in the feed. * To load the image that can be displayed to the user, use [.getImageLocation], @@ -132,7 +123,6 @@ class Episode : RealmObject { constructor() { this.playState = UNPLAYED -// this.hasChapters = false } /** @@ -143,11 +133,10 @@ class Episode : RealmObject { this.title = title this.identifier = itemIdentifier this.link = link - this.pubDate = if (pubDate != null) pubDate.time else 0 + this.pubDate = pubDate?.time ?: 0 this.playState = state if (feed != null) this.feedId = feed.id this.feed = feed -// this.hasChapters = false } fun updateFromOther(other: Episode) { @@ -218,7 +207,6 @@ class Episode : RealmObject { */ fun setDescriptionIfLonger(newDescription: String?) { if (newDescription.isNullOrEmpty()) return - when { this.description == null -> this.description = newDescription description!!.length < newDescription.length -> this.description = newDescription @@ -227,25 +215,12 @@ class Episode : RealmObject { fun setTranscriptIfLonger(newTranscript: String?) { if (newTranscript.isNullOrEmpty()) return - when { this.transcript == null -> this.transcript = newTranscript transcript!!.length < newTranscript.length -> this.transcript = newTranscript } } -// enum class State { -// UNREAD, IN_PROGRESS, READ, PLAYING -// } - - fun getHumanReadableIdentifier(): String? { - return title - } - -// fun hasChapters(): Boolean { -// return chapters.isNotEmpty() -// } - /** * Get the link for the feed item for the purpose of Share. It fallbacks to * use the feed's link if the named feed item has no link. diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt index 90162c24..d687e4c3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt @@ -1,10 +1,9 @@ package ac.mdiq.podcini.storage.model -import ac.mdiq.podcini.net.feed.parser.media.id3.ChapterReader +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.showStackTrace import android.content.Context import android.os.Parcel import android.os.Parcelable @@ -214,8 +213,9 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { } fun hasEmbeddedPicture(): Boolean { +// TODO: checkEmbeddedPicture needs to update current copy if (hasEmbeddedPicture == null) checkEmbeddedPicture() - return hasEmbeddedPicture!! + return hasEmbeddedPicture ?: false } override fun writeToParcel(dest: Parcel, flags: Int) { @@ -340,6 +340,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { hasEmbeddedPicture = false } } + upsertBlk(episode!!) {} } override fun equals(o: Any?): Boolean { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt index 58b44077..87f8e69b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt @@ -66,7 +66,7 @@ interface Playable : Parcelable, Serializable { * if last played time is unknown. */ /** - * @param lastPlayedTimestamp timestamp in ms + * @param lastPlayedTime timestamp in ms */ fun getLastPlayedTime(): Long @@ -137,7 +137,7 @@ interface Playable : Parcelable, Serializable { fun setDuration(newDuration: Int) - fun setLastPlayedTime(lastPlayedTimestamp: Long) + fun setLastPlayedTime(lastPlayedTime: Long) /** * Returns the location of the image or null if no image is available. diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt index 05f8e9f1..2c0a37ff 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt @@ -156,8 +156,8 @@ class RemoteMedia : Playable { duration = newDuration } - override fun setLastPlayedTime(lastPlayedTimestamp: Long) { - lastPlayedTime = lastPlayedTimestamp + override fun setLastPlayedTime(lastPlayedTime: Long) { + this.lastPlayedTime = lastPlayedTime } override fun onPlaybackStart() { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt index ef7caaa4..454b76c7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt @@ -2,9 +2,10 @@ package ac.mdiq.podcini.storage.model import java.util.ArrayList -class StatisticsItem(val feed: Feed, val time: Long, - val timePlayed: Long, // Respects speed, listening twice, ... - val episodes: Long, // Number of episodes. +class StatisticsItem(val feed: Feed, + val time: Long, // total time, in seconds + val timePlayed: Long, // in seconds, Respects speed, listening twice, ... + val numEpisodes: Long, // Number of episodes. val episodesStarted: Long, // Episodes that are actually played. val totalDownloadSize: Long, // Simply sums up the size of download podcasts. val episodesDownloadCount: Long // Stores the number of episodes downloaded. @@ -17,6 +18,6 @@ class MonthlyStatisticsItem { } class StatisticsResult { - var feedTime: MutableList = ArrayList() + var statsItems: MutableList = ArrayList() var oldestDate: Long = System.currentTimeMillis() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt index cfbd1477..d30a50b5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt @@ -9,7 +9,7 @@ import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.storage.database.Episodes import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode -import ac.mdiq.podcini.storage.database.Episodes.markPlayed +import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Queues import ac.mdiq.podcini.storage.database.Queues.removeFromQueue import ac.mdiq.podcini.ui.utils.LocalDeleteModal @@ -28,11 +28,11 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val R.id.add_to_queue_batch -> queueChecked(items) R.id.remove_from_queue_batch -> removeFromQueueChecked(items) R.id.mark_read_batch -> { - markPlayed(Episode.PLAYED, false, *items.toTypedArray()) + setPlayState(Episode.PLAYED, false, *items.toTypedArray()) showMessage(R.plurals.marked_read_batch_label, items.size) } R.id.mark_unread_batch -> { - markPlayed(Episode.UNPLAYED, false, *items.toTypedArray()) + setPlayState(Episode.UNPLAYED, false, *items.toTypedArray()) showMessage(R.plurals.marked_unread_batch_label, items.size) } R.id.download_batch -> downloadChecked(items) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt index a6ce136a..17271be0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt @@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.actions.actionbutton import android.content.Context import android.view.View import ac.mdiq.podcini.R -import ac.mdiq.podcini.storage.database.Episodes.markPlayed +import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.model.Episode import androidx.media3.common.util.UnstableApi @@ -15,7 +15,7 @@ class MarkAsPlayedActionButton(item: Episode) : EpisodeActionButton(item) { return R.drawable.ic_check } @UnstableApi override fun onClick(context: Context) { - if (!item.isPlayed()) markPlayed(Episode.PLAYED, true, item) + if (!item.isPlayed()) setPlayState(Episode.PLAYED, true, item) } override val visibility: Int diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt index 17ed092c..0d6fb9d2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt @@ -7,35 +7,31 @@ import ac.mdiq.podcini.net.sync.model.EpisodeAction import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.playback.base.InTheatre import ac.mdiq.podcini.playback.base.InTheatre.curQueue -import ac.mdiq.podcini.receiver.MediaButtonReceiver -import ac.mdiq.podcini.storage.utils.EpisodeUtil -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeMedia 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.markPlayed +import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Episodes.setFavorite -import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItemsOnFeed import ac.mdiq.podcini.storage.database.Queues.addToQueue import ac.mdiq.podcini.storage.database.Queues.removeFromQueue -import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.ui.dialog.ShareDialog import ac.mdiq.podcini.ui.utils.LocalDeleteModal -import ac.mdiq.podcini.util.* -import android.os.Handler +import ac.mdiq.podcini.util.IntentUtils +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.ShareUtils import android.view.KeyEvent import android.view.Menu import androidx.annotation.OptIn import androidx.fragment.app.Fragment import androidx.media3.common.util.UnstableApi -import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.math.ceil /** @@ -147,7 +143,7 @@ object EpisodeMenuHandler { } R.id.mark_read_item -> { selectedItem.setPlayed(true) - markPlayed(Episode.PLAYED, true, selectedItem) + setPlayState(Episode.PLAYED, true, selectedItem) if (selectedItem.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) { val media: EpisodeMedia? = selectedItem.media // not all items have media, Gpodder only cares about those that do @@ -164,7 +160,7 @@ object EpisodeMenuHandler { } R.id.mark_unread_item -> { selectedItem.setPlayed(false) - markPlayed(Episode.UNPLAYED, false, selectedItem) + setPlayState(Episode.UNPLAYED, false, selectedItem) if (selectedItem.feed?.isLocalFeed != true && selectedItem.media != null) { val actionNew: EpisodeAction = EpisodeAction.Builder(selectedItem, EpisodeAction.NEW) .currentTimestamp() @@ -182,7 +178,7 @@ object EpisodeMenuHandler { writeNoMediaPlaying() IntentUtils.sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE) } - markPlayed(Episode.UNPLAYED, true, selectedItem) + setPlayState(Episode.UNPLAYED, true, selectedItem) } R.id.visit_website_item -> { val url = selectedItem.getLinkWithFallback() @@ -200,46 +196,4 @@ object EpisodeMenuHandler { // Refresh menu state return true } - - /** - * Remove new flag with additional UI logic to allow undo with Snackbar. - * Undo is useful for Remove new flag, given there is no UI to undo it otherwise - * ,i.e., there is (context) menu item for add new flag - */ - @JvmStatic - fun markReadWithUndo(fragment: Fragment, item: Episode?, playState: Int, showSnackbar: Boolean) { - if (item == null) return - - Logd(TAG, "markReadWithUndo( ${item.id} )") - // we're marking it as unplayed since the user didn't actually play it - // but they don't want it considered 'NEW' anymore - markPlayed(playState, false, item) - - val h = Handler(fragment.requireContext().mainLooper) - val r = Runnable { - val media: EpisodeMedia? = item.media - val shouldAutoDelete: Boolean = if (item.feed == null) false else shouldAutoDeleteItemsOnFeed(item.feed!!) - if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) deleteMediaOfEpisode(fragment.requireContext(), item) - } - val playStateStringRes: Int = when (playState) { - Episode.UNPLAYED -> if (item.playState == Episode.NEW) R.string.removed_inbox_label //was new - else R.string.marked_as_unplayed_label //was played - Episode.PLAYED -> R.string.marked_as_played_label - else -> if (item.playState == Episode.NEW) R.string.removed_inbox_label - else R.string.marked_as_unplayed_label - } - val duration: Int = Snackbar.LENGTH_LONG - - if (showSnackbar) { - (fragment.activity as MainActivity).showSnackbarAbovePlayer( - playStateStringRes, duration) - .setAction(fragment.getString(R.string.undo)) { - markPlayed(item.playState, false, item) - // don't forget to cancel the thing that's going to remove the media - h.removeCallbacks(r) - } - } - - h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toInt().toLong()) - } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt index 575a9b39..e16dd76d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt @@ -2,7 +2,7 @@ package ac.mdiq.podcini.ui.actions.swipeactions import ac.mdiq.podcini.R import ac.mdiq.podcini.playback.base.InTheatre.curQueue -import ac.mdiq.podcini.storage.database.Episodes.markPlayed +import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Queues.removeFromQueue import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.upsert @@ -61,17 +61,12 @@ class RemoveFromQueueSwipeAction : SwipeAction { fun addToQueueAt(episode: Episode, index: Int) : Job { return runOnIOScope { if (curQueue.episodeIds.contains(episode.id)) return@runOnIOScope -// episode.queueId = curQueue.id -// episode.isInCurQueue = true - if (episode.isNew) markPlayed(Episode.UNPLAYED, false, episode) - upsert(episode) {} + if (episode.isNew) setPlayState(Episode.UNPLAYED, false, episode) curQueue.update() curQueue.episodeIds.add(index, episode.id) curQueue.episodes.add(index, episode) upsert(curQueue) {} EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, index)) -// EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode)) - // if (performAutoDownload) autodownloadEpisodeMedia(context) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt index 9b34f561..83b0fe4f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt @@ -3,9 +3,23 @@ package ac.mdiq.podcini.ui.actions.swipeactions import android.content.Context import androidx.fragment.app.Fragment import ac.mdiq.podcini.R -import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler.markReadWithUndo +import ac.mdiq.podcini.preferences.UserPreferences.shouldAutoDeleteItem +import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue +import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync +import ac.mdiq.podcini.storage.database.Episodes.setPlayState +import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync +import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.utils.EpisodeFilter +import ac.mdiq.podcini.storage.utils.EpisodeUtil +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.util.Logd +import android.os.Handler +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.math.ceil class TogglePlaybackStateSwipeAction : SwipeAction { override fun getId(): String { @@ -26,7 +40,50 @@ class TogglePlaybackStateSwipeAction : SwipeAction { override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { val newState = if (item.playState == Episode.UNPLAYED) Episode.PLAYED else Episode.UNPLAYED - markReadWithUndo(fragment, item, newState, willRemove(filter, item)) + + Logd("TogglePlaybackStateSwipeAction", "performAction( ${item.id} )") + // we're marking it as unplayed since the user didn't actually play it + // but they don't want it considered 'NEW' anymore + var item = runBlocking { setPlayStateSync(newState, false, item) } + + val h = Handler(fragment.requireContext().mainLooper) + val r = Runnable { + val media: EpisodeMedia? = item.media + 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) } + } + val playStateStringRes: Int = when (newState) { + Episode.UNPLAYED -> if (item.playState == Episode.NEW) R.string.removed_inbox_label //was new + else R.string.marked_as_unplayed_label //was played + Episode.PLAYED -> R.string.marked_as_played_label + else -> if (item.playState == Episode.NEW) R.string.removed_inbox_label + else R.string.marked_as_unplayed_label + } + val duration: Int = Snackbar.LENGTH_LONG + + if (willRemove(filter, item)) { + (fragment.activity as MainActivity).showSnackbarAbovePlayer( + playStateStringRes, duration) + .setAction(fragment.getString(R.string.undo)) { + setPlayState(item.playState, false, item) + // don't forget to cancel the thing that's going to remove the media + h.removeCallbacks(r) + } + } + + h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toLong()) + } + + private fun delayedExecution(item: Episode, fragment: Fragment, duration: Float) = runBlocking { + delay(ceil((duration * 1.05f).toDouble()).toLong()) + val media: EpisodeMedia? = item.media + val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!) + if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) { +// deleteMediaOfEpisode(fragment.requireContext(), item) + var item = deleteMediaSync(fragment.requireContext(), item) + if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item) } } override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { 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 f74c23af..a1cc3e5a 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 @@ -20,7 +20,6 @@ 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.monitorFeeds -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 @@ -130,7 +129,7 @@ class MainActivity : CastEnabledActivity() { NavDrawerFragment.getSharedPrefs(this@MainActivity) SwipeActions.getSharedPrefs(this@MainActivity) QueueFragment.getSharedPrefs(this@MainActivity) - updateFeedMap() +// updateFeedMap() monitorFeeds() // InTheatre.apply { } PlayerDetailsFragment.getSharedPrefs(this@MainActivity) @@ -223,7 +222,7 @@ class MainActivity : CastEnabledActivity() { } } } - EventFlow.postStickyEvent(FlowEvent.FeedUpdateRunningEvent(isRefreshingFeeds)) + EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(isRefreshingFeeds)) } observeDownloads() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt index 58b8ffba..ba839e43 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt @@ -1,8 +1,7 @@ package ac.mdiq.podcini.ui.adapter import ac.mdiq.podcini.R -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler @@ -12,6 +11,7 @@ import ac.mdiq.podcini.ui.utils.ThemeUtils import ac.mdiq.podcini.ui.view.viewholder.EpisodeViewHolder import android.R.color import android.app.Activity +import android.os.Bundle import android.view.* import androidx.media3.common.util.UnstableApi @@ -88,8 +88,10 @@ open class EpisodesAdapter(mainActivity: MainActivity) beforeBindViewHolder(holder, pos) - val item: Episode = unmanagedCopy(episodes[pos]) + val item: Episode = unmanaged(episodes[pos]) +// val item: Episode = episodes[pos] if (feed != null) item.feed = feed + else item.feed = episodes[pos].feed holder.bind(item) // holder.infoCard.setOnCreateContextMenuListener(this) @@ -128,6 +130,17 @@ open class EpisodesAdapter(mainActivity: MainActivity) holder.hideSeparatorIfNecessary() } + override fun onBindViewHolder(holder: EpisodeViewHolder, pos: Int, payloads: MutableList) { + if (payloads.isEmpty()) onBindViewHolder(holder, pos) + else { + val payload = payloads[0] + when { + payload is String && payload == "foo" -> onBindViewHolder(holder, pos) + payload is Bundle && !payload.getString("PositionUpdate").isNullOrEmpty() -> holder.updatePlaybackPositionNew(unmanaged(episodes[pos])) + } + } + } + protected open fun beforeBindViewHolder(holder: EpisodeViewHolder, pos: Int) {} protected open fun afterBindViewHolder(holder: EpisodeViewHolder, pos: Int) {} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SelectableAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SelectableAdapter.kt index 031b2e2a..bb8ca17d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SelectableAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/SelectableAdapter.kt @@ -16,7 +16,7 @@ abstract class SelectableAdapter(private val activ private val selectedIds = HashSet() private var onSelectModeListener: OnSelectModeListener? = null var shouldSelectLazyLoadedItems: Boolean = false - private var totalNumberOfItems = COUNT_AUTOMATICALLY + internal var totalNumberOfItems = COUNT_AUTOMATICALLY fun startSelectMode(pos: Int) { if (inActionMode()) endSelectMode() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt index d42448fe..a29ad56a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt @@ -2,7 +2,7 @@ package ac.mdiq.podcini.ui.dialog import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.EditTextDialogBinding -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.Feed import android.app.Activity @@ -29,7 +29,7 @@ class CustomFeedNameDialog(activity: Activity, private var feed: Feed) { .setView(binding.root) .setTitle(R.string.rename_feed_label) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - feed = unmanagedCopy(feed) + feed = unmanaged(feed) val newTitle = binding.editText.text.toString() feed.customTitle = newTitle upsertBlk(feed) {} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt index abdaa2fe..47b83436 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeFilterDialog.kt @@ -32,10 +32,8 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() { val newFilterValues: MutableSet = 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(group.checkedButtonId).tag as? String ?: continue newFilterValues.add(tag) } @@ -51,21 +49,24 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() { //add filter rows for (item in FeedItemFilterGroup.entries) { // Logd("EpisodeFilterDialog", "FeedItemFilterGroup: ${item.values[0].filterId} ${item.values[1].filterId}") - val rowBinding = FilterDialogRowBinding.inflate(inflater) - rowBinding.root.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, _: Int, _: Boolean -> - onFilterChanged(newFilterValues) - } - rowBinding.filterButton1.setText(item.values[0].displayName) - rowBinding.filterButton1.tag = item.values[0].filterId - buttonMap[item.values[0].filterId] = rowBinding.filterButton1 - rowBinding.filterButton2.setText(item.values[1].displayName) - rowBinding.filterButton2.tag = item.values[1].filterId - buttonMap[item.values[1].filterId] = rowBinding.filterButton2 - rowBinding.filterButton1.maxLines = 3 - rowBinding.filterButton1.isSingleLine = false - rowBinding.filterButton2.maxLines = 3 - rowBinding.filterButton2.isSingleLine = false - rows.addView(rowBinding.root, rows.childCount - 1) + 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() } @@ -121,7 +122,6 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() { QUEUED(ItemProperties(R.string.queued_label, EpisodeFilter.QUEUED), ItemProperties(R.string.not_queued_label, EpisodeFilter.NOT_QUEUED)), DOWNLOADED(ItemProperties(R.string.hide_downloaded_episodes_label, EpisodeFilter.DOWNLOADED), ItemProperties(R.string.hide_not_downloaded_episodes_label, EpisodeFilter.NOT_DOWNLOADED)); - // this.values = values as Array @JvmField val values: Array = arrayOf(*values) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeSortDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeSortDialog.kt index 195f6e40..491686a1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeSortDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/EpisodeSortDialog.kt @@ -41,7 +41,7 @@ open class EpisodeSortDialog : BottomSheetDialogFragment() { onAddItem(R.string.episode_title, SortOrder.EPISODE_TITLE_A_Z, SortOrder.EPISODE_TITLE_Z_A, true) onAddItem(R.string.feed_title, SortOrder.FEED_TITLE_A_Z, SortOrder.FEED_TITLE_Z_A, true) onAddItem(R.string.duration, SortOrder.DURATION_SHORT_LONG, SortOrder.DURATION_LONG_SHORT, true) - onAddItem(R.string.date, SortOrder.DATE_OLD_NEW, SortOrder.DATE_NEW_OLD, false) + onAddItem(R.string.publish_date, SortOrder.DATE_OLD_NEW, SortOrder.DATE_NEW_OLD, false) onAddItem(R.string.last_played_date, SortOrder.PLAYED_DATE_OLD_NEW, SortOrder.PLAYED_DATE_NEW_OLD, false) onAddItem(R.string.completed_date, SortOrder.COMPLETED_DATE_OLD_NEW, SortOrder.COMPLETED_DATE_NEW_OLD, false) onAddItem(R.string.size, SortOrder.SIZE_SMALL_LARGE, SortOrder.SIZE_LARGE_SMALL, false) 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 ec9872ae..d6090af2 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 @@ -1,7 +1,7 @@ package ac.mdiq.podcini.ui.dialog import ac.mdiq.podcini.R -import ac.mdiq.podcini.storage.database.Feeds.deleteFeed +import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow @@ -12,8 +12,10 @@ import android.content.DialogInterface import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi -import kotlinx.coroutines.* -import java.lang.Runnable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext object RemoveFeedDialog { private val TAG: String = RemoveFeedDialog::class.simpleName ?: "Anonymous" @@ -46,9 +48,9 @@ object RemoveFeedDialog { try { withContext(Dispatchers.IO) { for (feed in feeds) { - deleteFeed(context, feed.id, false) + deleteFeedSync(context, feed.id, false) } -// EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds)) + EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds.map { it.id })) } withContext(Dispatchers.Main) { Logd(TAG, "Feed(s) deleted") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/TagSettingsDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/TagSettingsDialog.kt index 189d67ce..f9d6d29f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/TagSettingsDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/TagSettingsDialog.kt @@ -5,13 +5,11 @@ import ac.mdiq.podcini.databinding.EditTagsDialogBinding import ac.mdiq.podcini.storage.database.Feeds.buildTags import ac.mdiq.podcini.storage.database.Feeds.getTags import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.ui.adapter.SimpleChipAdapter import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration -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 @@ -108,7 +106,7 @@ class TagSettingsDialog : DialogFragment() { // displayedTags.add(FeedPreferences.TAG_ROOT) // } for (feed_ in feedList) { - val feed = unmanagedCopy(feed_) + val feed = unmanaged(feed_) if (feed.preferences != null) { feed.preferences!!.tags.removeAll(commonTags) feed.preferences!!.tags.addAll(displayedTags) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt index a5d9bd41..097a0057 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt @@ -13,7 +13,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.playbackSpeedArray import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration @@ -229,7 +229,7 @@ import java.util.* if (episode != null) { var feed = episode.feed if (feed != null) { - feed = unmanagedCopy(feed) + feed = unmanaged(feed) val feedPrefs = feed.preferences if (feedPrefs != null) { feedPrefs.playSpeed = speed diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt index b9af9ed8..4810f191 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AllEpisodesFragment.kt @@ -43,7 +43,6 @@ import kotlin.math.min toolbar.inflateMenu(R.menu.episodes) toolbar.setTitle(R.string.episodes_label) updateToolbar() - updateFilterUi() txtvInformation.setOnClickListener { AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) } @@ -62,6 +61,7 @@ import kotlin.math.min override fun loadData(): List { allEpisodes = getEpisodes(0, Int.MAX_VALUE, getFilter(), allEpisodesSortOrder, false) + Logd(TAG, "loadData ${allEpisodes.size}") if (allEpisodes.isEmpty()) return listOf() return allEpisodes.subList(0, min(allEpisodes.size-1, page * EPISODES_PER_PAGE)) } @@ -123,18 +123,19 @@ import kotlin.math.min private fun onFilterChanged(event: FlowEvent.AllEpisodesFilterEvent) { prefFilterAllEpisodes = StringUtils.join(event.filterValues, ",") - updateFilterUi() page = 1 loadItems() } - private fun updateFilterUi() { + override fun updateToolbar() { swipeActions.setFilter(getFilter()) if (getFilter().values.isNotEmpty()) { txtvInformation.visibility = View.VISIBLE + txtvInformation.text = "${adapter.totalNumberOfItems} episodes - filtered" emptyView.setMessage(R.string.no_all_episodes_filtered_label) } else { - txtvInformation.visibility = View.GONE + txtvInformation.visibility = View.VISIBLE + txtvInformation.text = "${adapter.totalNumberOfItems} episodes" emptyView.setMessage(R.string.no_all_episodes_label) } toolbar.menu?.findItem(R.id.action_favorites)?.setIcon(if (getFilter().showIsFavorite) R.drawable.ic_star else R.drawable.ic_star_border) 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 2e0fa70d..462603b9 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 @@ -23,7 +23,6 @@ import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.receiver.MediaButtonReceiver -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy import ac.mdiq.podcini.storage.model.Chapter import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Playable diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt index 23cbc32c..69c8e44e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt @@ -4,7 +4,9 @@ import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.BaseEpisodesListFragmentBinding import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler @@ -17,7 +19,6 @@ import ac.mdiq.podcini.ui.dialog.ConfirmationDialog import ac.mdiq.podcini.ui.utils.EmptyViewHandler import ac.mdiq.podcini.ui.view.EpisodesRecyclerView import ac.mdiq.podcini.ui.utils.LiftOnScrollListener -import ac.mdiq.podcini.ui.view.viewholder.EpisodeViewHolder import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow @@ -63,10 +64,10 @@ import kotlinx.coroutines.flow.collectLatest lateinit var swipeRefreshLayout: SwipeRefreshLayout lateinit var swipeActions: SwipeActions private lateinit var progressBar: ProgressBar - lateinit var listAdapter: EpisodesAdapter + lateinit var adapter: EpisodesAdapter protected lateinit var txtvInformation: TextView - private var currentPlaying: EpisodeViewHolder? = null + private var curIndex = -1 @JvmField var episodes: MutableList = ArrayList() @@ -119,7 +120,7 @@ import kotlinx.coroutines.flow.collectLatest emptyView.setIcon(R.drawable.ic_feed) emptyView.setTitle(R.string.no_all_episodes_head_label) emptyView.setMessage(R.string.no_all_episodes_label) - emptyView.updateAdapter(listAdapter) + emptyView.updateAdapter(adapter) emptyView.hide() val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root) @@ -131,7 +132,7 @@ import kotlinx.coroutines.flow.collectLatest return false } override fun onToggleChanged(open: Boolean) { - if (open && listAdapter.selectedCount == 0) { + if (open && adapter.selectedCount == 0) { (activity as MainActivity).showSnackbarAbovePlayer(R.string.no_items_selected, Snackbar.LENGTH_SHORT) speedDialView.close() } @@ -139,7 +140,7 @@ import kotlinx.coroutines.flow.collectLatest }) speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> var confirmationString = 0 - if (listAdapter.selectedItems.size >= 25 || listAdapter.shouldSelectLazyLoadedItems()) { + if (adapter.selectedItems.size >= 25 || adapter.shouldSelectLazyLoadedItems()) { // Should ask for confirmation when (actionItem.id) { R.id.mark_read_batch -> confirmationString = R.string.multi_select_mark_played_confirmation @@ -160,7 +161,7 @@ import kotlinx.coroutines.flow.collectLatest } open fun createListAdaptor() { - listAdapter = object : EpisodesAdapter(activity as MainActivity) { + adapter = object : EpisodesAdapter(activity as MainActivity) { override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { super.onCreateContextMenu(menu, v, menuInfo) // if (!inActionMode()) { @@ -171,8 +172,8 @@ import kotlinx.coroutines.flow.collectLatest } } } - listAdapter.setOnSelectModeListener(this) - recyclerView.adapter = listAdapter + adapter.setOnSelectModeListener(this) + recyclerView.adapter = adapter } override fun onStart() { @@ -221,13 +222,13 @@ import kotlinx.coroutines.flow.collectLatest // The method is called on all fragments in a ViewPager, so this needs to be ignored in invisible ones. // Apparently, none of the visibility check method works reliably on its own, so we just use all. !userVisibleHint || !isVisible || !isMenuVisible -> return false - listAdapter.longPressedItem == null -> { + adapter.longPressedItem == null -> { Logd(TAG, "Selected item or listAdapter was null, ignoring selection") return super.onContextItemSelected(item) } - listAdapter.onContextItemSelected(item) -> return true + adapter.onContextItemSelected(item) -> return true else -> { - val selectedItem: Episode = listAdapter.longPressedItem ?: return false + val selectedItem: Episode = adapter.longPressedItem ?: return false return EpisodeMenuHandler.onMenuItemClicked(this, item.itemId, selectedItem) } } @@ -238,8 +239,8 @@ import kotlinx.coroutines.flow.collectLatest lifecycleScope.launch { try { withContext(Dispatchers.IO) { - handler.handleAction(listAdapter.selectedItems.filterIsInstance()) - if (listAdapter.shouldSelectLazyLoadedItems()) { + handler.handleAction(adapter.selectedItems.filterIsInstance()) + if (adapter.shouldSelectLazyLoadedItems()) { var applyPage = page + 1 var nextPage: List do { @@ -249,7 +250,7 @@ import kotlinx.coroutines.flow.collectLatest } while (nextPage.size == EPISODES_PER_PAGE) } withContext(Dispatchers.Main) { - listAdapter.endSelectMode() + adapter.endSelectMode() } } } catch (e: Throwable) { @@ -290,12 +291,12 @@ import kotlinx.coroutines.flow.collectLatest } withContext(Dispatchers.Main) { // listAdapter.setDummyViews(0) - listAdapter.updateItems(episodes) - if (listAdapter.shouldSelectLazyLoadedItems()) listAdapter.setSelected(episodes.size - data.size, episodes.size, true) + adapter.updateItems(episodes) + if (adapter.shouldSelectLazyLoadedItems()) adapter.setSelected(episodes.size - data.size, episodes.size, true) } } catch (e: Throwable) { // listAdapter.setDummyViews(0) - listAdapter.updateItems(emptyList()) + adapter.updateItems(emptyList()) Log.e(TAG, Log.getStackTraceString(e)) } finally { withContext(Dispatchers.Main) { recyclerView.post { isLoadingMore = false } } @@ -306,7 +307,7 @@ import kotlinx.coroutines.flow.collectLatest override fun onDestroyView() { super.onDestroyView() _binding = null - listAdapter.endSelectMode() + adapter.endSelectMode() } override fun onStartSelectMode() { @@ -326,25 +327,21 @@ import kotlinx.coroutines.flow.collectLatest episodes.removeAt(pos) if (getFilter().matches(item)) { episodes.add(pos, item) - listAdapter.notifyItemChangedCompat(pos) - } else listAdapter.notifyItemRemoved(pos) + adapter.notifyItemChangedCompat(pos) + } else adapter.notifyItemRemoved(pos) } } } private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { -// Logd(TAG, "onEventMainThread() called with ${event.TAG}") - if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) - else { - Logd(TAG, "onEventMainThread() ${event.TAG} search list") - for (i in 0 until listAdapter.itemCount) { - val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder - if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { - currentPlaying = holder - holder.notifyPlaybackPositionUpdated(event) - break - } - } + val item = (event.media as? EpisodeMedia)?.episode ?: return + val pos = if (curIndex in 0..= 0) { + episodes[pos] = item + curIndex = pos + adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") }) } } @@ -352,7 +349,7 @@ import kotlinx.coroutines.flow.collectLatest if (!isAdded || !isVisible || !isMenuVisible) return when (event.keyCode) { KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0) - KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(listAdapter.itemCount) + KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(adapter.itemCount) else -> {} } } @@ -360,7 +357,7 @@ import kotlinx.coroutines.flow.collectLatest private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { for (downloadUrl in event.urls) { val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl) - if (pos >= 0) listAdapter.notifyItemChangedCompat(pos) + if (pos >= 0) adapter.notifyItemChangedCompat(pos) } } @@ -393,7 +390,7 @@ import kotlinx.coroutines.flow.collectLatest Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) - is FlowEvent.FeedUpdateRunningEvent -> onFeedUpdateRunningEvent(event) + is FlowEvent.FeedUpdatingEvent -> onFeedUpdateRunningEvent(event) else -> {} } } @@ -424,14 +421,14 @@ import kotlinx.coroutines.flow.collectLatest hasMoreItems = !(page == 1 && episodes.size < EPISODES_PER_PAGE) progressBar.visibility = View.GONE // listAdapter.setDummyViews(0) - listAdapter.updateItems(episodes) - listAdapter.setTotalNumberOfItems(data.second) + adapter.updateItems(episodes) + adapter.setTotalNumberOfItems(data.second) if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName()) updateToolbar() } } catch (e: Throwable) { // listAdapter.setDummyViews(0) - listAdapter.updateItems(emptyList()) + adapter.updateItems(emptyList()) Log.e(TAG, Log.getStackTraceString(e)) } } @@ -452,8 +449,8 @@ import kotlinx.coroutines.flow.collectLatest protected open fun updateToolbar() {} - private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) { - swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning + private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdatingEvent) { + swipeRefreshLayout.isRefreshing = event.isRunning } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt index edb02bb2..9ff65e61 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt @@ -5,6 +5,7 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.databinding.SimpleListFragmentBinding import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Episodes.getEpisodes import ac.mdiq.podcini.storage.database.RealmDB.realm @@ -69,7 +70,7 @@ import java.util.* private lateinit var emptyView: EmptyViewHandler private var displayUpArrow = false - private var currentPlaying: EpisodeViewHolder? = null + private var curIndex = -1 @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = SimpleListFragmentBinding.inflate(inflater) @@ -239,8 +240,7 @@ import java.util.* val item = event.episode val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) if (pos >= 0) { - episodes.removeAt(pos) - episodes.add(pos, item) + episodes[pos] = item adapter.notifyItemChangedCompat(pos) } } @@ -250,8 +250,7 @@ import java.util.* val item = event.episode val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) if (pos >= 0) { - episodes.removeAt(pos) - episodes.add(pos, item) + episodes[pos] = item adapter.notifyItemChangedCompat(pos) } } @@ -302,18 +301,14 @@ import java.util.* } private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { -// Logd(TAG, "onPlaybackPositionEvent called with ${event.TAG}") - if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) - else { - Logd(TAG, "onPlaybackPositionEvent ${event.TAG} search list") - for (i in 0 until adapter.itemCount) { - val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder - if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { - currentPlaying = holder - holder.notifyPlaybackPositionUpdated(event) - break - } - } + val item = (event.media as? EpisodeMedia)?.episode ?: return + val pos = if (curIndex in 0..= 0) { + episodes[pos] = item + curIndex = pos + adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") }) } refreshInfoBar() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt index 2cd3a7a4..7bb9f90d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt @@ -61,7 +61,7 @@ class EpisodeHomeFragment : Fragment() { toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } toolbar.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) - if (!currentItem?.link.isNullOrEmpty()) showContent() + if (!episode?.link.isNullOrEmpty()) showContent() else { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() parentFragmentManager.popBackStack() @@ -88,24 +88,24 @@ class EpisodeHomeFragment : Fragment() { @OptIn(UnstableApi::class) private fun showReaderContent() { runOnIOScope { - if (!currentItem?.link.isNullOrEmpty()) { + if (!episode?.link.isNullOrEmpty()) { if (cleanedNotes == null) { - if (currentItem?.transcript == null) { - val url = currentItem!!.link!! + if (episode?.transcript == null) { + val url = episode!!.link!! val htmlSource = fetchHtmlSource(url) - val article = Readability4JExtended(currentItem?.link!!, htmlSource).parse() + val article = Readability4JExtended(episode?.link!!, htmlSource).parse() readerText = article.textContent // Log.d(TAG, "readability4J: ${article.textContent}") readerhtml = article.contentWithDocumentsCharsetOrUtf8 } else { - readerhtml = currentItem!!.transcript + readerhtml = episode!!.transcript readerText = HtmlCompat.fromHtml(readerhtml!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() } if (!readerhtml.isNullOrEmpty()) { val shownotesCleaner = ShownotesCleaner(requireContext()) cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0) - currentItem!!.setTranscriptIfLonger(readerhtml) - persistEpisode(currentItem) + episode!!.setTranscriptIfLonger(readerhtml) + persistEpisode(episode) } } } @@ -133,12 +133,12 @@ class EpisodeHomeFragment : Fragment() { if (tts == null) { tts = TextToSpeech(context) { status: Int -> if (status == TextToSpeech.SUCCESS) { - if (currentItem?.feed?.language != null) { - val result = tts?.setLanguage(Locale(currentItem!!.feed!!.language!!)) + if (episode?.feed?.language != null) { + val result = tts?.setLanguage(Locale(episode!!.feed!!.language!!)) if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { - Log.w(TAG, "TTS language not supported ${currentItem?.feed?.language}") + Log.w(TAG, "TTS language not supported ${episode?.feed?.language}") requireActivity().runOnUiThread { - Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${currentItem?.feed?.language}", Toast.LENGTH_LONG).show() + Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${episode?.feed?.language}", Toast.LENGTH_LONG).show() } } } @@ -154,10 +154,10 @@ class EpisodeHomeFragment : Fragment() { } private fun showWebContent() { - if (!currentItem?.link.isNullOrEmpty()) { + if (!episode?.link.isNullOrEmpty()) { binding.webView.settings.javaScriptEnabled = jsEnabled - Logd(TAG, "currentItem!!.link ${currentItem!!.link}") - binding.webView.loadUrl(currentItem!!.link!!) + Logd(TAG, "currentItem!!.link ${episode!!.link}") + binding.webView.loadUrl(episode!!.link!!) binding.readerView.visibility = View.GONE binding.webView.visibility = View.VISIBLE } else Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() @@ -207,7 +207,7 @@ class EpisodeHomeFragment : Fragment() { if (!ttsPlaying) { ttsPlaying = true if (!readerText.isNullOrEmpty()) { - ttsSpeed = currentItem?.feed?.preferences?.playSpeed ?: 1.0f + ttsSpeed = episode?.feed?.preferences?.playSpeed ?: 1.0f tts?.setSpeechRate(ttsSpeed) while (startIndex < readerText!!.length) { val endIndex = minOf(startIndex + MAX_CHUNK_LENGTH, readerText!!.length) @@ -237,7 +237,7 @@ class EpisodeHomeFragment : Fragment() { return true } else -> { - return currentItem != null + return episode != null } } } @@ -268,7 +268,7 @@ class EpisodeHomeFragment : Fragment() { } @UnstableApi private fun updateAppearance() { - if (currentItem == null) { + if (episode == null) { Logd(TAG, "updateAppearance currentItem is null") return } @@ -281,12 +281,12 @@ class EpisodeHomeFragment : Fragment() { private val TAG: String = EpisodeHomeFragment::class.simpleName ?: "Anonymous" private const val MAX_CHUNK_LENGTH = 2000 - var currentItem: Episode? = null + var episode: Episode? = null // unmanged fun newInstance(item: Episode): EpisodeHomeFragment { val fragment = EpisodeHomeFragment() Logd(TAG, "item.itemIdentifier ${item.identifier}") - if (item.identifier != currentItem?.identifier) currentItem = item + if (item.identifier != episode?.identifier) episode = item return fragment } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index 684aa257..469a8648 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -11,7 +11,7 @@ import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.base.InTheatre import ac.mdiq.podcini.preferences.UsageStatistics import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +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.EpisodeMedia @@ -74,7 +74,7 @@ import kotlin.math.max private var homeFragment: EpisodeHomeFragment? = null private var itemLoaded = false - private var episode: Episode? = null + private var episode: Episode? = null // unmanaged private var webviewData: String? = null private lateinit var shownotesCleaner: ShownotesCleaner @@ -409,7 +409,8 @@ import kotlin.math.max private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) { if (episode?.id == event.episode.id) { - episode = unmanagedCopy(event.episode) + episode = unmanaged(event.episode) +// episode = event.episode prepareMenu() } } @@ -420,7 +421,8 @@ import kotlin.math.max while (i < size) { val item_ = event.episodes[i] if (item_.id == episode?.id) { - episode = unmanagedCopy(item_) + episode = unmanaged(item_) +// episode = item_ prepareMenu() break } @@ -469,7 +471,7 @@ import kotlin.math.max } fun setItem(item_: Episode) { - episode = unmanagedCopy(item_) + episode = unmanaged(item_) } companion object { 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 2237c304..d2f46238 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 @@ -5,15 +5,18 @@ import ac.mdiq.podcini.databinding.FeedItemListFragmentBinding import ac.mdiq.podcini.databinding.MoreContentListFooterBinding import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Feeds.getFeed import ac.mdiq.podcini.storage.database.LogsAndStats.getFeedDownloadLog import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +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.DownloadResult import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.storage.utils.EpisodeUtil @@ -79,14 +82,14 @@ import java.util.concurrent.Semaphore private lateinit var swipeActions: SwipeActions private lateinit var nextPageLoader: MoreContentListFooterUtil - private var currentPlaying: EpisodeViewHolder? = null - private var displayUpArrow = false private var headerCreated = false private var feedID: Long = 0 private var feed: Feed? = null private var episodes: MutableList = mutableListOf() + private var curIndex = -1 + private var enableFilter: Boolean = true private val ioScope = CoroutineScope(Dispatchers.IO) @@ -271,7 +274,7 @@ import java.util.concurrent.Semaphore Thread { try { if (feed != null) { - val feed_ = unmanagedCopy(feed!!) + val feed_ = unmanaged(feed!!) feed_.nextPageLink = feed_.downloadUrl feed_.pageNr = 0 upsertBlk(feed_) {} @@ -318,10 +321,7 @@ import java.util.concurrent.Semaphore @UnstableApi override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { val activity: MainActivity = activity as MainActivity - if (feed != null) { -// val ids: LongArray = FeedItemUtil.getIds(feed!!.items) - activity.loadChildFragment(EpisodeInfoFragment.newInstance(episodes[position])) - } + if (feed != null) activity.loadChildFragment(EpisodeInfoFragment.newInstance(episodes[position])) } private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { @@ -375,8 +375,7 @@ import java.util.concurrent.Semaphore val item = event.episode val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) if (pos >= 0) { - episodes.removeAt(pos) - episodes.add(pos, item) + episodes[pos] = item adapter.notifyItemChangedCompat(pos) // episodes[pos].playState = item.playState // adapter.notifyItemChangedCompat(pos) @@ -387,11 +386,8 @@ import java.util.concurrent.Semaphore val item = event.episode val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) if (pos >= 0) { - episodes.removeAt(pos) - episodes.add(pos, item) + episodes[pos] = item adapter.notifyItemChangedCompat(pos) -// episodes[pos].isFavorite = item.isFavorite -// adapter.notifyItemChangedCompat(pos) } } @@ -409,18 +405,14 @@ import java.util.concurrent.Semaphore } private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { -// Logd(TAG, "onEventMainThread() called with ${event.TAG}") - if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) - else { - Logd(TAG, "onEventMainThread() ${event.TAG} search list") - for (i in 0 until adapter.itemCount) { - val holder: EpisodeViewHolder? = binding.recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder - if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { - currentPlaying = holder - holder.notifyPlaybackPositionUpdated(event) - break - } - } + val item = (event.media as? EpisodeMedia)?.episode ?: return + val pos = if (curIndex in 0..= 0) { + episodes[pos] = item + curIndex = pos + adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") }) } } @@ -459,7 +451,7 @@ import java.util.concurrent.Semaphore Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) - is FlowEvent.FeedUpdateRunningEvent -> onFeedUpdateRunningEvent(event) + is FlowEvent.FeedUpdatingEvent -> onFeedUpdateRunningEvent(event) else -> {} } } @@ -492,10 +484,10 @@ import java.util.concurrent.Semaphore swipeActions.attachTo(binding.recyclerView) } - private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) { - nextPageLoader.setLoadingState(event.isFeedUpdateRunning) - if (!event.isFeedUpdateRunning) nextPageLoader.root.visibility = View.GONE - binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning + private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdatingEvent) { + nextPageLoader.setLoadingState(event.isRunning) + if (!event.isRunning) nextPageLoader.root.visibility = View.GONE + binding.swipeRefresh.isRefreshing = event.isRunning } private fun refreshSwipeTelltale() { @@ -611,7 +603,7 @@ import java.util.concurrent.Semaphore lifecycleScope.launch { try { feed = withContext(Dispatchers.IO) { - val feed_ = getFeed(feedID) + val feed_ = getFeed(feedID, fromDB = true) if (feed_ != null) { episodes.clear() if (enableFilter && !feed_.preferences?.filterString.isNullOrEmpty()) { @@ -648,8 +640,8 @@ import java.util.concurrent.Semaphore swipeActions.setFilter(feed?.episodeFilter) refreshHeaderView() binding.progressBar.visibility = View.GONE - adapter.setDummyViews(0) - if (feed != null && episodes.isNotEmpty()) { +// adapter.setDummyViews(0) + if (feed != null) { adapter.updateItems(episodes, feed) binding.header.counts.text = episodes.size.toString() } @@ -658,7 +650,7 @@ import java.util.concurrent.Semaphore } catch (e: Throwable) { feed = null refreshHeaderView() - adapter.setDummyViews(0) +// adapter.setDummyViews(0) adapter.updateItems(emptyList()) updateToolbar() Log.e(TAG, Log.getStackTraceString(e)) @@ -694,10 +686,8 @@ import java.util.concurrent.Semaphore runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() if (feed_ != null) { - realm.write { - findLatest(feed_)?.let { - it.preferences?.filterString = newFilterValues.joinToString() - } + upsert(feed_) { + it.preferences?.filterString = newFilterValues.joinToString() } } } @@ -725,10 +715,8 @@ import java.util.concurrent.Semaphore runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() if (feed_ != null) { - realm.write { - findLatest(feed_)?.let { - it.sortOrder = sortOrder - } + upsert(feed_) { + it.sortOrder = sortOrder } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt index 1e7d08c1..27e6fab2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt @@ -8,7 +8,7 @@ import ac.mdiq.podcini.databinding.PlaybackSpeedFeedSettingDialogBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences -import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction @@ -19,8 +19,6 @@ import ac.mdiq.podcini.ui.dialog.AuthenticationDialog import ac.mdiq.podcini.ui.dialog.TagSettingsDialog import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration 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.content.DialogInterface import android.content.Intent @@ -514,7 +512,7 @@ class FeedSettingsFragment : Fragment() { } fun setFeed(feed_: Feed) { - feed = unmanagedCopy(feed_) + feed = unmanaged(feed_) } companion object { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt index 7cba0cf2..ce850cfa 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt @@ -5,7 +5,6 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.storage.utils.SortOrder import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils import ac.mdiq.podcini.ui.activity.MainActivity @@ -58,7 +57,7 @@ import kotlin.math.min } override fun createListAdaptor() { - listAdapter = object : EpisodesAdapter(activity as MainActivity) { + adapter = object : EpisodesAdapter(activity as MainActivity) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { return object: EpisodeViewHolder(mainActivityRef.get()!!, parent) { override fun setPubDate(item: Episode) { @@ -73,8 +72,8 @@ import kotlin.math.min MenuItemUtils.setOnClickListeners(menu) { item: MenuItem -> this@HistoryFragment.onContextItemSelected(item) } } } - listAdapter.setOnSelectModeListener(this) - recyclerView.adapter = listAdapter + adapter.setOnSelectModeListener(this) + recyclerView.adapter = adapter } override fun onStart() { @@ -124,6 +123,18 @@ import kotlin.math.min toolbar.menu.findItem(R.id.episodes_sort).setVisible(episodes.isNotEmpty()) toolbar.menu.findItem(R.id.filter_items).setVisible(episodes.isNotEmpty()) toolbar.menu.findItem(R.id.clear_history_item).setVisible(episodes.isNotEmpty()) + + swipeActions.setFilter(getFilter()) + if (getFilter().values.isNotEmpty()) { + txtvInformation.visibility = View.VISIBLE + txtvInformation.text = "${adapter.totalNumberOfItems} episodes - filtered" + emptyView.setMessage(R.string.no_all_episodes_filtered_label) + } else { + txtvInformation.visibility = View.VISIBLE + txtvInformation.text = "${adapter.totalNumberOfItems} episodes" + emptyView.setMessage(R.string.no_all_episodes_label) + } + toolbar.menu?.findItem(R.id.action_favorites)?.setIcon(if (getFilter().showIsFavorite) R.drawable.ic_star else R.drawable.ic_star_border) } private var eventSink: Job? = null 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 1b98f491..1f87cf22 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 @@ -6,6 +6,7 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.databinding.QueueFragmentBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.playback.base.InTheatre.curQueue +import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Queues.clearQueue @@ -15,6 +16,7 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.SortOrder @@ -83,7 +85,7 @@ import java.util.* private var queueItems: MutableList = mutableListOf() private var adapter: QueueRecyclerAdapter? = null - private var currentPlaying: EpisodeViewHolder? = null + private var curIndex = -1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -220,7 +222,7 @@ import java.util.* Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) - is FlowEvent.FeedUpdateRunningEvent -> swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning + is FlowEvent.FeedUpdatingEvent -> swipeRefreshLayout.isRefreshing = event.isRunning else -> {} } } @@ -327,20 +329,14 @@ import java.util.* } private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { -// Logd(TAG, "onEventMainThread() called with ${event.TAG}") - if (adapter != null) { - if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) - else { - Logd(TAG, "onPlaybackPositionEvent() ${event.TAG} search list") - for (i in 0 until adapter!!.itemCount) { - val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder - if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { - currentPlaying = holder - holder.notifyPlaybackPositionUpdated(event) - break - } - } - } + val item = (event.media as? EpisodeMedia)?.episode ?: return + val pos = if (curIndex in 0..= 0) { + queueItems[pos] = item + curIndex = pos + adapter?.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") }) } } @@ -546,7 +542,7 @@ import java.util.* queueItems.clear() queueItems.addAll(curQueue.episodes) binding.progressBar.visibility = View.GONE - adapter?.setDummyViews(0) +// adapter?.setDummyViews(0) adapter?.updateItems(queueItems) if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) refreshInfoBar() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt index 03cf1cfe..cd296b58 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt @@ -2,7 +2,6 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent @@ -33,7 +32,7 @@ import kotlin.math.min toolbar.inflateMenu(R.menu.episodes) toolbar.setTitle(R.string.episodes_label) updateToolbar() - listAdapter.setOnSelectModeListener(null) + adapter.setOnSelectModeListener(null) return root } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index feee19c7..08158f71 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -6,8 +6,10 @@ import ac.mdiq.podcini.databinding.HorizontalFeedItemBinding import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.databinding.SearchFragmentBinding import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher +import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler @@ -76,7 +78,8 @@ import java.lang.ref.WeakReference private lateinit var automaticSearchDebouncer: Handler private var results: MutableList = mutableListOf() - private var currentPlaying: EpisodeViewHolder? = null + private var curIndex = -1 + private var lastQueryChange: Long = 0 private var isOtherViewInFoucus = false @@ -254,8 +257,8 @@ import java.lang.ref.WeakReference Logd(TAG, "Received event: ${event.TAG}") when (event) { is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> search() - is FlowEvent.EpisodeEvent -> onEventMainThread(event) - is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event) + is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) + is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(event) else -> {} } } @@ -264,14 +267,14 @@ import java.lang.ref.WeakReference EventFlow.stickyEvents.collectLatest { event -> Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { - is FlowEvent.EpisodeDownloadEvent -> onEventMainThread(event) + is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) else -> {} } } } } - fun onEventMainThread(event: FlowEvent.EpisodeEvent) { + private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { // Logd(TAG, "onEventMainThread() called with ${event.TAG}") var i = 0 val size: Int = event.episodes.size @@ -287,29 +290,26 @@ import java.lang.ref.WeakReference } } - fun onEventMainThread(event: FlowEvent.EpisodeDownloadEvent) { + private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { for (downloadUrl in event.urls) { val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, downloadUrl) if (pos >= 0) adapter.notifyItemChangedCompat(pos) } } - fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { - if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) - currentPlaying!!.notifyPlaybackPositionUpdated(event) - else { - Logd(TAG, "onEventMainThread() ${event.TAG} search list") - for (i in 0 until adapter.itemCount) { - val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder - if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { - currentPlaying = holder - holder.notifyPlaybackPositionUpdated(event) - break - } - } + private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { + val item = (event.media as? EpisodeMedia)?.episode ?: return + val pos = if (curIndex in 0..= 0) { + results[pos] = item + curIndex = pos + adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") }) } } + @UnstableApi private fun searchWithProgressBar() { progressBar.visibility = View.VISIBLE emptyViewHandler.hide() 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 7b1a9ece..36e14b42 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 @@ -56,6 +56,7 @@ import io.realm.kotlin.query.Sort import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.NumberFormat @@ -69,8 +70,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec private var _binding: FragmentSubscriptionsBinding? = null private val binding get() = _binding!! - private lateinit var subscriptionRecycler: RecyclerView - private lateinit var listAdapter: SubscriptionsAdapter<*> + private lateinit var recyclerView: RecyclerView + private lateinit var adapter: SubscriptionsAdapter<*> private lateinit var emptyView: EmptyViewHandler private lateinit var toolbar: MaterialToolbar private lateinit var speedDialView: SpeedDialView @@ -100,8 +101,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec toolbar = binding.toolbar toolbar.setOnMenuItemClickListener(this) toolbar.setOnLongClickListener { - subscriptionRecycler.scrollToPosition(5) - subscriptionRecycler.post { subscriptionRecycler.smoothScrollToPosition(0) } + recyclerView.scrollToPosition(5) + recyclerView.post { recyclerView.smoothScrollToPosition(0) } false } displayUpArrow = parentFragmentManager.backStackEntryCount != 0 @@ -115,10 +116,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec toolbar.title = displayedFolder } - subscriptionRecycler = binding.subscriptionsGrid - subscriptionRecycler.addItemDecoration(GridDividerItemDecorator()) - registerForContextMenu(subscriptionRecycler) - subscriptionRecycler.addOnScrollListener(LiftOnScrollListener(binding.appbar)) + recyclerView = binding.subscriptionsGrid + recyclerView.addItemDecoration(GridDividerItemDecorator()) + registerForContextMenu(recyclerView) + recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) initAdapter() setupEmptyView() @@ -144,7 +145,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 } - listAdapter.setItems(resultList) + adapter.setItems(resultList) true } else false } @@ -172,7 +173,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec override fun onToggleChanged(isOpen: Boolean) {} }) speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> - FeedMultiSelectActionHandler(activity as MainActivity, listAdapter.selectedItems.filterIsInstance()).handleAction(actionItem.id) + FeedMultiSelectActionHandler(activity as MainActivity, adapter.selectedItems.filterIsInstance()).handleAction(actionItem.id) true } loadSubscriptions() @@ -184,13 +185,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec useGrid = useGridLayout var spanCount = 1 if (useGrid!!) { - listAdapter = GridAdapter() + adapter = GridAdapter() spanCount = 3 - } else listAdapter = ListAdapter() - subscriptionRecycler.layoutManager = GridLayoutManager(context, spanCount, RecyclerView.VERTICAL, false) - listAdapter.setOnSelectModeListener(this) - subscriptionRecycler.adapter = listAdapter - listAdapter.setItems(feedListFiltered) + } else adapter = ListAdapter() + recyclerView.layoutManager = GridLayoutManager(context, spanCount, RecyclerView.VERTICAL, false) + adapter.setOnSelectModeListener(this) + recyclerView.adapter = adapter + adapter.setItems(feedListFiltered) } } @@ -202,8 +203,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } override fun onStop() { + Logd(TAG, "onStop()") super.onStop() - listAdapter.endSelectMode() + adapter.endSelectMode() cancelFlowEvents() } @@ -232,7 +234,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } } binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() - listAdapter.setItems(feedListFiltered) + adapter.setItems(feedListFiltered) } private fun resetTags() { @@ -255,8 +257,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") when (event) { - is FlowEvent.FeedListEvent -> loadSubscriptions() - is FlowEvent.EpisodePlayedEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions() + is FlowEvent.FeedListEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions() + is FlowEvent.EpisodePlayedEvent -> loadSubscriptions() is FlowEvent.FeedTagsChangedEvent -> loadSubscriptions() else -> {} } @@ -266,7 +268,12 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec EventFlow.stickyEvents.collectLatest { event -> Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { - is FlowEvent.FeedUpdateRunningEvent -> binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning + is FlowEvent.FeedUpdatingEvent -> { + Logd(TAG, "FeedUpdateRunningEvent: ${event.isRunning}") + binding.swipeRefresh.isRefreshing = event.isRunning + if (!event.isRunning && event.id != prevFeedUpdatingEvent?.id) loadSubscriptions() + prevFeedUpdatingEvent = event + } else -> {} } } @@ -289,7 +296,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec emptyView.setIcon(R.drawable.ic_subscriptions) emptyView.setTitle(R.string.no_subscriptions_head_label) emptyView.setMessage(R.string.no_subscriptions_label) - emptyView.attachToRecyclerView(subscriptionRecycler) + emptyView.attachToRecyclerView(recyclerView) } @OptIn(UnstableApi::class) private fun loadSubscriptions() { @@ -303,10 +310,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } withContext(Dispatchers.Main) { // We have fewer items. This can result in items being selected that are no longer visible. - if ( feedListFiltered.size > feedList.size) listAdapter.endSelectMode() + if ( feedListFiltered.size > feedList.size) adapter.endSelectMode() filterOnTag() binding.progressBar.visibility = View.GONE - listAdapter.setItems(feedListFiltered) + adapter.setItems(feedListFiltered) binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() emptyView.updateVisibility() } @@ -410,11 +417,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } override fun onContextItemSelected(item: MenuItem): Boolean { - val feed: Feed = listAdapter.selectedItem ?: return false + val feed: Feed = adapter.selectedItem ?: return false val itemId = item.itemId if (itemId == R.id.multi_select) { speedDialView.visibility = View.VISIBLE - return listAdapter.onContextItemSelected(item) + return adapter.onContextItemSelected(item) } // TODO: this appears not called return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() } @@ -423,14 +430,14 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec override fun onEndSelectMode() { speedDialView.close() speedDialView.visibility = View.GONE - listAdapter.setItems(feedListFiltered) + adapter.setItems(feedListFiltered) } override fun onStartSelectMode() { speedDialView.visibility = View.VISIBLE val feedsOnly: MutableList = ArrayList(feedListFiltered) // feedsOnly.addAll(feedListFiltered) - listAdapter.setItems(feedsOnly) + adapter.setItems(feedsOnly) } @UnstableApi @@ -856,6 +863,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec private const val KEY_UP_ARROW = "up_arrow" private const val ARGUMENT_FOLDER = "folder" + private var prevFeedUpdatingEvent: FlowEvent.FeedUpdatingEvent? = null + fun newInstance(folderTitle: String?): SubscriptionsFragment { val fragment = SubscriptionsFragment() val args = Bundle() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/DownloadStatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/DownloadStatisticsFragment.kt deleted file mode 100644 index ad8e201e..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/DownloadStatisticsFragment.kt +++ /dev/null @@ -1,127 +0,0 @@ -package ac.mdiq.podcini.ui.statistics - - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.StatisticsFragmentBinding -import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics -import ac.mdiq.podcini.storage.model.StatisticsItem -import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData -import android.annotation.SuppressLint -import android.content.Context -import android.os.Bundle -import android.text.format.Formatter -import android.util.Log -import android.view.LayoutInflater -import android.view.Menu -import android.view.View -import android.view.ViewGroup -import android.widget.ProgressBar -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.* - -/** - * Displays the 'download statistics' screen - */ -class DownloadStatisticsFragment : Fragment() { - - private var _binding: StatisticsFragmentBinding? = null - private val binding get() = _binding!! - - private lateinit var downloadStatisticsList: RecyclerView - private lateinit var progressBar: ProgressBar - private lateinit var listAdapter: DownloadStatisticsListAdapter - - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = StatisticsFragmentBinding.inflate(inflater) - downloadStatisticsList = binding.statisticsList - progressBar = binding.progressBar - listAdapter = DownloadStatisticsListAdapter(requireContext(), this) - downloadStatisticsList.layoutManager = LinearLayoutManager(context) - downloadStatisticsList.adapter = listAdapter - refreshDownloadStatistics() - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - @Deprecated("Deprecated in Java") - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.statistics_reset).setVisible(false) - menu.findItem(R.id.statistics_filter).setVisible(false) - } - - private fun refreshDownloadStatistics() { - progressBar.visibility = View.VISIBLE - downloadStatisticsList.visibility = View.GONE - loadStatistics() - } - - private fun loadStatistics() { - lifecycleScope.launch { - try { - val statisticsData = withContext(Dispatchers.IO) { - val data = getStatistics(false, 0, Long.MAX_VALUE) - data.feedTime.sortWith { item1: StatisticsItem, item2: StatisticsItem -> - item2.totalDownloadSize.compareTo(item1.totalDownloadSize) - } - data - } - listAdapter.update(statisticsData.feedTime) - progressBar.visibility = View.GONE - downloadStatisticsList.visibility = View.VISIBLE - } catch (error: Throwable) { - Log.e(TAG, Log.getStackTraceString(error)) - } - } - } - - /** - * Adapter for the download statistics list. - */ - class DownloadStatisticsListAdapter(context: Context, private val fragment: Fragment) : StatisticsListAdapter(context!!) { - override val headerCaption: String - get() = context.getString(R.string.total_size_downloaded_podcasts) - - override val headerValue: String - get() = Formatter.formatShortFileSize(context, pieChartData!!.sum.toLong()) - - override fun generateChartData(statisticsData: List?): PieChartData { - val dataValues = FloatArray(statisticsData!!.size) - for (i in statisticsData.indices) { - val item = statisticsData[i] - dataValues[i] = item.totalDownloadSize.toFloat() - } - return PieChartData(dataValues) - } - - @SuppressLint("SetTextI18n") - override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) { - holder!!.value.text = (Formatter.formatShortFileSize(context, item!!.totalDownloadSize) - + " • " - + String.format(Locale.getDefault(), "%d%s", - item.episodesDownloadCount, context.getString(R.string.episodes_suffix))) - - holder.itemView.setOnClickListener { v: View? -> - val yourDialogFragment = FeedStatisticsDialogFragment.newInstance( - item.feed.id, item.feed.title) - yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment") - } - } - } - - companion object { - private val TAG: String = DownloadStatisticsFragment::class.simpleName ?: "Anonymous" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsDialogFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsDialogFragment.kt deleted file mode 100644 index a98e76a6..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsDialogFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ac.mdiq.podcini.ui.statistics - - -import ac.mdiq.podcini.R -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter - -class FeedStatisticsDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = MaterialAlertDialogBuilder(requireContext()) - dialog.setPositiveButton(android.R.string.ok, null) - dialog.setNeutralButton(R.string.open_podcast) { dialogInterface: DialogInterface?, i: Int -> - val feedId = requireArguments().getLong(EXTRA_FEED_ID) - MainActivityStarter(requireContext()).withOpenFeed(feedId).withAddToBackStack().start() - } - dialog.setTitle(requireArguments().getString(EXTRA_FEED_TITLE)) - dialog.setView(R.layout.feed_statistics_dialog) - return dialog.create() - } - - override fun onStart() { - super.onStart() - val feedId = requireArguments().getLong(EXTRA_FEED_ID) - childFragmentManager.beginTransaction().replace(R.id.statisticsContainer, - FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment") - .commitAllowingStateLoss() - } - - companion object { - private const val EXTRA_FEED_ID = "ac.mdiq.podcini.extra.feedId" - private const val EXTRA_FEED_TITLE = "ac.mdiq.podcini.extra.feedTitle" - - fun newInstance(feedId: Long, feedTitle: String?): FeedStatisticsDialogFragment { - val fragment = FeedStatisticsDialogFragment() - val arguments = Bundle() - arguments.putLong(EXTRA_FEED_ID, feedId) - arguments.putString(EXTRA_FEED_TITLE, feedTitle) - fragment.arguments = arguments - return fragment - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt index ed8f5e0e..9eadd900 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt @@ -29,9 +29,7 @@ class FeedStatisticsFragment : Fragment() { if (!requireArguments().getBoolean(EXTRA_DETAILED)) { for (i in 0 until binding.root.childCount) { val child = binding.root.getChildAt(i) - if ("detailed" == child.tag) { - child.visibility = View.GONE - } + if ("detailed" == child.tag) child.visibility = View.GONE } } @@ -44,10 +42,10 @@ class FeedStatisticsFragment : Fragment() { try { val statisticsData = withContext(Dispatchers.IO) { val data = getStatistics(true, 0, Long.MAX_VALUE) - data.feedTime.sortWith { item1: StatisticsItem, item2: StatisticsItem -> + data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.timePlayed.compareTo(item1.timePlayed) } - for (statisticsItem in data.feedTime) { + for (statisticsItem in data.statsItems) { if (statisticsItem.feed.id == feedId) return@withContext statisticsItem } null @@ -60,7 +58,7 @@ class FeedStatisticsFragment : Fragment() { } private fun showStats(s: StatisticsItem?) { - binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s!!.episodesStarted, s.episodes) + binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s!!.episodesStarted, s.numEpisodes) binding.timePlayedLabel.text = shortLocalizedDuration(requireContext(), s.timePlayed) binding.totalDurationLabel.text = shortLocalizedDuration(requireContext(), s.time) binding.onDeviceLabel.text = String.format(Locale.getDefault(), "%d", s.episodesDownloadCount) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/PagedToolbarFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/PagedToolbarFragment.kt deleted file mode 100644 index 1c9bf066..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/PagedToolbarFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package ac.mdiq.podcini.ui.statistics - -import android.view.MenuItem -import androidx.fragment.app.Fragment -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback -import com.google.android.material.appbar.MaterialToolbar - -/** - * Fragment with a ViewPager where the displayed items influence the top toolbar's menu. - * All items share the same general menu items and are just allowed to show/hide them. - */ -abstract class PagedToolbarFragment : Fragment() { - private var toolbar: MaterialToolbar? = null - private var viewPager: ViewPager2? = null - - /** - * Invalidate the toolbar menu if the current child fragment is visible. - * @param child The fragment to invalidate - */ - fun invalidateOptionsMenuIfActive(child: Fragment) { - val visibleChild = childFragmentManager.findFragmentByTag("f" + viewPager!!.currentItem) - if (visibleChild === child) visibleChild.onPrepareOptionsMenu(toolbar!!.menu) - } - - protected fun setupPagedToolbar(toolbar: MaterialToolbar, viewPager: ViewPager2) { - this.toolbar = toolbar - this.viewPager = viewPager - - toolbar.setOnMenuItemClickListener { item: MenuItem? -> - if (this.onOptionsItemSelected(item!!)) return@setOnMenuItemClickListener true - val child = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem) - if (child != null) return@setOnMenuItemClickListener child.onOptionsItemSelected(item) - false - } - viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - val child = childFragmentManager.findFragmentByTag("f$position") - child?.onPrepareOptionsMenu(toolbar.menu) - } - }) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt index c54f9669..6ce85431 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt @@ -2,42 +2,60 @@ package ac.mdiq.podcini.ui.statistics import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.PagerFragmentBinding +import ac.mdiq.podcini.databinding.* +import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.update -import ac.mdiq.podcini.storage.model.EpisodeMedia +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.dialog.ConfirmationDialog +import ac.mdiq.podcini.ui.dialog.DatesFilterDialog +import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData +import ac.mdiq.podcini.util.Converter.shortLocalizedDuration +import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent +import android.annotation.SuppressLint +import android.app.Dialog import android.content.Context import android.content.DialogInterface import android.content.SharedPreferences import android.os.Bundle +import android.text.format.DateFormat +import android.text.format.Formatter import android.util.Log -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup +import android.view.* +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView import androidx.annotation.OptIn +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import coil.load import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.max +import kotlin.math.min -/** - * Displays the 'statistics' screen - */ -class StatisticsFragment : PagedToolbarFragment() { +class StatisticsFragment : Fragment() { private lateinit var tabLayout: TabLayout private lateinit var viewPager: ViewPager2 @@ -57,10 +75,10 @@ class StatisticsFragment : PagedToolbarFragment() { toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } (activity as MainActivity).setupToolbarToggle(toolbar, false) - viewPager.adapter = StatisticsPagerAdapter(this) + viewPager.adapter = PagerAdapter(this) // Give the TabLayout the ViewPager tabLayout = binding.slidingTabs - super.setupPagedToolbar(toolbar, viewPager) + setupPagedToolbar(toolbar, viewPager) TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int -> when (position) { @@ -87,11 +105,36 @@ class StatisticsFragment : PagedToolbarFragment() { return super.onOptionsItemSelected(item) } + /** + * Invalidate the toolbar menu if the current child fragment is visible. + * @param child The fragment to invalidate + */ + fun invalidateOptionsMenuIfActive(child: Fragment) { + val visibleChild = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem) + if (visibleChild === child) visibleChild.onPrepareOptionsMenu(toolbar.menu) + } + + private fun setupPagedToolbar(toolbar: MaterialToolbar, viewPager: ViewPager2) { + this.toolbar = toolbar + this.viewPager = viewPager + + toolbar.setOnMenuItemClickListener { item: MenuItem? -> + if (this.onOptionsItemSelected(item!!)) return@setOnMenuItemClickListener true + val child = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem) + if (child != null) return@setOnMenuItemClickListener child.onOptionsItemSelected(item) + false + } + viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + val child = childFragmentManager.findFragmentByTag("f$position") + child?.onPrepareOptionsMenu(toolbar.menu) + } + }) + } + @UnstableApi private fun confirmResetStatistics() { - val conDialog: ConfirmationDialog = object : ConfirmationDialog( - requireContext(), - R.string.statistics_reset_data, - R.string.statistics_reset_data_msg) { + val conDialog: ConfirmationDialog = object : ConfirmationDialog(requireContext(), + R.string.statistics_reset_data, R.string.statistics_reset_data_msg) { override fun onConfirmButtonPressed(dialog: DialogInterface) { dialog.dismiss() doResetStatistics() @@ -130,7 +173,7 @@ class StatisticsFragment : PagedToolbarFragment() { } } - class StatisticsPagerAdapter internal constructor(fragment: Fragment) : FragmentStateAdapter(fragment) { + private class PagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { override fun createFragment(position: Int): Fragment { return when (position) { POS_SUBSCRIPTIONS -> SubscriptionStatisticsFragment() @@ -139,12 +182,554 @@ class StatisticsFragment : PagedToolbarFragment() { else -> DownloadStatisticsFragment() } } - override fun getItemCount(): Int { return TOTAL_COUNT } } + /** + * Displays the 'playback statistics' screen + */ + class SubscriptionStatisticsFragment : Fragment() { + private var _binding: StatisticsFragmentBinding? = null + private val binding get() = _binding!! + private var statisticsResult: StatisticsResult? = null + private lateinit var feedStatisticsList: RecyclerView + private lateinit var progressBar: ProgressBar + private lateinit var listAdapter: ListAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = StatisticsFragmentBinding.inflate(inflater) + feedStatisticsList = binding.statisticsList + progressBar = binding.progressBar + listAdapter = ListAdapter(this) + feedStatisticsList.layoutManager = LinearLayoutManager(context) + feedStatisticsList.adapter = listAdapter + refreshStatistics() + return binding.root + } + override fun onStart() { + super.onStart() + procFlowEvents() + } + override fun onStop() { + super.onStop() + cancelFlowEvents() + } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + private var eventSink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + } + private fun procFlowEvents() { + if (eventSink != null) return + eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.StatisticsEvent -> refreshStatistics() + else -> {} + } + } + } + } + @Deprecated("Deprecated in Java") + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.statistics_reset).setVisible(true) + menu.findItem(R.id.statistics_filter).setVisible(true) + } + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.statistics_filter) { + if (statisticsResult != null) { + val dialog = object: DatesFilterDialog(requireContext(), statisticsResult!!.oldestDate) { + override fun initParams() { + prefs = StatisticsFragment.prefs + includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) + timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0) + timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE) + } + override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) { + prefs!!.edit() + .putBoolean(PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed) + .putLong(PREF_FILTER_FROM, timeFilterFrom) + .putLong(PREF_FILTER_TO, timeFilterTo) + .apply() + EventFlow.postEvent(FlowEvent.StatisticsEvent()) + } + } + dialog.show() + } + return true + } + return super.onOptionsItemSelected(item) + } + private fun refreshStatistics() { + progressBar.visibility = View.VISIBLE + feedStatisticsList.visibility = View.GONE + loadStatistics() + } + private fun loadStatistics() { + val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) + val timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0) + val timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE) + lifecycleScope.launch { + try { + val statisticsData = withContext(Dispatchers.IO) { + val data = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo) + data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> + item2.timePlayed.compareTo(item1.timePlayed) + } + data + } + statisticsResult = statisticsData + // When "from" is "today", set it to today + listAdapter.setTimeFilter(includeMarkedAsPlayed, + max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(), + min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong()) + listAdapter.update(statisticsData.statsItems) + progressBar.visibility = View.GONE + feedStatisticsList.visibility = View.VISIBLE + } catch (error: Throwable) { + Log.e(TAG, Log.getStackTraceString(error)) + } + } + } + + private class ListAdapter(private val fragment: Fragment) : StatisticsListAdapter(fragment.requireContext()) { + private var timeFilterFrom: Long = 0 + private var timeFilterTo = Long.MAX_VALUE + private var includeMarkedAsPlayed = false + + override val headerCaption: String + get() { + if (includeMarkedAsPlayed) return context.getString(R.string.statistics_counting_total) + val skeleton = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMM yyyy") + val dateFormat = SimpleDateFormat(skeleton, Locale.getDefault()) + val dateFrom = dateFormat.format(Date(timeFilterFrom)) + // FilterTo is first day of next month => Subtract one day + val dateTo = dateFormat.format(Date(timeFilterTo - 24L * 3600000L)) + return context.getString(R.string.statistics_counting_range, dateFrom, dateTo) + } + override val headerValue: String + get() = shortLocalizedDuration(context, pieChartData!!.sum.toLong()) + + fun setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) { + this.includeMarkedAsPlayed = includeMarkedAsPlayed + this.timeFilterFrom = timeFilterFrom + this.timeFilterTo = timeFilterTo + } + override fun generateChartData(statisticsData: List?): PieChartData { + val dataValues = FloatArray(statisticsData!!.size) + for (i in statisticsData.indices) { + val item = statisticsData[i] + dataValues[i] = item.timePlayed.toFloat() + } + return PieChartData(dataValues) + } + override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) { + val time = item!!.timePlayed + holder!!.value.text = shortLocalizedDuration(context, time) + holder.itemView.setOnClickListener { + val yourDialogFragment = StatisticsDialogFragment.newInstance(item.feed.id, item.feed.title) + yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment") + } + } + } + } + + class YearsStatisticsFragment : Fragment() { + private var _binding: StatisticsFragmentBinding? = null + private val binding get() = _binding!! + private lateinit var yearStatisticsList: RecyclerView + private lateinit var progressBar: ProgressBar + private lateinit var listAdapter: ListAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = StatisticsFragmentBinding.inflate(inflater) + yearStatisticsList = binding.statisticsList + progressBar = binding.progressBar + listAdapter = ListAdapter(requireContext()) + yearStatisticsList.layoutManager = LinearLayoutManager(context) + yearStatisticsList.adapter = listAdapter + refreshStatistics() + return binding.root + } + override fun onStart() { + super.onStart() + procFlowEvents() + } + override fun onStop() { + super.onStop() + cancelFlowEvents() + } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + private var eventSink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + } + private fun procFlowEvents() { + if (eventSink != null) return + eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.StatisticsEvent -> refreshStatistics() + else -> {} + } + } + } + } + @Deprecated("Deprecated in Java") + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.statistics_reset).setVisible(true) + menu.findItem(R.id.statistics_filter).setVisible(false) + } + private fun refreshStatistics() { + progressBar.visibility = View.VISIBLE + yearStatisticsList.visibility = View.GONE + loadStatistics() + } + private fun loadStatistics() { + lifecycleScope.launch { + try { + val result: List = withContext(Dispatchers.IO) { + getMonthlyTimeStatistics() + } + listAdapter.update(result) + progressBar.visibility = View.GONE + yearStatisticsList.visibility = View.VISIBLE + } catch (error: Throwable) { + // This also runs on the Main thread + Log.e(TAG, Log.getStackTraceString(error)) + } + } + } + private fun getMonthlyTimeStatistics(): List { + Logd(TAG, "getMonthlyTimeStatistics called") + val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) + + val months: MutableList = ArrayList() + val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0").find() + val groupdMedias = medias.groupBy { + val calendar = Calendar.getInstance() + calendar.timeInMillis = it.lastPlayedTime + "${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}" + } + val orderedGroupedItems = groupdMedias.toList().sortedBy { + val (key, _) = it + val year = key.substringBefore("-").toInt() + val month = key.substringAfter("-").toInt() + year * 12 + month + }.toMap() + for (key in orderedGroupedItems.keys) { + val medias_ = orderedGroupedItems[key] ?: continue + val mItem = MonthlyStatisticsItem() + mItem.year = key.substringBefore("-").toInt() + mItem.month = key.substringAfter("-").toInt() + var dur = 0L + for (m in medias_) { + if (m.playedDuration > 0) dur += m.playedDuration + else { +// progress import does not include playedDuration + if (includeMarkedAsPlayed) { + if (m.playbackCompletionTime > 0 || m.episode?.playState == Episode.PLAYED) + dur += m.duration + else if (m.position > 0) dur += m.position + } else dur += m.position + } + } + mItem.timePlayed = dur + months.add(mItem) + } + return months + } + + /** + * Adapter for the yearly playback statistics list. + */ + private class ListAdapter(val context: Context) : RecyclerView.Adapter() { + private val statisticsData: MutableList = ArrayList() + private val yearlyAggregate: MutableList = ArrayList() + + override fun getItemCount(): Int { + return yearlyAggregate.size + 1 + } + override fun getItemViewType(position: Int): Int { + return if (position == 0) TYPE_HEADER else TYPE_FEED + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(context) + if (viewType == TYPE_HEADER) return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_barchart, parent, false)) + return StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false)) + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) { + if (getItemViewType(position) == TYPE_HEADER) { + val holder = h as HeaderHolder + holder.barChart.setData(statisticsData) + } else { + val holder = h as StatisticsHolder + val statsItem = yearlyAggregate[position - 1] + holder.year.text = String.format(Locale.getDefault(), "%d ", statsItem!!.year) + holder.hours.text = String.format(Locale.getDefault(), + "%.1f ", + statsItem.timePlayed / 3600000.0f) + context.getString(R.string.time_hours) + } + } + @SuppressLint("NotifyDataSetChanged") + fun update(statistics: List) { + var lastYear = if (statistics.isNotEmpty()) statistics[0].year else 0 + var lastDataPoint = if (statistics.isNotEmpty()) (statistics[0].month - 1) + lastYear * 12 else 0 + var yearSum: Long = 0 + yearlyAggregate.clear() + statisticsData.clear() + for (statistic in statistics) { + if (statistic.year != lastYear) { + val yearAggregate = MonthlyStatisticsItem() + yearAggregate.year = lastYear + yearAggregate.timePlayed = yearSum + yearlyAggregate.add(yearAggregate) + yearSum = 0 + lastYear = statistic.year + } + yearSum += statistic.timePlayed + while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) { + lastDataPoint++ + val item = MonthlyStatisticsItem() + item.year = lastDataPoint / 12 + item.month = lastDataPoint % 12 + 1 + statisticsData.add(item) // Compensate for months without playback + } + statisticsData.add(statistic) + lastDataPoint = (statistic.month - 1) + statistic.year * 12 + } + val yearAggregate = MonthlyStatisticsItem() + yearAggregate.year = lastYear + yearAggregate.timePlayed = yearSum + yearlyAggregate.add(yearAggregate) + yearlyAggregate.reverse() + notifyDataSetChanged() + } + + private class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = StatisticsListitemBarchartBinding.bind(itemView) + var barChart: BarChartView = binding.barChart + } + + private class StatisticsHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = StatisticsYearListitemBinding.bind(itemView) + var year: TextView = binding.yearLabel + var hours: TextView = binding.hoursLabel + } + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_FEED = 1 + } + } + } + + class DownloadStatisticsFragment : Fragment() { + private var _binding: StatisticsFragmentBinding? = null + private val binding get() = _binding!! + private lateinit var downloadStatisticsList: RecyclerView + private lateinit var progressBar: ProgressBar + private lateinit var listAdapter: ListAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = StatisticsFragmentBinding.inflate(inflater) + downloadStatisticsList = binding.statisticsList + progressBar = binding.progressBar + listAdapter = ListAdapter(requireContext(), this) + downloadStatisticsList.layoutManager = LinearLayoutManager(context) + downloadStatisticsList.adapter = listAdapter + refreshDownloadStatistics() + + return binding.root + } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + @Deprecated("Deprecated in Java") + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.statistics_reset).setVisible(false) + menu.findItem(R.id.statistics_filter).setVisible(false) + } + private fun refreshDownloadStatistics() { + progressBar.visibility = View.VISIBLE + downloadStatisticsList.visibility = View.GONE + loadStatistics() + } + private fun loadStatistics() { + lifecycleScope.launch { + try { + val statisticsData = withContext(Dispatchers.IO) { + val data = getStatistics(false, 0, Long.MAX_VALUE) + data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> + item2.totalDownloadSize.compareTo(item1.totalDownloadSize) + } + data + } + listAdapter.update(statisticsData.statsItems) + progressBar.visibility = View.GONE + downloadStatisticsList.visibility = View.VISIBLE + } catch (error: Throwable) { + Log.e(TAG, Log.getStackTraceString(error)) + } + } + } + + private class ListAdapter(context: Context, private val fragment: Fragment) : StatisticsListAdapter(context) { + override val headerCaption: String + get() = context.getString(R.string.total_size_downloaded_podcasts) + override val headerValue: String + get() = Formatter.formatShortFileSize(context, pieChartData!!.sum.toLong()) + + override fun generateChartData(statisticsData: List?): PieChartData { + val dataValues = FloatArray(statisticsData!!.size) + for (i in statisticsData.indices) { + val item = statisticsData[i] + dataValues[i] = item.totalDownloadSize.toFloat() + } + return PieChartData(dataValues) + } + @SuppressLint("SetTextI18n") + override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) { + holder!!.value.text = ("${Formatter.formatShortFileSize(context, item!!.totalDownloadSize)} • " + + String.format(Locale.getDefault(), "%d%s", item.episodesDownloadCount, context.getString(R.string.episodes_suffix))) + holder.itemView.setOnClickListener { + val yourDialogFragment = StatisticsDialogFragment.newInstance(item.feed.id, item.feed.title) + yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment") + } + } + } + } + + class StatisticsDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = MaterialAlertDialogBuilder(requireContext()) + dialog.setPositiveButton(android.R.string.ok, null) + dialog.setNeutralButton(R.string.open_podcast) { _: DialogInterface?, _: Int -> + val feedId = requireArguments().getLong(EXTRA_FEED_ID) + MainActivityStarter(requireContext()).withOpenFeed(feedId).withAddToBackStack().start() + } + dialog.setTitle(requireArguments().getString(EXTRA_FEED_TITLE)) + dialog.setView(R.layout.feed_statistics_dialog) + return dialog.create() + } + override fun onStart() { + super.onStart() + val feedId = requireArguments().getLong(EXTRA_FEED_ID) + childFragmentManager.beginTransaction().replace(R.id.statisticsContainer, + FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment") + .commitAllowingStateLoss() + } + + companion object { + private const val EXTRA_FEED_ID = "ac.mdiq.podcini.extra.feedId" + private const val EXTRA_FEED_TITLE = "ac.mdiq.podcini.extra.feedTitle" + + fun newInstance(feedId: Long, feedTitle: String?): StatisticsDialogFragment { + val fragment = StatisticsDialogFragment() + val arguments = Bundle() + arguments.putLong(EXTRA_FEED_ID, feedId) + arguments.putString(EXTRA_FEED_TITLE, feedTitle) + fragment.arguments = arguments + return fragment + } + } + } + + /** + * Parent Adapter for the playback and download statistics list. + */ + private abstract class StatisticsListAdapter protected constructor(@JvmField protected val context: Context) : + RecyclerView.Adapter() { + private var statisticsData: List? = null + @JvmField + protected var pieChartData: PieChartData? = null + protected abstract val headerCaption: String? + protected abstract val headerValue: String? + + override fun getItemCount(): Int { + return statisticsData!!.size + 1 + } + override fun getItemViewType(position: Int): Int { + return if (position == 0) TYPE_HEADER else TYPE_FEED + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(context) + if (viewType == TYPE_HEADER) return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_total, parent, false)) + return StatisticsHolder(inflater.inflate(R.layout.statistics_listitem, parent, false)) + } + override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) { + if (getItemViewType(position) == TYPE_HEADER) { + val holder = h as HeaderHolder + holder.pieChart.setData(pieChartData) + holder.totalTime.text = headerValue + holder.totalText.text = headerCaption + } else { + val holder = h as StatisticsHolder + val statsItem = statisticsData!![position - 1] + holder.image.load(statsItem.feed.imageUrl) { + placeholder(R.color.light_gray) + error(R.mipmap.ic_launcher) + } + holder.title.text = statsItem.feed.title + holder.chip.setTextColor(pieChartData!!.getColorOfItem(position - 1)) + onBindFeedViewHolder(holder, statsItem) + } + } + @SuppressLint("NotifyDataSetChanged") + fun update(statistics: List?) { + statisticsData = statistics + pieChartData = generateChartData(statistics) + notifyDataSetChanged() + } + + class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = StatisticsListitemTotalBinding.bind(itemView) + var totalTime: TextView = binding.totalTime + var pieChart: PieChartView = binding.pieChart + var totalText: TextView = binding.totalDescription + } + + class StatisticsHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = StatisticsListitemBinding.bind(itemView) + var image: ImageView = binding.imgvCover + var title: TextView = binding.txtvTitle + @JvmField + var value: TextView = binding.txtvValue + var chip: TextView = binding.chip + } + + protected abstract fun generateChartData(statisticsData: List?): PieChartData? + protected abstract fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_FEED = 1 + } + } + companion object { val TAG = StatisticsFragment::class.simpleName ?: "Anonymous" @@ -163,6 +748,5 @@ class StatisticsFragment : PagedToolbarFragment() { fun getSharedPrefs(context: Context) { if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) } - } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsListAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsListAdapter.kt deleted file mode 100644 index 39a20814..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsListAdapter.kt +++ /dev/null @@ -1,98 +0,0 @@ -package ac.mdiq.podcini.ui.statistics - - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.StatisticsListitemBinding -import ac.mdiq.podcini.databinding.StatisticsListitemTotalBinding -import ac.mdiq.podcini.storage.model.StatisticsItem -import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData -import android.annotation.SuppressLint -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import coil.load - -/** - * Parent Adapter for the playback and download statistics list. - */ -abstract class StatisticsListAdapter protected constructor(@JvmField protected val context: Context) : - RecyclerView.Adapter() { - private var statisticsData: List? = null - @JvmField - protected var pieChartData: PieChartData? = null - - override fun getItemCount(): Int { - return statisticsData!!.size + 1 - } - - override fun getItemViewType(position: Int): Int { - return if (position == 0) TYPE_HEADER else TYPE_FEED - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(context) - if (viewType == TYPE_HEADER) { - return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_total, parent, false)) - } - return StatisticsHolder(inflater.inflate(R.layout.statistics_listitem, parent, false)) - } - - override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) { - if (getItemViewType(position) == TYPE_HEADER) { - val holder = h as HeaderHolder - holder.pieChart.setData(pieChartData) - holder.totalTime.text = headerValue - holder.totalText.text = headerCaption - } else { - val holder = h as StatisticsHolder - val statsItem = statisticsData!![position - 1] - holder.image.load(statsItem.feed.imageUrl) { - placeholder(R.color.light_gray) - error(R.mipmap.ic_launcher) - } - holder.title.text = statsItem.feed.title - holder.chip.setTextColor(pieChartData!!.getColorOfItem(position - 1)) - onBindFeedViewHolder(holder, statsItem) - } - } - - @SuppressLint("NotifyDataSetChanged") - fun update(statistics: List?) { - statisticsData = statistics - pieChartData = generateChartData(statistics) - notifyDataSetChanged() - } - - internal class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val binding = StatisticsListitemTotalBinding.bind(itemView) - var totalTime: TextView = binding.totalTime - var pieChart: PieChartView = binding.pieChart - var totalText: TextView = binding.totalDescription - } - - class StatisticsHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) { - val binding = StatisticsListitemBinding.bind(itemView) - var image: ImageView = binding.imgvCover - var title: TextView = binding.txtvTitle - @JvmField - var value: TextView = binding.txtvValue - var chip: TextView = binding.chip - } - - protected abstract val headerCaption: String? - - protected abstract val headerValue: String? - - protected abstract fun generateChartData(statisticsData: List?): PieChartData? - - protected abstract fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) - - companion object { - private const val TYPE_HEADER = 0 - private const val TYPE_FEED = 1 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/SubscriptionStatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/SubscriptionStatisticsFragment.kt deleted file mode 100644 index cbc92eea..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/SubscriptionStatisticsFragment.kt +++ /dev/null @@ -1,219 +0,0 @@ -package ac.mdiq.podcini.ui.statistics - - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.StatisticsFragmentBinding -import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics -import ac.mdiq.podcini.storage.model.StatisticsResult -import ac.mdiq.podcini.storage.model.StatisticsItem -import ac.mdiq.podcini.ui.dialog.DatesFilterDialog -import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData -import ac.mdiq.podcini.ui.statistics.StatisticsFragment.Companion.prefs -import ac.mdiq.podcini.ui.statistics.FeedStatisticsDialogFragment.Companion.newInstance -import ac.mdiq.podcini.util.Converter.shortLocalizedDuration -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.event.EventFlow -import ac.mdiq.podcini.util.event.FlowEvent -import android.os.Bundle -import android.text.format.DateFormat -import android.util.Log -import android.view.* -import android.widget.ProgressBar -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collectLatest -import java.text.SimpleDateFormat -import java.util.* -import kotlin.math.max -import kotlin.math.min - -/** - * Displays the 'playback statistics' screen - */ -class SubscriptionStatisticsFragment : Fragment() { - private var _binding: StatisticsFragmentBinding? = null - private val binding get() = _binding!! - private var statisticsResult: StatisticsResult? = null - - private lateinit var feedStatisticsList: RecyclerView - private lateinit var progressBar: ProgressBar - private lateinit var listAdapter: PlaybackStatisticsListAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = StatisticsFragmentBinding.inflate(inflater) - feedStatisticsList = binding.statisticsList - progressBar = binding.progressBar - listAdapter = PlaybackStatisticsListAdapter(this) - feedStatisticsList.setLayoutManager(LinearLayoutManager(context)) - feedStatisticsList.setAdapter(listAdapter) - refreshStatistics() - - return binding.root - } - - override fun onStart() { - super.onStart() - procFlowEvents() - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.StatisticsEvent -> refreshStatistics() - else -> {} - } - } - } - } - - @Deprecated("Deprecated in Java") - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.statistics_reset).setVisible(true) - menu.findItem(R.id.statistics_filter).setVisible(true) - } - - @Deprecated("Deprecated in Java") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.statistics_filter) { - if (statisticsResult != null) { - val dialog = object: DatesFilterDialog(requireContext(), statisticsResult!!.oldestDate) { - override fun initParams() { - prefs = StatisticsFragment.prefs - includeMarkedAsPlayed = prefs!!.getBoolean(StatisticsFragment.PREF_INCLUDE_MARKED_PLAYED, false) - timeFilterFrom = prefs!!.getLong(StatisticsFragment.PREF_FILTER_FROM, 0) - timeFilterTo = prefs!!.getLong(StatisticsFragment.PREF_FILTER_TO, Long.MAX_VALUE) - } - override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) { - prefs!!.edit() - .putBoolean(StatisticsFragment.PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed) - .putLong(StatisticsFragment.PREF_FILTER_FROM, timeFilterFrom) - .putLong(StatisticsFragment.PREF_FILTER_TO, timeFilterTo) - .apply() - EventFlow.postEvent(FlowEvent.StatisticsEvent()) - } - } - dialog.show() - } - return true - } - return super.onOptionsItemSelected(item) - } - - private fun refreshStatistics() { - progressBar.visibility = View.VISIBLE - feedStatisticsList.visibility = View.GONE - loadStatistics() - } - - private fun loadStatistics() { - val includeMarkedAsPlayed = prefs!!.getBoolean(StatisticsFragment.PREF_INCLUDE_MARKED_PLAYED, false) - val timeFilterFrom = prefs!!.getLong(StatisticsFragment.PREF_FILTER_FROM, 0) - val timeFilterTo = prefs!!.getLong(StatisticsFragment.PREF_FILTER_TO, Long.MAX_VALUE) - - lifecycleScope.launch { - try { - val statisticsData = withContext(Dispatchers.IO) { - val data = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo) - data.feedTime.sortWith { item1: StatisticsItem, item2: StatisticsItem -> - item2.timePlayed.compareTo(item1.timePlayed) - } - data - } - statisticsResult = statisticsData - // When "from" is "today", set it to today - listAdapter.setTimeFilter(includeMarkedAsPlayed, - max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(), - min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong()) - listAdapter.update(statisticsData.feedTime) - progressBar.visibility = View.GONE - feedStatisticsList.visibility = View.VISIBLE - } catch (error: Throwable) { - // This also runs on the Main thread - Log.e(TAG, Log.getStackTraceString(error)) - } - } - } - - /** - * Adapter for the playback statistics list. - */ - class PlaybackStatisticsListAdapter(private val fragment: Fragment) : StatisticsListAdapter( - fragment.requireContext()) { - private var timeFilterFrom: Long = 0 - private var timeFilterTo = Long.MAX_VALUE - private var includeMarkedAsPlayed = false - - fun setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) { - this.includeMarkedAsPlayed = includeMarkedAsPlayed - this.timeFilterFrom = timeFilterFrom - this.timeFilterTo = timeFilterTo - } - - override val headerCaption: String - get() { - if (includeMarkedAsPlayed) { - return context.getString(R.string.statistics_counting_total) - } - val skeleton = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMM yyyy") - val dateFormat = SimpleDateFormat(skeleton, Locale.getDefault()) - val dateFrom = dateFormat.format(Date(timeFilterFrom)) - // FilterTo is first day of next month => Subtract one day - val dateTo = dateFormat.format(Date(timeFilterTo - 24L * 3600000L)) - return context.getString(R.string.statistics_counting_range, dateFrom, dateTo) - } - - override val headerValue: String - get() = shortLocalizedDuration(context, pieChartData!!.sum.toLong()) - - override fun generateChartData(statisticsData: List?): PieChartData { - val dataValues = FloatArray(statisticsData!!.size) - for (i in statisticsData.indices) { - val item = statisticsData[i] - dataValues[i] = item.timePlayed.toFloat() - } - return PieChartData(dataValues) - } - - override fun onBindFeedViewHolder(holder: StatisticsHolder?, statsItem: StatisticsItem?) { - val time = statsItem!!.timePlayed - holder!!.value.text = shortLocalizedDuration(context, time) - - holder.itemView.setOnClickListener { v: View? -> - val yourDialogFragment = newInstance( - statsItem.feed.id, statsItem.feed.title) - yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment") - } - } - } - - companion object { - private val TAG: String = SubscriptionStatisticsFragment::class.simpleName ?: "Anonymous" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/YearsStatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/YearsStatisticsFragment.kt deleted file mode 100644 index 35699118..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/YearsStatisticsFragment.kt +++ /dev/null @@ -1,245 +0,0 @@ -package ac.mdiq.podcini.ui.statistics - - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.StatisticsFragmentBinding -import ac.mdiq.podcini.databinding.StatisticsListitemBarchartBinding -import ac.mdiq.podcini.databinding.StatisticsYearListitemBinding -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.MonthlyStatisticsItem -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.event.EventFlow -import ac.mdiq.podcini.util.event.FlowEvent -import android.annotation.SuppressLint -import android.content.Context -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.Menu -import android.view.View -import android.view.ViewGroup -import android.widget.ProgressBar -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.* - -/** - * Displays the yearly statistics screen - */ -class YearsStatisticsFragment : Fragment() { - private var _binding: StatisticsFragmentBinding? = null - private val binding get() = _binding!! - - private lateinit var yearStatisticsList: RecyclerView - private lateinit var progressBar: ProgressBar - private lateinit var listAdapter: YearStatisticsListAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = StatisticsFragmentBinding.inflate(inflater) - yearStatisticsList = binding.statisticsList - progressBar = binding.progressBar - listAdapter = YearStatisticsListAdapter(requireContext()) - yearStatisticsList.layoutManager = LinearLayoutManager(context) - yearStatisticsList.adapter = listAdapter - refreshStatistics() - - return binding.root - } - - override fun onStart() { - super.onStart() - procFlowEvents() - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.StatisticsEvent -> refreshStatistics() - else -> {} - } - } - } - } - - @Deprecated("Deprecated in Java") - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.statistics_reset).setVisible(true) - menu.findItem(R.id.statistics_filter).setVisible(false) - } - - private fun refreshStatistics() { - progressBar.visibility = View.VISIBLE - yearStatisticsList.visibility = View.GONE - loadStatistics() - } - - private fun loadStatistics() { - lifecycleScope.launch { - try { - val result: List = withContext(Dispatchers.IO) { - getMonthlyTimeStatistics() - } - listAdapter.update(result) - progressBar.visibility = View.GONE - yearStatisticsList.visibility = View.VISIBLE - } catch (error: Throwable) { - // This also runs on the Main thread - Log.e(TAG, Log.getStackTraceString(error)) - } - } - } - - private fun getMonthlyTimeStatistics(): List { - Logd(TAG, "getMonthlyTimeStatistics called") - val months: MutableList = ArrayList() - val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0 AND playedDuration > 0").find() - val groupdMedias = medias.groupBy { - val calendar = Calendar.getInstance() - calendar.timeInMillis = it.lastPlayedTime - "${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}" - } - val orderedGroupedItems = groupdMedias.toList().sortedBy { - val (key, _) = it - val year = key.substringBefore("-").toInt() - val month = key.substringAfter("-").toInt() - year * 12 + month - }.toMap() - for (key in orderedGroupedItems.keys) { - val v = orderedGroupedItems[key] ?: continue - val episode = MonthlyStatisticsItem() - episode.year = key.substringBefore("-").toInt() - episode.month = key.substringAfter("-").toInt() - var dur = 0L - for (m in v) { - dur += m.playedDuration - } - episode.timePlayed = dur - months.add(episode) - } - return months - } - - /** - * Adapter for the yearly playback statistics list. - */ - class YearStatisticsListAdapter(val context: Context) : RecyclerView.Adapter() { - private val statisticsData: MutableList = ArrayList() - private val yearlyAggregate: MutableList = ArrayList() - - override fun getItemCount(): Int { - return yearlyAggregate.size + 1 - } - - override fun getItemViewType(position: Int): Int { - return if (position == 0) TYPE_HEADER else TYPE_FEED - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(context) - if (viewType == TYPE_HEADER) { - return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_barchart, parent, false)) - } - return StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false)) - } - - @SuppressLint("SetTextI18n") - override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) { - if (getItemViewType(position) == TYPE_HEADER) { - val holder = h as HeaderHolder - holder.barChart.setData(statisticsData) - } else { - val holder = h as StatisticsHolder - val statsItem = yearlyAggregate[position - 1] - holder.year.text = String.format(Locale.getDefault(), "%d ", statsItem!!.year) - holder.hours.text = String.format(Locale.getDefault(), - "%.1f ", - statsItem.timePlayed / 3600000.0f) + context.getString(R.string.time_hours) - } - } - - @SuppressLint("NotifyDataSetChanged") - fun update(statistics: List) { - var lastYear = if (statistics.isNotEmpty()) statistics[0].year else 0 - var lastDataPoint = if (statistics.isNotEmpty()) (statistics[0].month - 1) + lastYear * 12 else 0 - var yearSum: Long = 0 - yearlyAggregate.clear() - statisticsData.clear() - for (statistic in statistics) { - if (statistic.year != lastYear) { - val yearAggregate = MonthlyStatisticsItem() - yearAggregate.year = lastYear - yearAggregate.timePlayed = yearSum - yearlyAggregate.add(yearAggregate) - yearSum = 0 - lastYear = statistic.year - } - yearSum += statistic.timePlayed - while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) { - lastDataPoint++ - val item = MonthlyStatisticsItem() - item.year = lastDataPoint / 12 - item.month = lastDataPoint % 12 + 1 - statisticsData.add(item) // Compensate for months without playback - } - statisticsData.add(statistic) - lastDataPoint = (statistic.month - 1) + statistic.year * 12 - } - val yearAggregate = MonthlyStatisticsItem() - yearAggregate.year = lastYear - yearAggregate.timePlayed = yearSum - yearlyAggregate.add(yearAggregate) - yearlyAggregate.reverse() - notifyDataSetChanged() - } - - internal class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val binding = StatisticsListitemBarchartBinding.bind(itemView) - var barChart: BarChartView = binding.barChart - } - - internal class StatisticsHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val binding = StatisticsYearListitemBinding.bind(itemView) - var year: TextView = binding.yearLabel - var hours: TextView = binding.hoursLabel - } - - companion object { - private const val TYPE_HEADER = 0 - private const val TYPE_FEED = 1 - } - } - - companion object { - private val TAG: String = YearsStatisticsFragment::class.simpleName ?: "Anonymous" - } -} 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 05f4f68b..36f403ad 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 @@ -255,28 +255,21 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro } } - private fun updateDuration(event: FlowEvent.PlaybackPositionEvent) { - val media = this.episode?.media - if (media != null) { - media.setPosition(event.position) - media.setDuration(event.duration) - } - val currentPosition: Int = event.position - val timeDuration: Int = event.duration + fun updatePlaybackPositionNew(item: Episode) { + Logd(TAG, "updatePlaybackPositionNew called") + this.episode = item + val currentPosition = item.media?.position ?: 0 + val timeDuration = item.media?.duration ?: 0 + progressBar.progress = (100.0 * currentPosition / timeDuration).toInt() + position.text = Converter.getDurationStringLong(currentPosition) + val remainingTime = max((timeDuration - currentPosition).toDouble(), 0.0).toInt() - // Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition)); if (currentPosition == Playable.INVALID_TIME || timeDuration == Playable.INVALID_TIME) { Log.w(TAG, "Could not react to position observer update because of invalid time") return } if (UserPreferences.shouldShowRemainingTime()) duration.text = (if (remainingTime > 0) "-" else "") + Converter.getDurationStringLong(remainingTime) else duration.text = Converter.getDurationStringLong(timeDuration) - } - - fun notifyPlaybackPositionUpdated(event: FlowEvent.PlaybackPositionEvent) { - progressBar.progress = (100.0 * event.position / event.duration).toInt() - position.text = Converter.getDurationStringLong(event.position) - updateDuration(event) duration.visibility = View.VISIBLE // Even if the duration was previously unknown, it is now known } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/Converter.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/Converter.kt index 1dc6df53..83a7922f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/Converter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/Converter.kt @@ -75,11 +75,7 @@ object Converter { */ @JvmStatic fun getDurationStringLocalized(context: Context, duration: Long): String { - return getDurationStringLocalized(context.resources, duration) - } - - @JvmStatic - fun getDurationStringLocalized(resources: Resources, duration: Long): String { + val resources = context.resources var result = "" var h = (duration / HOURS_MIL).toInt() val d = h / 24 @@ -110,6 +106,6 @@ object Converter { @JvmStatic fun shortLocalizedDuration(context: Context, time: Long): String { val hours = time.toFloat() / 3600f - return String.format(Locale.getDefault(), "%.1f ", hours) + context.getString(R.string.time_hours) + return String.format(Locale.getDefault(), "%.2f ", hours) + context.getString(R.string.time_hours) } } 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 a8934ed4..3d9697cd 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 @@ -4,22 +4,16 @@ import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.net.download.DownloadStatus import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.SortOrder -import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting import ac.mdiq.podcini.util.Logd import android.content.Context import android.view.KeyEvent import androidx.core.util.Consumer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import org.apache.commons.lang3.builder.ToStringBuilder -import org.apache.commons.lang3.builder.ToStringStyle import java.util.* import kotlin.math.abs @@ -27,6 +21,7 @@ import kotlin.math.max sealed class FlowEvent { val TAG = this::class.simpleName ?: "FlowEvent" + val id: Long = Date().time data class PlaybackPositionEvent(val media: Playable?, val position: Int, val duration: Int) : FlowEvent() @@ -173,7 +168,7 @@ sealed class FlowEvent { data class FeedTagsChangedEvent(val dummy: Unit = Unit) : FlowEvent() - data class FeedUpdateRunningEvent(val isFeedUpdateRunning: Boolean) : FlowEvent() + data class FeedUpdatingEvent(val isRunning: Boolean) : FlowEvent() data class MessageEvent(val message: String, val action: Consumer? = null, val actionText: String? = null) : FlowEvent() diff --git a/app/src/main/kotlin/ac/test/podcini/service/download/FeedComponent.kt b/app/src/main/kotlin/ac/test/podcini/service/download/FeedComponent.kt deleted file mode 100644 index b01ab65d..00000000 --- a/app/src/main/kotlin/ac/test/podcini/service/download/FeedComponent.kt +++ /dev/null @@ -1,46 +0,0 @@ -package ac.test.podcini.service.download - -/** - * Represents every possible component of a feed - * - * @author daniel - */ -// only used in test -abstract class FeedComponent internal constructor() { - open var id: Long = 0 - - /** - * Update this FeedComponent's attributes with the attributes from another - * FeedComponent. This method should only update attributes which where read from - * the feed. - */ - fun updateFromOther(other: FeedComponent?) {} - - /** - * Compare's this FeedComponent's attribute values with another FeedComponent's - * attribute values. This method will only compare attributes which were - * read from the feed. - * - * @return true if attribute values are different, false otherwise - */ - fun compareWithOther(other: FeedComponent?): Boolean { - return false - } - - /** - * Should return a non-null, human-readable String so that the item can be - * identified by the user. Can be title, download-url, etc. - */ - abstract fun getHumanReadableIdentifier(): String? - - override fun equals(o: Any?): Boolean { - if (this === o) return true - if (o !is FeedComponent) return false - - return id == o.id - } - - override fun hashCode(): Int { - return (id xor (id ushr 32)).toInt() - } -} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2e2669f..6b81c760 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,7 +225,7 @@ Delete Unable to delete file. Rebooting the device could help. Unable to delete file. Try re-connecting the local folder from the podcast info screen. - Delete episode + Delete episode media 1 downloaded episode deleted. %d downloaded episodes deleted. @@ -355,6 +355,7 @@ Move to bottom Sort Keep sorted + Publish date Date Played date Completed date @@ -425,6 +426,8 @@ Auto delete from local folders Include local folders in Auto delete functionality Note that for local folders this will remove episodes from Podcini and delete their media files from your device storage. They cannot be downloaded again through Podcini. Enable auto delete\? + Mark played removes from queue + Removes the episodes from all queues when they are mark episodes as played Mark episodes as played even if less than a certain amount of seconds of playing time is still left Smart mark as played Keep episodes when they are skipped @@ -619,7 +622,7 @@ Episodes progress import Transfer Podcini episodes history to Podcini on another device Import Podcini episodes history from another device - Importing episodes progress will replace all of your current playing history. You should export your current progress as a backup. Do you want to replace\? + Importing episodes progress will replace all of your current playing history. You should export your current progress as a backup. Do you want to replace\? If yes, in the next screen, choose the desired .json file. The process can take a couple minutes depending on size. Once completed, a popup of either success or failure will be shown. OPML export HTML export Preferences export @@ -627,7 +630,9 @@ Importing preferences will replace all of your current preferences. If confirmed, choose a previously exported directory with name containing \"Podcini-Prefs\" Database export Database import - Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace\? + Realm database import + Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace\? If yes, in the next screen, choose a file with extension .realm + Only accepting file extension: Please wait… Export error Export successful diff --git a/app/src/main/res/xml/preferences_downloads.xml b/app/src/main/res/xml/preferences_downloads.xml index cc0660dc..da36976a 100644 --- a/app/src/main/res/xml/preferences_downloads.xml +++ b/app/src/main/res/xml/preferences_downloads.xml @@ -39,7 +39,7 @@ android:summary="@string/pref_favorite_keeps_episodes_sum" android:title="@string/pref_favorite_keeps_episodes_title"/> + diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbWriterTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbWriterTest.kt index a0a3d4a8..1c6176bf 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbWriterTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbWriterTest.kt @@ -2,13 +2,10 @@ package ac.mdiq.podcini.storage import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterfaceTestStub +import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.storage.database.Episodes.addToHistory import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode @@ -16,11 +13,14 @@ import ac.mdiq.podcini.storage.database.Episodes.getEpisode import ac.mdiq.podcini.storage.database.Episodes.getEpisodeMedia import ac.mdiq.podcini.storage.database.Episodes.persistEpisode import ac.mdiq.podcini.storage.database.Episodes.persistEpisodeMedia -import ac.mdiq.podcini.storage.database.Feeds.deleteFeed +import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync import ac.mdiq.podcini.storage.database.Queues.addToQueue import ac.mdiq.podcini.storage.database.Queues.clearQueue import ac.mdiq.podcini.storage.database.Queues.moveInQueue import ac.mdiq.podcini.storage.database.Queues.removeFromQueue +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia +import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.config.ApplicationCallbacks import ac.mdiq.podcini.util.config.ClientConfig @@ -78,9 +78,9 @@ class DbWriterTest { // PodDBAdapter.tearDownTests() // DBWriter.tearDownTests() - val testDir = context.getExternalFilesDir(TEST_FOLDER) - Assert.assertNotNull(testDir) - for (f in testDir!!.listFiles()) { + val testDir = context.getExternalFilesDir(TEST_FOLDER) ?: return + val files = testDir.listFiles() ?: return + for (f in files) { f.delete() } } @@ -241,8 +241,7 @@ class DbWriterTest { } runBlocking { - val job = deleteFeed(context, feed.id) - withTimeout(TIMEOUT*1000) { job.join() } + deleteFeedSync(context, feed.id) } // check if files still exist @@ -284,8 +283,7 @@ class DbWriterTest { Assert.assertTrue(feed.id != 0L) runBlocking { - val job = deleteFeed(context, feed.id) - withTimeout(TIMEOUT*1000) { job.join() } + deleteFeedSync(context, feed.id) } // adapter = getInstance() @@ -324,8 +322,7 @@ class DbWriterTest { } runBlocking { - val job = deleteFeed(context, feed.id) - withTimeout(TIMEOUT*1000) { job.join() } + deleteFeedSync(context, feed.id) } // adapter = getInstance() @@ -383,8 +380,7 @@ class DbWriterTest { // // adapter.close() runBlocking { - val job = deleteFeed(context, feed.id) - withTimeout(TIMEOUT*1000) { job.join() } + deleteFeedSync(context, feed.id) } // adapter.open() // @@ -438,8 +434,7 @@ class DbWriterTest { } runBlocking { - val job = deleteFeed(context, feed.id) - withTimeout(TIMEOUT*1000) { job.join() } + deleteFeedSync(context, feed.id) } // adapter = getInstance() @@ -522,7 +517,7 @@ class DbWriterTest { } } Assert.assertNotNull(media) - Assert.assertNotNull(media!!.playbackCompletionDate) + Assert.assertNotNull(media.playbackCompletionDate) } @Test @@ -539,7 +534,7 @@ class DbWriterTest { } Assert.assertNotNull(media) - Assert.assertNotNull(media!!.playbackCompletionDate) + Assert.assertNotNull(media.playbackCompletionDate) Assert.assertNotEquals(media.playbackCompletionDate!!.time, oldDate) } @@ -742,7 +737,7 @@ class DbWriterTest { assertQueueByItemIds("Average case - 2 items removed successfully", itemIds[0], itemIds[2]) runBlocking { - val job = removeFromQueue(null,) + val job = removeFromQueue(null) withTimeout(TIMEOUT*1000) { job.join() } } assertQueueByItemIds("Boundary case - no items supplied. queue should see no change", itemIds[0], itemIds[2]) diff --git a/changelog.md b/changelog.md index 0d69c693..1fe73bf0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,19 @@ +## 6.0.5 + +* fixed threading issue of downloading multiple episodes +* tidied up and fixed the mal-functioning statistics view +* tidied up routine of delete media +* fixed issue of episode not properly marked after complete listening +* fixed redundant double-pass processing in episodes filter +* in episodes sort dialog, "Date" is changed to "Publish date" +* in preference "Delete Removes From Queue" is set to true by default +* added in preference "Remove from queue when marked as played" and set it to true by default +* added episode counts in Episodes and History views +* enhanced a bit on progress import +* restricted file types for DB import to only a .realm file and Progress import to a .json file +* enhanced play position updates in all episodes list views +* remove feeds is performed in blocking way + ## 6.0.4 * bug fix on ShareDialog having no argument diff --git a/fastlane/metadata/android/en-US/changelogs/3020205.txt b/fastlane/metadata/android/en-US/changelogs/3020205.txt new file mode 100644 index 00000000..14ee2e61 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020205.txt @@ -0,0 +1,16 @@ + +Version 6.0.5 brings several changes: + +* fixed threading issue of downloading multiple episodes +* tidied up and fixed the mal-functioning statistics view +* tidied up routine of delete media +* fixed issue of episode not properly marked after complete listening +* fixed redundant double-pass processing in episodes filter +* in episodes sort dialog, "Date" is changed to "Publish date" +* in preference "Delete Removes From Queue" is set to true by default +* added in preference "Remove from queue when marked as played" and set it to true by default +* added episode counts in Episodes and History views +* enhanced a bit on progress import +* restricted file types for DB import to only a .realm file and Progress import to a .json file +* enhanced play position updates in all episodes list views +* remove feeds is performed in blocking way