From 1afc48290d3ea5bc8e791730e800400c86adb0da Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Fri, 28 Jun 2024 23:08:14 +0100 Subject: [PATCH] 6.0.3 commit --- .tx/config | 78 +- CONTRIBUTING.md | 2 +- app/build.gradle | 4 +- .../playback/CancelableMediaPlayerCallback.kt | 2 +- .../playback/DefaultMediaPlayerCallback.kt | 2 +- .../service/playback/MediaPlayerBaseTest.kt | 2 +- .../service/playback/TaskManagerTest.kt | 2 +- .../test/podcini/storage/AutoDownloadTest.kt | 10 +- .../ac/test/podcini/ui/PreferencesTest.kt | 10 +- .../kotlin/ac/test/podcini/ui/UITestUtils.kt | 12 +- .../ac/mdiq/podcini/playback/cast/CastPsmp.kt | 2 +- app/src/main/AndroidManifest.xml | 2 +- .../main/kotlin/ac/mdiq/podcini/PodciniApp.kt | 8 +- .../NetworkConnectionChangeHandler.kt | 2 +- .../serviceinterface/DownloadRequest.kt | 2 +- .../podcini/net/feed/FeedUpdateManager.kt | 59 +- .../ac/mdiq/podcini/net/sync/SyncService.kt | 4 +- .../podcini/net/sync/wifi/WifiSyncService.kt | 6 +- .../podcini/playback/PlaybackController.kt | 205 +++-- .../mdiq/podcini/playback/base/InTheatre.kt | 4 - .../podcini/playback/base/MediaPlayerBase.kt | 64 +- .../playback/base/MediaPlayerCallback.kt | 28 + .../podcini/playback/base/PlayerStatus.kt | 14 +- .../playback/service/LocalMediaPlayer.kt | 109 +-- .../playback/service/PlaybackService.kt | 862 ++++++++---------- .../service/QuickSettingsTileService.kt | 3 +- .../podcini/playback/service/TaskManager.kt | 2 +- .../mdiq/podcini/preferences/ExportWriter.kt | 13 + .../backup => preferences}/OpmlBackupAgent.kt | 16 +- .../podcini/preferences/OpmlTransporter.kt | 153 ++++ .../podcini/preferences/UserPreferences.kt | 4 + .../ImportExportPreferencesFragment.kt | 453 ++++++++- .../ac/mdiq/podcini/receiver/PlayerWidget.kt | 19 +- .../receiver/PowerConnectionReceiver.kt | 2 +- ...nupAlgorithmFactory.kt => AutoCleanups.kt} | 222 ++--- .../storage/algorithms/AutoDownloads.kt | 130 +++ .../mdiq/podcini/storage/database/Episodes.kt | 197 +--- .../ac/mdiq/podcini/storage/database/Feeds.kt | 232 ++++- .../podcini/storage/database/LogsAndStats.kt | 4 +- .../mdiq/podcini/storage/database/Queues.kt | 4 +- .../mdiq/podcini/storage/database/RealmDB.kt | 9 +- .../ac/mdiq/podcini/storage/model/Episode.kt | 23 - .../podcini/storage/model/EpisodeMedia.kt | 40 +- .../ac/mdiq/podcini/storage/model/Feed.kt | 51 +- .../podcini/storage/model/FeedPreferences.kt | 3 - .../storage/transport/CommonSymbols.kt | 24 - .../storage/transport/DatabaseTransporter.kt | 93 -- .../transport/EpisodeProgressReader.kt | 77 -- .../transport/EpisodesProgressWriter.kt | 73 -- .../podcini/storage/transport/ExportWriter.kt | 26 - .../storage/transport/FavoritesWriter.kt | 110 --- .../podcini/storage/transport/HtmlWriter.kt | 48 - .../podcini/storage/transport/OpmlElement.kt | 10 - .../podcini/storage/transport/OpmlReader.kt | 85 -- .../podcini/storage/transport/OpmlSymbols.kt | 13 - .../podcini/storage/transport/OpmlWriter.kt | 64 -- .../transport/PreferencesTransporter.kt | 118 --- .../podcini/storage/utils/ChapterUtils.kt | 12 +- .../actions/actionbutton/PlayActionButton.kt | 4 +- .../actionbutton/PlayLocalActionButton.kt | 4 +- .../actionbutton/StreamActionButton.kt | 2 +- .../mdiq/podcini/ui/activity/MainActivity.kt | 18 +- .../podcini/ui/activity/OpmlImportActivity.kt | 4 +- .../ui/activity/VideoplayerActivity.kt | 10 +- .../ui/activity/WidgetConfigActivity.kt | 6 +- .../podcini/ui/adapter/EpisodesAdapter.kt | 35 +- .../podcini/ui/adapter/OnlineFeedsAdapter.kt | 11 - .../ui/adapter/SimpleIconListAdapter.kt | 7 - .../podcini/ui/dialog/RemoveFeedDialog.kt | 2 +- .../podcini/ui/dialog/VariableSpeedDialog.kt | 12 +- .../ui/fragment/AllEpisodesFragment.kt | 14 +- .../ui/fragment/AudioPlayerFragment.kt | 280 +++--- .../ui/fragment/BaseEpisodesFragment.kt | 49 +- .../podcini/ui/fragment/ChaptersFragment.kt | 11 +- .../podcini/ui/fragment/DownloadsFragment.kt | 23 +- .../ui/fragment/EpisodeHomeFragment.kt | 13 +- .../ui/fragment/EpisodeInfoFragment.kt | 67 +- .../ui/fragment/FeedEpisodesFragment.kt | 165 ++-- .../podcini/ui/fragment/FeedInfoFragment.kt | 52 +- .../ui/fragment/FeedSettingsFragment.kt | 30 +- .../podcini/ui/fragment/HistoryFragment.kt | 22 +- .../podcini/ui/fragment/NavDrawerFragment.kt | 4 +- .../ui/fragment/OnlineFeedViewFragment.kt | 11 +- .../ui/fragment/OnlineSearchFragment.kt | 2 +- .../ui/fragment/PlayerDetailsFragment.kt | 113 +-- .../mdiq/podcini/ui/fragment/QueueFragment.kt | 32 +- .../ui/fragment/QuickDiscoveryFragment.kt | 8 - .../ui/fragment/RemoteEpisodesFragment.kt | 65 +- .../podcini/ui/fragment/SearchFragment.kt | 13 +- .../ui/fragment/SubscriptionsFragment.kt | 346 ++++--- .../ui/fragment/VideoEpisodeFragment.kt | 116 +-- .../ui/statistics/StatisticsListAdapter.kt | 8 - .../ac/mdiq/podcini/ui/utils/CoverLoader.kt | 8 +- .../mdiq/podcini/ui/widget/WidgetUpdater.kt | 28 +- .../kotlin/ac/mdiq/podcini/util/PowerUtils.kt | 24 - .../util/config/ApplicationCallbacksImpl.kt | 11 - .../util/error/InvalidFeedException.kt | 10 - .../ac/mdiq/podcini/util/event/FlowEvent.kt | 44 +- .../util/sorting/EpisodePubdateComparator.kt | 20 - .../podcini/util/sorting/EpisodesPermutors.kt | 97 +- .../ac/mdiq/podcini/util/sorting/Permutor.kt | 15 - .../PlaybackCompletionDateComparator.kt | 12 - .../PlaybackLastPlayedDateComparator.kt | 12 - .../play/listings/en-US/full-description.txt | 41 +- .../main/play/release-notes/en-US/default.txt | 8 - app/src/main/res/drawable/outline_home_24.xml | 5 + .../res/layout/fragment_subscriptions.xml | 9 +- ...er_fragment.xml => player_ui_fragment.xml} | 2 +- .../res/layout/subscription_item_brief.xml | 125 +++ app/src/main/res/menu/mediaplayer.xml | 1 + app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 + .../res/xml/preferences_user_interface.xml | 18 +- .../ac/mdiq/podcini/dialog/RatingDialog.kt | 7 +- .../ac/mdiq/podcini/playback/cast/CastPsmp.kt | 1 + .../kotlin/ac/mdiq/podcini/feed/FeedMother.kt | 2 +- .../element/namespace/FeedParserTestHelper.kt | 3 +- .../service/playback/VolumeUpdaterTest.kt | 44 +- .../podcini/storage/APCleanupAlgorithmTest.kt | 2 +- .../ac/mdiq/podcini/storage/DbCleanupTests.kt | 2 +- .../storage/DbNullCleanupAlgorithmTest.kt | 2 +- .../DbPlayQueueCleanupAlgorithmTest.kt | 2 +- .../ac/mdiq/podcini/storage/DbReaderTest.kt | 8 +- .../ac/mdiq/podcini/storage/DbTestUtils.kt | 2 +- .../storage/EpisodeDuplicateGuesserTest.kt | 2 +- .../ExceptFavoriteCleanupAlgorithmTest.kt | 2 +- changelog.md | 15 + .../android/en-US/changelogs/3020203.txt | 15 + .../android/en-US/full_description.txt | 2 + 129 files changed, 2719 insertions(+), 3273 deletions(-) create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerCallback.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWriter.kt rename app/src/main/kotlin/ac/mdiq/podcini/{storage/backup => preferences}/OpmlBackupAgent.kt (96%) create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlTransporter.kt rename app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/{EpisodeCleanupAlgorithmFactory.kt => AutoCleanups.kt} (90%) create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/CommonSymbols.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/DatabaseTransporter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodeProgressReader.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodesProgressWriter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/ExportWriter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/FavoritesWriter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/HtmlWriter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlElement.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlReader.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlSymbols.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlWriter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/transport/PreferencesTransporter.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/PowerUtils.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/config/ApplicationCallbacksImpl.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/error/InvalidFeedException.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodePubdateComparator.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/sorting/Permutor.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackCompletionDateComparator.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackLastPlayedDateComparator.kt create mode 100644 app/src/main/res/drawable/outline_home_24.xml rename app/src/main/res/layout/{internal_player_fragment.xml => player_ui_fragment.xml} (99%) create mode 100644 app/src/main/res/layout/subscription_item_brief.xml create mode 100644 fastlane/metadata/android/en-US/changelogs/3020203.txt diff --git a/.tx/config b/.tx/config index 6fe4a78f..7ee2a44e 100644 --- a/.tx/config +++ b/.tx/config @@ -2,46 +2,46 @@ host = https://www.transifex.com [o:podcini:p:podcini:r:core-values] -file_filter = ui/i18n/src/main/res/values-/strings.xml -source_file = ui/i18n/src/main/res/values/strings.xml +file_filter = app/src/main/res/values-/strings.xml +source_file = app/src/main/res/values/strings.xml source_lang = en -trans.ar = ui/i18n/src/main/res/values-ar/strings.xml -trans.ast_ES = ui/i18n/src/main/res/values-ast/strings.xml -trans.br = ui/i18n/src/main/res/values-br/strings.xml -trans.ca = ui/i18n/src/main/res/values-ca/strings.xml -trans.cs_CZ = ui/i18n/src/main/res/values-cs/strings.xml -trans.da = ui/i18n/src/main/res/values-da/strings.xml -trans.de = ui/i18n/src/main/res/values-de/strings.xml -trans.es = ui/i18n/src/main/res/values-es/strings.xml -trans.et = ui/i18n/src/main/res/values-et/strings.xml -trans.eu = ui/i18n/src/main/res/values-eu/strings.xml -trans.fa = ui/i18n/src/main/res/values-fa/strings.xml -trans.fi = ui/i18n/src/main/res/values-fi/strings.xml -trans.fr = ui/i18n/src/main/res/values-fr/strings.xml -trans.gl = ui/i18n/src/main/res/values-gl/strings.xml -trans.he_IL = ui/i18n/src/main/res/values-iw/strings.xml -trans.hi_IN = ui/i18n/src/main/res/values-hi/strings.xml -trans.hu = ui/i18n/src/main/res/values-hu/strings.xml -trans.id = ui/i18n/src/main/res/values-in/strings.xml -trans.it_IT = ui/i18n/src/main/res/values-it/strings.xml -trans.ja = ui/i18n/src/main/res/values-ja/strings.xml -trans.ko = ui/i18n/src/main/res/values-ko/strings.xml -trans.lt = ui/i18n/src/main/res/values-lt/strings.xml -trans.nb_NO = ui/i18n/src/main/res/values-nb/strings.xml -trans.nl = ui/i18n/src/main/res/values-nl/strings.xml -trans.pl_PL = ui/i18n/src/main/res/values-pl/strings.xml -trans.pt = ui/i18n/src/main/res/values-pt/strings.xml -trans.pt_BR = ui/i18n/src/main/res/values-pt-rBR/strings.xml -trans.ro_RO = ui/i18n/src/main/res/values-ro/strings.xml -trans.ru_RU = ui/i18n/src/main/res/values-ru/strings.xml -trans.sk = ui/i18n/src/main/res/values-sk/strings.xml -trans.sl_SI = ui/i18n/src/main/res/values-sl/strings.xml -trans.sv_SE = ui/i18n/src/main/res/values-sv/strings.xml -trans.tr = ui/i18n/src/main/res/values-tr/strings.xml -trans.uk_UA = ui/i18n/src/main/res/values-uk/strings.xml -trans.zh_CN = ui/i18n/src/main/res/values-zh-rCN/strings.xml -trans.zh_HK = ui/i18n/src/main/res/values-zh-rHK/strings.xml -trans.zh_TW = ui/i18n/src/main/res/values-zh-rTW/strings.xml +trans.ar = app/src/main/res/values-ar/strings.xml +trans.ast_ES = app/src/main/res/values-ast/strings.xml +trans.br = app/src/main/res/values-br/strings.xml +trans.ca = app/src/main/res/values-ca/strings.xml +trans.cs_CZ = app/src/main/res/values-cs/strings.xml +trans.da = app/src/main/res/values-da/strings.xml +trans.de = app/src/main/res/values-de/strings.xml +trans.es = app/src/main/res/values-es/strings.xml +trans.et = app/src/main/res/values-et/strings.xml +trans.eu = app/src/main/res/values-eu/strings.xml +trans.fa = app/src/main/res/values-fa/strings.xml +trans.fi = app/src/main/res/values-fi/strings.xml +trans.fr = app/src/main/res/values-fr/strings.xml +trans.gl = app/src/main/res/values-gl/strings.xml +trans.he_IL = app/src/main/res/values-iw/strings.xml +trans.hi_IN = app/src/main/res/values-hi/strings.xml +trans.hu = app/src/main/res/values-hu/strings.xml +trans.id = app/src/main/res/values-in/strings.xml +trans.it_IT = app/src/main/res/values-it/strings.xml +trans.ja = app/src/main/res/values-ja/strings.xml +trans.ko = app/src/main/res/values-ko/strings.xml +trans.lt = app/src/main/res/values-lt/strings.xml +trans.nb_NO = app/src/main/res/values-nb/strings.xml +trans.nl = app/src/main/res/values-nl/strings.xml +trans.pl_PL = app/src/main/res/values-pl/strings.xml +trans.pt = app/src/main/res/values-pt/strings.xml +trans.pt_BR = app/src/main/res/values-pt-rBR/strings.xml +trans.ro_RO = app/src/main/res/values-ro/strings.xml +trans.ru_RU = app/src/main/res/values-ru/strings.xml +trans.sk = app/src/main/res/values-sk/strings.xml +trans.sl_SI = app/src/main/res/values-sl/strings.xml +trans.sv_SE = app/src/main/res/values-sv/strings.xml +trans.tr = app/src/main/res/values-tr/strings.xml +trans.uk_UA = app/src/main/res/values-uk/strings.xml +trans.zh_CN = app/src/main/res/values-zh-rCN/strings.xml +trans.zh_HK = app/src/main/res/values-zh-rHK/strings.xml +trans.zh_TW = app/src/main/res/values-zh-rTW/strings.xml [o:podcini:p:podcini:r:description] file_filter = app/src/main/play/listings//full-description.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5947b41..814c03c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ How to submit a feature request Translating Podcini ---------------------- -If you would like to translate the app into another language or improve an existing translation, you can visit the [Transifex project page](to be announced/). From there, you can either join a language team if it already exists or create a new language team. +If you would like to translate the app into another language or improve an existing translation, you can visit the [Transifex project page](https://app.transifex.com/xilinjia/podcini/dashboard/). From there, you can either join a language team if it already exists or create a new language team. Submit a pull request --------------------- diff --git a/app/build.gradle b/app/build.gradle index 6a7a55dd..829a8771 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -125,8 +125,8 @@ android { buildConfig true } defaultConfig { - versionCode 3020202 - versionName "6.0.2" + versionCode 3020203 + versionName "6.0.3" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/CancelableMediaPlayerCallback.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/CancelableMediaPlayerCallback.kt index cee16ad4..ae0eeaa8 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/CancelableMediaPlayerCallback.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/CancelableMediaPlayerCallback.kt @@ -2,8 +2,8 @@ package de.test.podcini.service.playback import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.model.Playable -import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo +import ac.mdiq.podcini.playback.base.MediaPlayerCallback class CancelableMediaPlayerCallback(private val originalCallback: MediaPlayerCallback) : MediaPlayerCallback { private var isCancelled = false diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/DefaultMediaPlayerCallback.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/DefaultMediaPlayerCallback.kt index ebeb3dbb..6b521779 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/DefaultMediaPlayerCallback.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/DefaultMediaPlayerCallback.kt @@ -2,8 +2,8 @@ package de.test.podcini.service.playback import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.model.Playable -import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo +import ac.mdiq.podcini.playback.base.MediaPlayerCallback open class DefaultMediaPlayerCallback : MediaPlayerCallback { override fun statusChanged(newInfo: MediaPlayerInfo?) { diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt index 1ae9259f..d07b6f45 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt @@ -100,7 +100,7 @@ class MediaPlayerBaseTest { } private fun writeTestPlayable(downloadUrl: String?, fileUrl: String?): Playable { - val f = Feed(0, null, "f", "l", "d", null, null, null, null, "i", null, null, "l", false) + val f = Feed(0, null, "f", "l", "d", null, null, null, null, "i", null, null, "l") val prefs = FeedPreferences(f.id, false, FeedPreferences.AutoDeleteAction.NEVER, VolumeAdaptionSetting.OFF, null, null) f.preferences = prefs diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt index 1c3cdb07..2c610a91 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt @@ -62,7 +62,7 @@ class TaskManagerTest { private fun writeTestQueue(pref: String): List? { val NUM_ITEMS = 10 - val f = Feed(0, null, "title", "link", "d", null, null, null, null, "id", null, "null", "url", false) + val f = Feed(0, null, "title", "link", "d", null, null, null, null, "id", null, "null", "url") f.episodes.clear() for (i in 0 until NUM_ITEMS) { f.episodes.add(Episode(0, pref + i, pref + i, "link", Date(), Episode.PLAYED, f)) diff --git a/app/src/androidTest/kotlin/ac/test/podcini/storage/AutoDownloadTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/storage/AutoDownloadTest.kt index a23c402b..e90d7017 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/storage/AutoDownloadTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/storage/AutoDownloadTest.kt @@ -4,8 +4,8 @@ import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming import ac.mdiq.podcini.preferences.UserPreferences.isFollowQueue -import ac.mdiq.podcini.storage.database.Episodes -import ac.mdiq.podcini.storage.database.Episodes.downloadAlgorithm +import ac.mdiq.podcini.storage.algorithms.AutoDownloads +import ac.mdiq.podcini.storage.algorithms.AutoDownloads.downloadAlgorithm import ac.mdiq.podcini.storage.model.Episode import android.content.Context import androidx.test.core.app.ApplicationProvider @@ -47,7 +47,7 @@ class AutoDownloadTest { @Throws(Exception::class) fun tearDown() { // setDownloadAlgorithm(Episodes.AutomaticDownloadAlgorithm()) - downloadAlgorithm = Episodes.AutomaticDownloadAlgorithm() + downloadAlgorithm = AutoDownloads.AutoDownloadAlgorithm() EspressoTestUtils.tryKillPlaybackService() stubFeedsServer!!.tearDown() } @@ -103,11 +103,11 @@ class AutoDownloadTest { // .until { item.media!!.id == currentlyPlayingFeedMediaId } } - private class StubDownloadAlgorithm : Episodes.AutomaticDownloadAlgorithm() { + private class StubDownloadAlgorithm : AutoDownloads.AutoDownloadAlgorithm() { var currentlyPlayingAtDownload: Long = -1 private set - override fun autoDownloadEpisodeMedia(context: Context): Runnable? { + override fun autoDownloadEpisodeMedia(context: Context): Runnable { return Runnable { if (currentlyPlayingAtDownload == -1L) { // currentlyPlayingAtDownload = currentlyPlayingFeedMediaId diff --git a/app/src/androidTest/kotlin/ac/test/podcini/ui/PreferencesTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/ui/PreferencesTest.kt index ff8fb042..f2a21bea 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/ui/PreferencesTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/ui/PreferencesTest.kt @@ -12,11 +12,11 @@ import androidx.test.filters.LargeTest import androidx.test.rule.ActivityTestRule import ac.mdiq.podcini.R import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APCleanupAlgorithm -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APNullCleanupAlgorithm -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APQueueCleanupAlgorithm -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.build -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.ExceptFavoriteCleanupAlgorithm +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APCleanupAlgorithm +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APNullCleanupAlgorithm +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APQueueCleanupAlgorithm +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.build +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.ExceptFavoriteCleanupAlgorithm import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.EnqueueLocation import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation diff --git a/app/src/androidTest/kotlin/ac/test/podcini/ui/UITestUtils.kt b/app/src/androidTest/kotlin/ac/test/podcini/ui/UITestUtils.kt index 1ffc54d5..47b5143b 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/ui/UITestUtils.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/ui/UITestUtils.kt @@ -107,7 +107,7 @@ class UITestUtils(private val context: Context) { for (i in 0 until NUM_FEEDS) { val feed = Feed(0, null, "Title $i", "http://example.com/$i", "Description of feed $i", "http://example.com/pay/feed$i", "author $i", "en", Feed.TYPE_RSS2, "feed$i", null, null, - "http://example.com/feed/src/$i", false) + "http://example.com/feed/src/$i") // create items val items: MutableList = ArrayList() @@ -147,11 +147,8 @@ class UITestUtils(private val context: Context) { /** * Adds feeds, images and episodes to the local database. This method will also call addHostedFeedData if it has not * been called yet. - * * Adds one item of each feed to the queue and to the playback history. - * * This method should NOT be called if the testing class wants to download the hosted feed data. - * * @param downloadEpisodes true if episodes should also be marked as downloaded. */ @Throws(Exception::class) @@ -161,13 +158,10 @@ class UITestUtils(private val context: Context) { // might be a flaky test, this is actually not that severe return } - if (!feedDataHosted) { - addHostedFeedData() - } + if (!feedDataHosted) addHostedFeedData() val queue: MutableList = ArrayList() for (feed in hostedFeeds) { - feed.downloaded = (true) if (downloadEpisodes) { for (item in feed.episodes) { if (item.media != null) { @@ -191,7 +185,7 @@ class UITestUtils(private val context: Context) { // adapter.setCompleteFeed(*hostedFeeds.toTypedArray()) // adapter.setQueue(queue) // adapter.close() - EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(hostedFeeds)) +// EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.UNKNOWN, hostedFeeds)) EventFlow.postEvent(FlowEvent.QueueEvent.setQueue(queue)) } diff --git a/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt b/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt index 9f3d58ef..f8c866b6 100644 --- a/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt +++ b/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt @@ -2,7 +2,7 @@ package ac.mdiq.podcini.playback.cast import android.content.Context import ac.mdiq.podcini.playback.base.MediaPlayerBase -import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback +import ac.mdiq.podcini.playback.base.MediaPlayerCallback /** * Stub implementation of CastPsmp for Free build flavour diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08d1c705..e2488ccf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,7 +39,7 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:label="@string/app_name" - android:backupAgent=".storage.backup.OpmlBackupAgent" + android:backupAgent=".preferences.OpmlBackupAgent" android:restoreAnyVersion="true" android:theme="@style/Theme.Podcini.Splash" android:supportsRtl="true" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt b/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt index 3460b1ac..f3e75a09 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/PodciniApp.kt @@ -4,7 +4,7 @@ import ac.mdiq.podcini.preferences.PreferenceUpgrader import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.ui.activity.SplashActivity import ac.mdiq.podcini.util.SPAUtil -import ac.mdiq.podcini.util.config.ApplicationCallbacksImpl +import ac.mdiq.podcini.util.config.ApplicationCallbacks import ac.mdiq.podcini.util.config.ClientConfig import ac.mdiq.podcini.util.config.ClientConfigurator import ac.mdiq.podcini.util.error.CrashReportWriter @@ -49,6 +49,12 @@ class PodciniApp : Application() { DynamicColors.applyToActivitiesIfAvailable(this) } + class ApplicationCallbacksImpl : ApplicationCallbacks { + override fun getApplicationInstance(): Application { + return PodciniApp.getInstance() + } + } + companion object { private lateinit var singleton: PodciniApp diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/NetworkConnectionChangeHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/NetworkConnectionChangeHandler.kt index dfc6af0c..bda70eaf 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/NetworkConnectionChangeHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/NetworkConnectionChangeHandler.kt @@ -6,7 +6,7 @@ import androidx.media3.common.util.UnstableApi import ac.mdiq.podcini.net.utils.NetworkUtils.isAutoDownloadAllowed import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface -import ac.mdiq.podcini.storage.database.Episodes.autodownloadEpisodeMedia +import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia import ac.mdiq.podcini.util.Logd @UnstableApi diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/serviceinterface/DownloadRequest.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/serviceinterface/DownloadRequest.kt index 550e15b9..cd47d60d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/serviceinterface/DownloadRequest.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/serviceinterface/DownloadRequest.kt @@ -152,7 +152,7 @@ class DownloadRequest private constructor(@JvmField val destination: String?, feed.downloadUrl != null -> prepareUrl(feed.downloadUrl!!) else -> null } - this.title = feed.getHumanReadableIdentifier() + this.title = feed.getTextIdentifier() this.feedfileId = feed.id this.feedfileType = feed.getTypeAsInt() arguments.putInt(REQUEST_ARG_PAGE_NR, feed.pageNr) 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 62cfdc51..bd21f870 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 @@ -14,6 +14,7 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.isFeedRefreshAllowed 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 @@ -23,7 +24,6 @@ import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting import ac.mdiq.podcini.ui.utils.NotificationUtils import ac.mdiq.podcini.util.config.ClientConfigurator -import ac.mdiq.podcini.util.error.InvalidFeedException import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import android.Manifest @@ -129,14 +129,11 @@ object FeedUpdateManager { @OptIn(UnstableApi::class) class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(context, params) { - // private val newEpisodesNotification = NewEpisodesNotification() private val notificationManager = NotificationManagerCompat.from(context) @UnstableApi override fun doWork(): Result { ClientConfigurator.initialize(applicationContext) -// newEpisodesNotification.loadCountersBeforeRefresh() - val toUpdate: MutableList val feedId = inputData.getLong(EXTRA_FEED_ID, -1L) var allAreLocal = true @@ -158,7 +155,6 @@ object FeedUpdateManager { toUpdate.add(feed) // Needs to be updatable, so no singletonList force = true } - if (!inputData.getBoolean(EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) { if (!networkAvailable() || !isFeedRefreshAllowed) { Logd(TAG, "Blocking automatic update") @@ -166,12 +162,10 @@ object FeedUpdateManager { } } refreshFeeds(toUpdate, force) - notificationManager.cancel(R.id.notification_updating_feeds) - Episodes.autodownloadEpisodeMedia(applicationContext) + autodownloadEpisodeMedia(applicationContext) return Result.success() } - private fun createNotification(toUpdate: List?): Notification { val context = applicationContext var contentText = "" @@ -190,11 +184,9 @@ object FeedUpdateManager { .addAction(R.drawable.ic_cancel, context.getString(R.string.cancel_label), WorkManager.getInstance(context).createCancelPendingIntent(id)) .build() } - override fun getForegroundInfoAsync(): ListenableFuture { return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null))) } - @UnstableApi private fun refreshFeeds(toUpdate: MutableList, force: Boolean) { if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext, @@ -211,10 +203,8 @@ object FeedUpdateManager { // Toast.makeText(applicationContext, R.string.notification_permission_text, Toast.LENGTH_LONG).show() return } - while (toUpdate.isNotEmpty()) { if (isStopped) return - notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate)) val feed = unmanagedCopy(toUpdate[0]) try { @@ -230,18 +220,15 @@ object FeedUpdateManager { toUpdate.removeAt(0) } } - @UnstableApi @Throws(Exception::class) fun refreshFeed(feed: Feed, force: Boolean) { val nextPage = (inputData.getBoolean(EXTRA_NEXT_PAGE, false) && feed.nextPageLink != null) if (nextPage) feed.pageNr += 1 - val builder = create(feed) builder.setForce(force || feed.lastUpdateFailed) if (nextPage) builder.source = feed.nextPageLink val request = builder.build() - val downloader = DefaultDownloaderFactory().create(request) ?: throw Exception("Unable to create downloader") downloader.call() if (!downloader.result.isSuccessful) { @@ -251,24 +238,18 @@ object FeedUpdateManager { LogsAndStats.addDownloadStatus(downloader.result) return } - val feedSyncTask = FeedSyncTask(applicationContext, request) val success = feedSyncTask.run() - if (!success) { Logd(TAG, "update failed: unsuccessful") Feeds.persistFeedLastUpdateFailed(feed, true) LogsAndStats.addDownloadStatus(feedSyncTask.downloadStatus) return } - if (request.feedfileId == null) return // No download logs for new subscriptions - // we create a 'successful' download log if the feed's last refresh failed val log = LogsAndStats.getFeedDownloadLog(request.feedfileId) if (log.isNotEmpty() && !log[0].isSuccessful) LogsAndStats.addDownloadStatus(feedSyncTask.downloadStatus) - -// newEpisodesNotification.showIfNeeded(applicationContext, feedSyncTask.savedFeed!!) if (!request.source.isNullOrEmpty()) { when { !downloader.permanentRedirectUrl.isNullOrEmpty() -> Feeds.updateFeedDownloadURL(request.source, downloader.permanentRedirectUrl!!) @@ -289,20 +270,16 @@ object FeedUpdateManager { DownloadError.ERROR_REQUEST_ERROR, Date(), "Unknown error: Status not set") } override fun call(): FeedHandlerResult? { - Logd(TAG, "in call()") + Logd(TAG, "in FeedParserTask call()") val feed = Feed(request.source, request.lastModified) feed.fileUrl = request.destination feed.id = request.feedfileId - feed.downloaded = true if (feed.preferences == null) feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, request.username, request.password) - if (request.arguments != null) feed.pageNr = request.arguments.getInt(DownloadRequest.REQUEST_ARG_PAGE_NR, 0) - var reason: DownloadError? = null var reasonDetailed: String? = null val feedHandler = FeedHandler() - var result: FeedHandlerResult? = null try { result = feedHandler.parseFeed(feed) @@ -344,31 +321,33 @@ object FeedUpdateManager { } } if (isSuccessful) { - downloadStatus = DownloadResult(feed.id, feed.getHumanReadableIdentifier()?:"", DownloadError.SUCCESS, isSuccessful, reasonDetailed?:"") + downloadStatus = DownloadResult(feed.id, feed.getTextIdentifier()?:"", DownloadError.SUCCESS, isSuccessful, reasonDetailed?:"") return result } else { - downloadStatus = DownloadResult(feed.id, feed.getHumanReadableIdentifier()?:"", reason?: DownloadError.ERROR_NOT_FOUND, - isSuccessful, reasonDetailed?:"") + downloadStatus = DownloadResult(feed.id, feed.getTextIdentifier()?:"", reason?: DownloadError.ERROR_NOT_FOUND, isSuccessful, reasonDetailed?:"") return null } } - /** * Checks if the feed was parsed correctly. */ @Throws(InvalidFeedException::class) private fun checkFeedData(feed: Feed) { if (feed.title == null) throw InvalidFeedException("Feed has no title") - checkFeedItems(feed) - } - - @Throws(InvalidFeedException::class) - private fun checkFeedItems(feed: Feed) { for (item in feed.episodes) { if (item.title == null) throw InvalidFeedException("Item has no title: $item") } } + /** + * Thrown if a feed has invalid attribute values. + */ + class InvalidFeedException(message: String?) : Exception(message) { + companion object { + private const val serialVersionUID = 1L + } + } + companion object { private val TAG: String = FeedParserTask::class.simpleName ?: "Anonymous" } @@ -379,6 +358,10 @@ object FeedUpdateManager { private set private val task = FeedParserTask(request) private var feedHandlerResult: FeedHandlerResult? = null + val downloadStatus: DownloadResult + get() = task.downloadStatus + val redirectUrl: String + get() = feedHandlerResult?.redirectUrl?:"" fun run(): Boolean { feedHandlerResult = task.call() @@ -386,12 +369,6 @@ object FeedUpdateManager { savedFeed = Feeds.updateFeed(context, feedHandlerResult!!.feed, false) return true } - - val downloadStatus: DownloadResult - get() = task.downloadStatus - - val redirectUrl: String - get() = feedHandlerResult?.redirectUrl?:"" } companion object { 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 81dc5f17..75187141 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 @@ -252,11 +252,11 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont val guid = if (isValidGuid(action.guid)) action.guid else null val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") if (feedItem == null) { - Log.i(TAG, "Unknown feed item: $action") + Logd(TAG, "Unknown feed item: $action") return null } if (feedItem.media == null) { - Log.i(TAG, "Feed item has no media: $action") + Logd(TAG, "Feed item has no media: $action") return null } var idRemove: Long? = null diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt index 8b7b8147..5af5ad4f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt @@ -157,7 +157,7 @@ import kotlin.math.min } } } else { - Log.w(TAG, "port $hostPort in use, ignored") + Logd(TAG, "port $hostPort in use, ignored") loginFail = true } EventFlow.postEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_in_progress, "5")) @@ -316,11 +316,11 @@ import kotlin.math.min val guid = if (isValidGuid(action.guid)) action.guid else null val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") if (feedItem == null) { - Log.i(TAG, "Unknown feed item: $action") + Logd(TAG, "Unknown feed item: $action") return null } if (feedItem.media == null) { - Log.i(TAG, "Feed item has no media: $action") + Logd(TAG, "Feed item has no media: $action") return null } // feedItem.media = getFeedMedia(feedItem.media!!.id) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt index 5c95a3b8..cb41a968 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt @@ -8,10 +8,14 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.service.PlaybackService import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isCasting +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isRunning import ac.mdiq.podcini.playback.service.PlaybackService.LocalBinder import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.MediaType +import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter +import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent @@ -39,6 +43,71 @@ abstract class PlaybackController(private val activity: FragmentActivity) { private var initialized = false private var eventsRegistered = false + private val mConnection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + if (service is LocalBinder) { + playbackService = service.service + onPlaybackServiceConnected() + if (!released) { + queryService() + Logd(TAG, "Connection to Service established") + } else Logd(TAG, "Connection to playback service has been established, but controller has already been released") + } + } + + override fun onServiceDisconnected(name: ComponentName) { + playbackService = null + initialized = false + Logd(TAG, "Disconnected from Service") + } + } + + private var prevStatus = PlayerStatus.STOPPED + private val statusUpdate: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Logd(TAG, "BroadcastReceiver onReceive") + if (playbackService != null && mPlayerInfo != null) { + val info = mPlayerInfo!! + Logd(TAG, "statusUpdate onReceive $prevStatus ${MediaPlayerBase.status} ${info.playerStatus} ${curMedia?.getIdentifier()} ${info.playable?.getIdentifier()}.") + if (prevStatus != info.playerStatus || curMedia == null || curMedia!!.getIdentifier() != info.playable?.getIdentifier()) { + MediaPlayerBase.status = info.playerStatus + prevStatus = MediaPlayerBase.status + curMedia = info.playable + handleStatus() + } + } else { + Logd(TAG, "statusUpdate onReceive: Couldn't receive status update: playbackService was null") + if (isRunning) bindToService() + else { + MediaPlayerBase.status = PlayerStatus.STOPPED + handleStatus() + } + } + } + } + + private val notificationReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val type = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_TYPE, -1) + val code = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_CODE, -1) + if (code == -1 || type == -1) { + Logd(TAG, "Bad arguments. Won't handle intent") + return + } + when (type) { + PlaybackService.NOTIFICATION_TYPE_RELOAD -> { + if (playbackService == null && isRunning) { + bindToService() + return + } + mediaInfoLoaded = false + queryService() + } + PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END -> onPlaybackEnd() + } + } + } + @Synchronized fun init() { Logd(TAG, "controller init") @@ -46,7 +115,7 @@ abstract class PlaybackController(private val activity: FragmentActivity) { procFlowEvents() eventsRegistered = true } - if (PlaybackService.isRunning) initServiceRunning() + if (isRunning) initServiceRunning() else updatePlayButtonShowsPlay(true) } @@ -133,76 +202,11 @@ abstract class PlaybackController(private val activity: FragmentActivity) { */ private fun bindToService() { Logd(TAG, "Trying to connect to service") - check(PlaybackService.isRunning) { "Trying to bind but service is not running" } + check(isRunning) { "Trying to bind but service is not running" } val bound = activity.bindService(Intent(activity, PlaybackService::class.java), mConnection, 0) Logd(TAG, "Result for service binding: $bound") } - private val mConnection: ServiceConnection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - if (service is LocalBinder) { - playbackService = service.service - onPlaybackServiceConnected() - if (!released) { - queryService() - Logd(TAG, "Connection to Service established") - } else Log.i(TAG, "Connection to playback service has been established, but controller has already been released") - } - } - - override fun onServiceDisconnected(name: ComponentName) { - playbackService = null - initialized = false - Logd(TAG, "Disconnected from Service") - } - } - - private var prevStatus = PlayerStatus.STOPPED - private val statusUpdate: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Logd(TAG, "BroadcastReceiver onReceive") - if (playbackService != null && mPlayerInfo != null) { - val info = mPlayerInfo!! - Logd(TAG, "statusUpdate onReceive $prevStatus ${MediaPlayerBase.status} ${info.playerStatus} ${curMedia?.getIdentifier()} ${info.playable?.getIdentifier()}.") - if (prevStatus != info.playerStatus || curMedia == null || curMedia!!.getIdentifier() != info.playable?.getIdentifier()) { - MediaPlayerBase.status = info.playerStatus - prevStatus = MediaPlayerBase.status - curMedia = info.playable - handleStatus() - } - } else { - Log.w(TAG, "statusUpdate onReceive: Couldn't receive status update: playbackService was null") - if (PlaybackService.isRunning) bindToService() - else { - MediaPlayerBase.status = PlayerStatus.STOPPED - handleStatus() - } - } - } - } - - private val notificationReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val type = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_TYPE, -1) - val code = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_CODE, -1) - if (code == -1 || type == -1) { - Logd(TAG, "Bad arguments. Won't handle intent") - return - } - when (type) { - PlaybackService.NOTIFICATION_TYPE_RELOAD -> { - if (playbackService == null && PlaybackService.isRunning) { - bindToService() - return - } - mediaInfoLoaded = false - queryService() - } - PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END -> onPlaybackEnd() - } - } - } - open fun onPlaybackEnd() {} /** @@ -258,7 +262,7 @@ abstract class PlaybackController(private val activity: FragmentActivity) { if (curMedia == null) return if (playbackService == null) { PlaybackServiceStarter(activity, curMedia!!).start() - Log.w(TAG, "playbackservice was null, restarted!") +// Log.w(TAG, "playbackservice was null, restarted!") } } @@ -266,26 +270,26 @@ abstract class PlaybackController(private val activity: FragmentActivity) { if (curMedia == null) return if (playbackService == null) { PlaybackServiceStarter(activity, curMedia!!).start() - Log.w(TAG, "playbackservice was null, restarted!") + Logd(TAG, "playbackservice was null, restarted!") return } when (MediaPlayerBase.status) { PlayerStatus.FALLBACK -> fallbackSpeed(1.0f) PlayerStatus.PLAYING -> { - playbackService?.mediaPlayer?.pause(true, reinit = false) + playbackService?.mPlayer?.pause(true, reinit = false) playbackService?.isSpeedForward = false playbackService?.isFallbackSpeed = false // if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END)) } PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { - playbackService?.mediaPlayer?.resume() + playbackService?.mPlayer?.resume() playbackService?.taskManager?.restartSleepTimer() // if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!)) } PlayerStatus.PREPARING -> isStartWhenPrepared = !isStartWhenPrepared PlayerStatus.INITIALIZED -> { if (playbackService != null) isStartWhenPrepared = true - playbackService?.mediaPlayer?.prepare() + playbackService?.mPlayer?.prepare() playbackService?.taskManager?.restartSleepTimer() } else -> { @@ -300,35 +304,35 @@ abstract class PlaybackController(private val activity: FragmentActivity) { var playbackService: PlaybackService? = null - val position: Int - get() = playbackService?.currentPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME + val curPosition: Int + get() = playbackService?.curPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME val duration: Int - get() = playbackService?.duration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME + get() = playbackService?.curDuration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME val curSpeedMultiplier: Float - get() = playbackService?.currentPlaybackSpeed ?: getCurrentPlaybackSpeed(curMedia) + get() = playbackService?.curSpeed ?: getCurrentPlaybackSpeed(curMedia) val isPlayingVideoLocally: Boolean get() = when { - PlaybackService.isCasting -> false + isCasting -> false playbackService != null -> currentMediaType == MediaType.VIDEO else -> curMedia?.getMediaType() == MediaType.VIDEO } private var isStartWhenPrepared: Boolean - get() = playbackService?.mediaPlayer?.startWhenPrepared?.get() ?: false + get() = playbackService?.mPlayer?.startWhenPrepared?.get() ?: false set(s) { - playbackService?.mediaPlayer?.startWhenPrepared?.set(s) + playbackService?.mPlayer?.startWhenPrepared?.set(s) } private val mPlayerInfo: MediaPlayerInfo? - get() = playbackService?.mediaPlayer?.playerInfo + get() = playbackService?.mPlayer?.playerInfo fun seekTo(time: Int) { if (playbackService != null) { - playbackService!!.mediaPlayer?.seekTo(time) - if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(time, duration)) + playbackService!!.mPlayer?.seekTo(time) +// if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, time, duration)) } } @@ -337,24 +341,24 @@ abstract class PlaybackController(private val activity: FragmentActivity) { when (MediaPlayerBase.status) { PlayerStatus.PLAYING -> { MediaPlayerBase.status = PlayerStatus.FALLBACK - fallbackSpeed_(speed) + setToFallback(speed) } PlayerStatus.FALLBACK -> { MediaPlayerBase.status = PlayerStatus.PLAYING - fallbackSpeed_(speed) + setToFallback(speed) } else -> {} } } } - private fun fallbackSpeed_(speed: Float) { - if (playbackService?.mediaPlayer == null || playbackService!!.isSpeedForward) return + private fun setToFallback(speed: Float) { + if (playbackService?.mPlayer == null || playbackService!!.isSpeedForward) return if (!playbackService!!.isFallbackSpeed) { - playbackService!!.normalSpeed = playbackService!!.mediaPlayer!!.getPlaybackSpeed() - playbackService!!.mediaPlayer!!.setPlaybackParams(speed, isSkipSilence) - } else playbackService!!.mediaPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) + playbackService!!.normalSpeed = playbackService!!.mPlayer!!.getPlaybackSpeed() + playbackService!!.mPlayer!!.setPlaybackParams(speed, isSkipSilence) + } else playbackService!!.mPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) playbackService!!.isFallbackSpeed = !playbackService!!.isFallbackSpeed } @@ -362,5 +366,28 @@ abstract class PlaybackController(private val activity: FragmentActivity) { fun sleepTimerActive(): Boolean { return playbackService?.taskManager?.isSleepTimerActive ?: false } + + /** + * Returns an intent which starts an audio- or videoplayer, depending on the + * type of media that is being played. If the playbackservice is not + * running, the type of the last played media will be looked up. + */ + @JvmStatic + fun getPlayerActivityIntent(context: Context): Intent { + val showVideoPlayer = if (isRunning) currentMediaType == MediaType.VIDEO && !isCasting + else curState.curIsVideo + return if (showVideoPlayer) VideoPlayerActivityStarter(context).intent + else MainActivityStarter(context).withOpenPlayer().getIntent() + } + + /** + * Same as [.getPlayerActivityIntent], but here the type of activity + * depends on the medaitype that is provided as an argument. + */ + @JvmStatic + fun getPlayerActivityIntent(context: Context, mediaType: MediaType?): Intent { + return if (mediaType == MediaType.VIDEO && !isCasting) VideoPlayerActivityStarter(context).intent + else MainActivityStarter(context).withOpenPlayer().getIntent() + } } } 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 9c855b3f..655a9c16 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt @@ -27,10 +27,6 @@ object InTheatre { } var curMedia: Playable? = null -// get() { -// if (field == null) field = loadPlayableFromPreferences() -// return field -// } set(value) { field = value if (field is EpisodeMedia) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt index 02562680..a93c6fe1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt @@ -49,18 +49,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont status = PlayerStatus.STOPPED } -// fun startWhenPrepared.get(): Boolean { -// return startWhenPrepared.get() -// } - -// fun startWhenPrepared.set(startWhenPrepared: Boolean) { -// this.startWhenPrepared.set(startWhenPrepared) -// } - -// open fun getPlayable(): Playable? { -// return curMedia -// } - protected open fun setPlayable(playable: Playable?) { if (playable != null && playable !== curMedia) { curMedia = playable @@ -154,12 +142,15 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont /** * Seek a specific position from the current position - * @param d offset from current position (positive or negative) + * @param delta offset from current position (positive or negative) */ - fun seekDelta(d: Int) { - val currentPosition = getPosition() - if (currentPosition != Playable.INVALID_TIME) seekTo(currentPosition + d) - else Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta") + fun seekDelta(delta: Int) { + val curPosition = getPosition() + if (curPosition != Playable.INVALID_TIME) { + val prevMedia = curMedia + seekTo(curPosition + delta) + } + else Log.e(TAG, "seekDelta getPosition() returned INVALID_TIME in seekDelta") } /** @@ -268,18 +259,15 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont @Synchronized protected fun setPlayerStatus(newStatus: PlayerStatus, newMedia: Playable?, position: Int = Playable.INVALID_TIME) { Logd(TAG, this.javaClass.simpleName + ": Setting player status to " + newStatus) - this.oldStatus = status status = newStatus if (newMedia != null) setPlayable(newMedia) - if (newMedia != null && newStatus != PlayerStatus.INDETERMINATE) { when { oldStatus == PlayerStatus.PLAYING && newStatus != PlayerStatus.PLAYING -> callback.onPlaybackPause(newMedia, position) oldStatus != PlayerStatus.PLAYING && newStatus == PlayerStatus.PLAYING -> callback.onPlaybackStart(newMedia, position) } } - callback.statusChanged(MediaPlayerInfo(oldStatus, status, curMedia)) } @@ -289,29 +277,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont return (audioManager.mode != AudioManager.MODE_NORMAL || audioManager.isMusicActive) } - interface MediaPlayerCallback { - fun statusChanged(newInfo: MediaPlayerInfo?) - - // TODO: not used - fun shouldStop() {} - - fun onMediaChanged(reloadUI: Boolean) - - fun onPostPlayback(media: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean) - - fun onPlaybackStart(playable: Playable, position: Int) - - fun onPlaybackPause(playable: Playable?, position: Int) - - fun getNextInQueue(currentMedia: Playable?): Playable? - - fun findMedia(url: String): Playable? - - fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean) - - fun ensureMediaInfoLoaded(media: Playable) - } - class MediaPlayerInfo(@JvmField val oldPlayerStatus: PlayerStatus?, @JvmField var playerStatus: PlayerStatus, @JvmField var playable: Playable?) companion object { @@ -354,7 +319,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont val newPosition = currentPosition - rewindTime.toInt() return max(newPosition.toDouble(), 0.0).toInt() } else return currentPosition - } /** @@ -363,19 +327,11 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont @JvmStatic fun getCurrentPlaybackSpeed(media: Playable?): Float { var playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL - var mediaType: MediaType? = null + val mediaType: MediaType? = media?.getMediaType() if (media != null) { - mediaType = media.getMediaType() playbackSpeed = curState.curTempSpeed if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) { - val item = media.episode - if (item != null) { - val feed = item.feed - if (feed?.preferences != null) { - playbackSpeed = feed.preferences!!.playSpeed - Logd(TAG, "using feed speed $playbackSpeed") - } else Logd(TAG, "Can not get feed specific playback speed: $feed") - } + if (media.episode?.feed?.preferences != null) playbackSpeed = media.episode!!.feed!!.preferences!!.playSpeed } } if (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = UserPreferences.getPlaybackSpeed(mediaType) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerCallback.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerCallback.kt new file mode 100644 index 00000000..de6daa80 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerCallback.kt @@ -0,0 +1,28 @@ +package ac.mdiq.podcini.playback.base + +import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo +import ac.mdiq.podcini.storage.model.Playable +import ac.mdiq.podcini.storage.utils.MediaType + +interface MediaPlayerCallback { + fun statusChanged(newInfo: MediaPlayerInfo?) + + // TODO: not used + fun shouldStop() {} + + fun onMediaChanged(reloadUI: Boolean) + + fun onPostPlayback(playable: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean) + + fun onPlaybackStart(playable: Playable, position: Int) + + fun onPlaybackPause(playable: Playable?, position: Int) + + fun getNextInQueue(currentMedia: Playable?): Playable? + + fun findMedia(url: String): Playable? + + fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean) + + fun ensureMediaInfoLoaded(media: Playable) +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/PlayerStatus.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/PlayerStatus.kt index 1bb1796b..18da9097 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/PlayerStatus.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/PlayerStatus.kt @@ -1,17 +1,17 @@ package ac.mdiq.podcini.playback.base enum class PlayerStatus(private val statusValue: Int) { - INDETERMINATE(0), // player is currently changing its state, listeners should wait until the state is left ERROR(-1), - PREPARING(19), - PAUSED(30), - FALLBACK(35), - PLAYING(40), + INDETERMINATE(0), // player is currently changing its state, listeners should wait until the state is left STOPPED(5), + INITIALIZING(9), // playback service is loading the Playable's metadata + INITIALIZED(10), // playback service was started, data source of media player was set + PREPARING(19), PREPARED(20), SEEKING(29), - INITIALIZING(9), // playback service is loading the Playable's metadata - INITIALIZED(10); // playback service was started, data source of media player was set + PAUSED(30), + FALLBACK(35), + PLAYING(40); fun isAtLeast(other: PlayerStatus?): Boolean { return other == null || this.statusValue >= other.statusValue 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 1d0fe46b..a264778f 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 @@ -1,6 +1,5 @@ package ac.mdiq.podcini.playback.service -import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.R import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder import ac.mdiq.podcini.net.download.service.PodciniHttpClient @@ -8,6 +7,7 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.MediaPlayerBase +import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope @@ -58,7 +58,6 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.Volatile - /** * Manages the MediaPlayer object of the PlaybackService. */ @@ -82,13 +81,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP private val formats: List get() { - val formats: MutableList = arrayListOf() + val formats_: MutableList = arrayListOf() val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList() val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) for (i in 0 until trackGroups.length) { - formats.add(trackGroups[i].getFormat(0)) + formats_.add(trackGroups[i].getFormat(0)) } - return formats + return formats_ } private val audioRendererIndex: Int @@ -139,7 +138,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP private fun prepareWR() { Logd(TAG, "prepareWR() called") if (mediaSource == null && mediaItem == null) return - if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false) else exoPlayer?.setMediaItem(mediaItem!!) exoPlayer?.prepare() @@ -164,7 +162,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP exoPlayer?.setAudioAttributes(b.build(), true) } - private fun metadata(p: Playable): MediaMetadata { + private fun buildMetadata(p: Playable): MediaMetadata { val builder = MediaMetadata.Builder() .setArtist(p.getFeedTitle()) .setTitle(p.getEpisodeTitle()) @@ -208,18 +206,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will * not do anything. * Whether playback starts immediately depends on the given parameters. See below for more details. - * * States: * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. - * * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. - * * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object * will enter the ERROR state. - * * This method is executed on an internal executor service. - * * @param playable The Playable object that is supposed to be played. This parameter must not be null. * @param stream The type of playback. If false, the Playable object MUST provide access to a locally available file via * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by @@ -230,32 +223,28 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP * @param prepareImmediately Set to true if the method should also prepare the episode for playback. */ override fun playMediaObject(playable: Playable, stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { - Logd(TAG, "playMediaObject $forceReset $stream $startWhenPrepared $prepareImmediately $status ${playable.getEpisodeTitle()} ") + Logd(TAG, "playMediaObject status=$status stream=$stream startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ") if (curMedia != null) { if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { - // episode is already playing -> ignore method call Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.") return - } else { - Logd(TAG, "playMediaObject starts new media ${curMedia!!.getIdentifier()} ${prevMedia?.getIdentifier()} $status") - // set temporarily to pause in order to update list with current position - if (status == PlayerStatus.PLAYING) { - val pos = curMedia?.getPosition() ?: -1 - seekTo(pos) - callback.onPlaybackPause(curMedia, pos) - } - // 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) - } - prevMedia = curMedia - setPlayerStatus(PlayerStatus.INDETERMINATE, null) } + Logd(TAG, "playMediaObject starts new media playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}") + // set temporarily to pause in order to update list with current position + if (status == PlayerStatus.PLAYING) { + val pos = curMedia?.getPosition() ?: -1 + seekTo(pos) + callback.onPlaybackPause(curMedia, pos) + } + // 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) + prevMedia = curMedia + setPlayerStatus(PlayerStatus.INDETERMINATE, null) } + Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}") curMedia = playable this.isStreaming = stream mediaType = curMedia!!.getMediaType() @@ -263,7 +252,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP createMediaPlayer() this.startWhenPrepared.set(startWhenPrepared) setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) - val metadata = metadata(curMedia!!) + val metadata = buildMetadata(curMedia!!) try { callback.ensureMediaInfoLoaded(curMedia!!) callback.onMediaChanged(false) @@ -313,7 +302,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime()) seekTo(newPosition) } -// play() if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() exoPlayer?.play() // Can't set params when paused - so always set it on start in case they changed @@ -331,9 +319,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition()) if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END)) if (isStreaming && reinit) reinit() - } else { - Logd(TAG, "Ignoring call to pause: Player is in $status state") - } + } else Logd(TAG, "Ignoring call to pause: Player is in $status state") } override fun prepare() { @@ -346,7 +332,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP if (curMedia != null) { val pos = curMedia!!.getPosition() if (pos > 0) seekTo(pos) - if (curMedia!!.getDuration() <= 0) { + if (curMedia != null && curMedia!!.getDuration() <= 0) { Logd(TAG, "Setting duration of media") curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt()) } @@ -367,21 +353,24 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP } } - override fun seekTo(t0: Int) { - var t = t0 + override fun seekTo(t: Int) { + var t = t if (t < 0) t = 0 - Logd(TAG, "seekTo() called") + Logd(TAG, "seekTo() called $t") if (t >= getDuration()) { Logd(TAG, "Seek reached end of file, skipping to next episode") - exoPlayer?.seekTo(t.toLong()) + exoPlayer?.seekTo(t.toLong()) // can set curMedia to null + if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration())) audioSeekCompleteListener?.run() endPlayback(true, wasSkipped = true, true, toStoppedState = true) + t = getPosition() // return } when (status) { PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { + Logd(TAG, "seekTo() called $t") if (seekLatch != null && seekLatch!!.count > 0) { try { seekLatch!!.await(3, TimeUnit.SECONDS) @@ -391,8 +380,9 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP } seekLatch = CountDownLatch(1) statusBeforeSeeking = status - setPlayerStatus(PlayerStatus.SEEKING, curMedia, getPosition()) + setPlayerStatus(PlayerStatus.SEEKING, curMedia, t) exoPlayer?.seekTo(t.toLong()) + if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration())) audioSeekCompleteListener?.run() if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t) try { @@ -411,25 +401,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP } override fun getDuration(): Int { - var retVal = Playable.INVALID_TIME - if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) - retVal = if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt() - if (retVal <= 0) { - val playableDur = curMedia?.getDuration() ?: -1 - if (playableDur > 0) retVal = playableDur - } - return retVal + return curMedia?.getDuration() ?: Playable.INVALID_TIME } override fun getPosition(): Int { var retVal = Playable.INVALID_TIME -// Log.d(TAG, "getPosition() ${playable?.getIdentifier()} $status") if (status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt() - - if (retVal <= 0) { - val playablePos = curMedia?.getPosition() ?: -1 - if (playablePos >= 0) retVal = playablePos - } + if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition() return retVal } @@ -461,16 +439,14 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP volumeRight *= adaptionFactor } } - if (volumeLeft > 1) { - exoPlayer!!.volume = 1f + exoPlayer?.volume = 1f loudnessEnhancer?.setEnabled(true) loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt()) } else { - exoPlayer!!.volume = volumeLeft + exoPlayer?.volume = volumeLeft loudnessEnhancer?.setEnabled(false) } - Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight") } @@ -543,6 +519,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP } override fun createMediaPlayer() { + Logd(TAG, "createMediaPlayer()") release() if (curMedia == null) { status = PlayerStatus.STOPPED @@ -559,21 +536,19 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP // we're relying on the position stored in the Playable object for post-playback processing val position = getPosition() if (position >= 0) curMedia?.setPosition(position) - Logd(TAG, "endPlayback $hasEnded $wasSkipped $shouldContinue $toStoppedState") + Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState") val currentMedia = curMedia var nextMedia: Playable? = null if (shouldContinue) { - // Load next episode if previous episode was in the queue and if there - // is an episode in the queue left. + // Load next episode if previous episode was in the queue and if there is an episode in the queue left. // Start playback immediately if continuous playback is enabled nextMedia = callback.getNextInQueue(currentMedia) if (nextMedia != null) { Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false") -// curMedia = null + if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null) callback.onPlaybackEnded(nextMedia.getMediaType(), false) - // setting media to null signals to playMediaObject() that - // we're taking care of post-playback processing + // setting media to null signals to playMediaObject that we're taking care of post-playback processing curMedia = null playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying) } @@ -585,7 +560,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP callback.onPlaybackEnded(null, true) curMedia = null exoPlayer?.stop() -// stop() releaseWifiLockIfNecessary() if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null) else Logd(TAG, "Ignored call to stop: Current player state is: $status") @@ -689,7 +663,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP const val BUFFERING_STARTED: Int = -1 const val BUFFERING_ENDED: Int = -2 - const val ERROR_CODE_OFFSET: Int = 1000 private var httpDataSourceFactory: OkHttpDataSource.Factory? = 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 b8355c11..29c73aa5 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 @@ -11,8 +11,8 @@ import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.playback.base.InTheatre.loadPlayableFromPreferences import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying import ac.mdiq.podcini.playback.base.MediaPlayerBase -import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo +import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.cast.CastPsmp import ac.mdiq.podcini.playback.cast.CastStateListener @@ -55,11 +55,8 @@ import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded -import ac.mdiq.podcini.storage.utils.EpisodeUtil.indexOfItemWithId import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting -import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter -import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.ui.utils.NotificationUtils import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast @@ -106,7 +103,7 @@ import kotlin.math.max @UnstableApi class PlaybackService : MediaSessionService() { - internal var mediaPlayer: MediaPlayerBase? = null + internal var mPlayer: MediaPlayerBase? = null internal lateinit var taskManager: TaskManager internal var isSpeedForward = false internal var isFallbackSpeed = false @@ -130,19 +127,16 @@ class PlaybackService : MediaSessionService() { private val status: PlayerStatus get() = MediaPlayerBase.status - internal val playable: Playable? - get() = curMedia + val curSpeed: Float + get() = mPlayer?.getPlaybackSpeed() ?: 1.0f - val currentPlaybackSpeed: Float - get() = mediaPlayer?.getPlaybackSpeed() ?: 1.0f + val curDuration: Int + get() = mPlayer?.getDuration() ?: Playable.INVALID_TIME - val duration: Int - get() = mediaPlayer?.getDuration() ?: Playable.INVALID_TIME + val curPosition: Int + get() = mPlayer?.getPosition() ?: Playable.INVALID_TIME - val currentPosition: Int - get() = mediaPlayer?.getPosition() ?: Playable.INVALID_TIME - - private var previousPosition: Int = -1 + private var prevPosition: Int = -1 private val autoStateUpdated: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -154,11 +148,11 @@ class PlaybackService : MediaSessionService() { } else { val playerStatus = MediaPlayerBase.status when (playerStatus) { - PlayerStatus.PAUSED, PlayerStatus.PREPARED -> mediaPlayer?.resume() - PlayerStatus.PREPARING -> mediaPlayer?.startWhenPrepared?.set(!mediaPlayer!!.startWhenPrepared.get()) + PlayerStatus.PAUSED, PlayerStatus.PREPARED -> mPlayer?.resume() + PlayerStatus.PREPARING -> mPlayer?.startWhenPrepared?.set(!mPlayer!!.startWhenPrepared.get()) PlayerStatus.INITIALIZED -> { - mediaPlayer?.startWhenPrepared?.set(true) - mediaPlayer?.prepare() + mPlayer?.startWhenPrepared?.set(true) + mPlayer?.prepare() } else -> {} } @@ -215,6 +209,263 @@ class PlaybackService : MediaSessionService() { } } + private val taskManagerCallback: PSTMCallback = object : PSTMCallback { + override fun positionSaverTick() { + if (curPosition != prevPosition) { +// Log.d(TAG, "positionSaverTick currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed") + if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, curPosition, curDuration)) + skipEndingIfNecessary() + saveCurrentPosition(true, null, Playable.INVALID_TIME) + prevPosition = curPosition + } + } + override fun requestWidgetState(): WidgetState { + return WidgetState(curMedia, status, curPosition, curDuration, curSpeed) + } + override fun onChapterLoaded(media: Playable?) { + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0) + } + } + + private val mediaPlayerCallback: MediaPlayerCallback = object : MediaPlayerCallback { + override fun statusChanged(newInfo: MediaPlayerInfo?) { + currentMediaType = mPlayer?.mediaType ?: MediaType.UNKNOWN + Logd(TAG, "statusChanged called ${newInfo?.playerStatus}") + if (newInfo != null) { + when (newInfo.playerStatus) { + PlayerStatus.INITIALIZED -> + if (mPlayer != null) writeMediaPlaying(mPlayer!!.playerInfo.playable, mPlayer!!.playerInfo.playerStatus) + PlayerStatus.PREPARED -> { + if (mPlayer != null) writeMediaPlaying(mPlayer!!.playerInfo.playable, mPlayer!!.playerInfo.playerStatus) + if (newInfo.playable != null) taskManager.startChapterLoader(newInfo.playable!!) + } + PlayerStatus.PAUSED -> writePlayerStatus(MediaPlayerBase.status) + PlayerStatus.STOPPED -> {} + PlayerStatus.PLAYING -> { + writePlayerStatus(MediaPlayerBase.status) + saveCurrentPosition(true, null, Playable.INVALID_TIME) + recreateMediaSessionIfNeeded() + // set sleep timer if auto-enabled + var autoEnableByTime = true + val fromSetting = autoEnableFrom() + val toSetting = autoEnableTo() + if (fromSetting != toSetting) { + val now: Calendar = GregorianCalendar() + now.timeInMillis = System.currentTimeMillis() + val currentHour = now[Calendar.HOUR_OF_DAY] + autoEnableByTime = isInTimeRange(fromSetting, toSetting, currentHour) + } + if (newInfo.oldPlayerStatus != null && newInfo.oldPlayerStatus != PlayerStatus.SEEKING && autoEnable() && autoEnableByTime && !taskManager.isSleepTimerActive) { +// setSleepTimer(timerMillis()) + taskManager.setSleepTimer(timerMillis()) + EventFlow.postEvent(FlowEvent.MessageEvent(getString(R.string.sleep_timer_enabled_label), { taskManager.disableSleepTimer() }, getString(R.string.undo))) + } + } + PlayerStatus.ERROR -> writeNoMediaPlaying() + else -> {} + } + } + if (Build.VERSION.SDK_INT >= VERSION_CODES.N) + TileService.requestListeningState(applicationContext, ComponentName(applicationContext, QuickSettingsTileService::class.java)) + + sendLocalBroadcast(applicationContext, ACTION_PLAYER_STATUS_CHANGED) + bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED) + bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED) + taskManager.requestWidgetUpdate() + } + + override fun onMediaChanged(reloadUI: Boolean) { + Logd(TAG, "reloadUI callback reached") + if (reloadUI) sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0) + } + + override fun onPostPlayback(playable: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean) { + if (playable == null) { + Log.e(TAG, "Cannot do post-playback processing: media was null") + return + } + Logd(TAG, "onPostPlayback(): ended=$ended skipped=$skipped playingNext=$playingNext media=${playable.getEpisodeTitle()} ") + if (playable !is EpisodeMedia) { + Logd(TAG, "Not doing post-playback processing: media not of type EpisodeMedia") + if (ended) playable.onPlaybackCompleted(applicationContext) + else playable.onPlaybackPause(applicationContext) +// TODO: test +// return + } + val item = (playable as? EpisodeMedia)?.episode ?: currentitem + val smartMarkAsPlayed = hasAlmostEnded(playable) + if (!ended && smartMarkAsPlayed) Logd(TAG, "smart mark as played") + + var autoSkipped = false + if (autoSkippedFeedMediaId != null && autoSkippedFeedMediaId == item?.identifyingValue) { + autoSkippedFeedMediaId = null + autoSkipped = true + } + if (playable is EpisodeMedia) { + if (ended || smartMarkAsPlayed) { + SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, playable, true) + playable.onPlaybackCompleted(applicationContext) + } else { + SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, playable, false) + playable.onPlaybackPause(applicationContext) + } + } + 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") + } + } + if (playable is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item) + } + } + + override fun onPlaybackStart(playable: Playable, position: Int) { + Logd(TAG, "onPlaybackStart position: $position") + taskManager.startWidgetUpdater() + if (position != Playable.INVALID_TIME) playable.setPosition(position) + else skipIntro(playable) + playable.onPlaybackStart() + taskManager.startPositionSaver() + } + + override fun onPlaybackPause(playable: Playable?, position: Int) { + Logd(TAG, "onPlaybackPause $position") + taskManager.cancelPositionSaver() + saveCurrentPosition(position == Playable.INVALID_TIME || playable == null, playable, position) + taskManager.cancelWidgetUpdater() + if (playable != null) { + if (playable is EpisodeMedia) SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, playable, false) + playable.onPlaybackPause(applicationContext) + } + } + + override fun getNextInQueue(currentMedia: Playable?): Playable? { + Logd(TAG, "call getNextInQueue currentMedia: ${currentMedia?.getEpisodeTitle()}") + if (currentMedia !is EpisodeMedia) { + Logd(TAG, "getNextInQueue(), but playable not an instance of EpisodeMedia, so not proceeding") + writeNoMediaPlaying() + return null + } + val item = currentMedia.episode + if (item == null) { + Logd(TAG, "getNextInQueue() with EpisodeMedia object whose FeedItem is null") + writeNoMediaPlaying() + return null + } + val nextItem = getNextInQueue(item) + if (nextItem?.media == null) { + Logd(TAG, "getNextInQueue nextItem: $nextItem media is null") + writeNoMediaPlaying() + return null + } + + if (!isFollowQueue) { + Logd(TAG, "getNextInQueue(), but follow queue is not enabled.") + writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED) + return null + } + + if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed != null && !nextItem.feed!!.isLocalFeed) { + displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent) + writeNoMediaPlaying() + return null + } + EventFlow.postEvent(FlowEvent.PlayEvent(item, FlowEvent.PlayEvent.Action.END)) + EventFlow.postEvent(FlowEvent.PlayEvent(nextItem)) + return nextItem.media + } + + private fun getNextInQueue(episode: Episode): Episode? { + Logd(TAG, "getNextInQueue() with: itemId ${episode.id}") + if (curQueue.episodes.isEmpty()) return null + + val i = curQueue.episodes.indexOf(episode) + var j = 0 + if (i >= 0 && i < curQueue.episodes.size-1) j = i+1 + + val itemNew = curQueue.episodes[j] + return if (itemNew.isManaged()) realm.copyFromRealm(itemNew) else itemNew + } + + override fun findMedia(url: String): Playable? { + val item = getEpisodeByGuidOrUrl(null, url) + return item?.media + } + + override fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean) { + Logd(TAG, "onPlaybackEnded mediaType: $mediaType stopPlaying: $stopPlaying") + clearCurTempSpeed() + if (stopPlaying) taskManager.cancelPositionSaver() + + if (mediaType == null) sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0) + else { + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, + when { + isCasting -> EXTRA_CODE_CAST + mediaType == MediaType.VIDEO -> EXTRA_CODE_VIDEO + else -> EXTRA_CODE_AUDIO + }) + } + } + + override fun ensureMediaInfoLoaded(media: Playable) { +// if (media is EpisodeMedia && media.item == null) media.item = DBReader.getFeedItem(media.itemId) + } + + fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) { + Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}") + if (playable == null) { + writeNoMediaPlaying() + } else { + curState.curMediaType = playable.getPlayableType().toLong() + curState.curIsVideo = playable.getMediaType() == MediaType.VIDEO + if (playable is EpisodeMedia) { + val feedId = playable.episode?.feed?.id + if (feedId != null) curState.curFeedId = feedId + curState.curMediaId = playable.id + } else { + curState.curFeedId = NO_MEDIA_PLAYING + curState.curMediaId = NO_MEDIA_PLAYING + } + } + curState.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus) + upsertBlk(curState) {} + } + + fun writePlayerStatus(playerStatus: PlayerStatus) { + Logd(InTheatre.TAG, "Writing player status playback preferences") + curState.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus) + upsertBlk(curState) {} + } + + private fun getCurPlayerStatusAsInt(playerStatus: PlayerStatus): Int { + val playerStatusAsInt = when (playerStatus) { + PlayerStatus.PLAYING -> PLAYER_STATUS_PLAYING + PlayerStatus.PAUSED -> PLAYER_STATUS_PAUSED + else -> PLAYER_STATUS_OTHER + } + return playerStatusAsInt + } + } + + private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_SHUTDOWN_PLAYBACK_SERVICE) + EventFlow.postEvent(FlowEvent.PlaybackServiceEvent(FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)) + } + } + inner class LocalBinder : Binder() { val service: PlaybackService get() = this@PlaybackService @@ -274,16 +525,16 @@ class PlaybackService : MediaSessionService() { fun recreateMediaPlayer() { val media = curMedia var wasPlaying = false - if (mediaPlayer != null) { + if (mPlayer != null) { wasPlaying = MediaPlayerBase.status == PlayerStatus.PLAYING || MediaPlayerBase.status == PlayerStatus.FALLBACK - mediaPlayer!!.pause(abandonFocus = true, reinit = false) - mediaPlayer!!.shutdown() + mPlayer!!.pause(abandonFocus = true, reinit = false) + mPlayer!!.shutdown() } - mediaPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback) - if (mediaPlayer == null) mediaPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected + mPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback) + if (mPlayer == null) mPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected - if (media != null) mediaPlayer!!.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true) - isCasting = mediaPlayer!!.isCasting() + if (media != null) mPlayer!!.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true) + isCasting = mPlayer!!.isCasting() } override fun onTaskRemoved(rootIntent: Intent?) { @@ -313,7 +564,7 @@ class PlaybackService : MediaSessionService() { mediaSession = null } LocalMediaPlayer.exoPlayer = null - mediaPlayer?.shutdown() + mPlayer?.shutdown() cancelFlowEvents() unregisterReceiver(autoStateUpdated) @@ -330,15 +581,16 @@ class PlaybackService : MediaSessionService() { private inner class MyCallback : MediaSession.Callback { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { - Logd(TAG, "in onConnect") + Logd(TAG, "in MyCallback onConnect") val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() // .add(NotificationCustomButton.REWIND) // .add(NotificationCustomButton.FORWARD) when { session.isMediaNotificationController(controller) -> { + Logd(TAG, "MyCallback onConnect isMediaNotificationController") val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() notificationCustomButtons.forEach { commandButton -> - Logd(TAG, "onConnect commandButton ${commandButton.displayName}") + Logd(TAG, "MyCallback onConnect commandButton ${commandButton.displayName}") commandButton.sessionCommand?.let(sessionCommands::add) } return MediaSession.ConnectionResult.accept( @@ -347,16 +599,21 @@ class PlaybackService : MediaSessionService() { ) } session.isAutoCompanionController(controller) -> { + Logd(TAG, "MyCallback onConnect isAutoCompanionController") return MediaSession.ConnectionResult.AcceptedResultBuilder(session) .setAvailableSessionCommands(sessionCommands.build()) .build() } - else -> return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() + else -> { + Logd(TAG, "MyCallback onConnect other controller") + return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() + } } } override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { super.onPostConnect(session, controller) + Logd(TAG, "MyCallback onPostConnect") if (notificationCustomButtons.isNotEmpty()) { mediaSession?.setCustomLayout(notificationCustomButtons) // mediaSession?.setCustomLayout(customMediaNotificationProvider.notificationMediaButtons) @@ -364,17 +621,18 @@ class PlaybackService : MediaSessionService() { } override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { - Logd(TAG, "onCustomCommand ${customCommand.customAction}") + Logd(TAG, "MyCallback onCustomCommand ${customCommand.customAction}") /* Handling custom command buttons from player notification. */ when (customCommand.customAction) { - NotificationCustomButton.REWIND.customAction -> mediaPlayer?.seekDelta(-rewindSecs * 1000) - NotificationCustomButton.FORWARD.customAction -> mediaPlayer?.seekDelta(fastForwardSecs * 1000) - NotificationCustomButton.SKIP.customAction -> mediaPlayer?.skip() + NotificationCustomButton.REWIND.customAction -> mPlayer?.seekDelta(-rewindSecs * 1000) + NotificationCustomButton.FORWARD.customAction -> mPlayer?.seekDelta(fastForwardSecs * 1000) + NotificationCustomButton.SKIP.customAction -> mPlayer?.skip() } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo): ListenableFuture { + Logd(TAG, "MyCallback onPlaybackResumption ") val settable = SettableFuture.create() // scope.launch { // // Your app is responsible for storing the playlist and the start position @@ -388,7 +646,7 @@ class PlaybackService : MediaSessionService() { override fun onMediaButtonEvent(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, intent: Intent): Boolean { val keyEvent = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent.extras!!.getParcelable(EXTRA_KEY_EVENT, KeyEvent::class.java) else intent.extras!!.getParcelable(EXTRA_KEY_EVENT) as? KeyEvent - Logd(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}") + Logd(TAG, "MyCallback onMediaButtonEvent ${keyEvent?.keyCode}") if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { val keyCode = keyEvent.keyCode @@ -398,8 +656,8 @@ class PlaybackService : MediaSessionService() { clickHandler.postDelayed({ when (clickCount) { 1 -> handleKeycode(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false) - 2 -> mediaPlayer?.seekDelta(fastForwardSecs * 1000) - 3 -> mediaPlayer?.seekDelta(-rewindSecs * 1000) + 2 -> mPlayer?.seekDelta(fastForwardSecs * 1000) + 3 -> mPlayer?.seekDelta(-rewindSecs * 1000) } clickCount = 0 }, ViewConfiguration.getDoubleTapTimeout().toLong()) @@ -416,11 +674,7 @@ class PlaybackService : MediaSessionService() { override fun onBind(intent: Intent?): IBinder? { Logd(TAG, "Received onBind event") - return if (intent?.action != null && intent.action == SERVICE_INTERFACE) { - super.onBind(intent) - } else { - mBinder - } + return if (intent?.action != null && intent.action == SERVICE_INTERFACE) super.onBind(intent) else mBinder } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -429,48 +683,38 @@ class PlaybackService : MediaSessionService() { val hardwareButton = intent?.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false) ?: false val playable = curMedia - Logd(TAG, "onStartCommand flags=$flags startId=$startId $keycode $customAction $hardwareButton ${playable?.getEpisodeTitle()}") + Logd(TAG, "onStartCommand flags=$flags startId=$startId keycode=$keycode customAction=$customAction hardwareButton=$hardwareButton action=${intent?.action.toString()} ${playable?.getEpisodeTitle()}") if (keycode == -1 && playable == null && customAction == null) { - Log.e(TAG, "PlaybackService was started with no arguments") + Log.e(TAG, "onStartCommand PlaybackService was started with no arguments, return") return START_NOT_STICKY } - if ((flags and START_FLAG_REDELIVERY) != 0) { - Logd(TAG, "onStartCommand is a redelivered intent, calling stopForeground now.") - } else { - when { - keycode != -1 -> { - val notificationButton: Boolean - if (hardwareButton) { - Logd(TAG, "Received hardware button event") - notificationButton = false - } else { - Logd(TAG, "Received media button event") - notificationButton = true - } - val handled = handleKeycode(keycode, notificationButton) - return super.onStartCommand(intent, flags, startId) - } - playable != null -> { - if (status == PlayerStatus.PLAYING && mediaPlayer?.prevMedia?.getIdentifier() == playable.getIdentifier()) { - Logd(TAG, "onStartCommand playing same media: $status") -// pause button on notification also calls onStartCommand - return super.onStartCommand(intent, flags, startId) - } else { - Logd(TAG, "onStartCommand status: $status") - val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false - val allowStreamAlways = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_ALWAYS, false) ?: false - sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0) - if (allowStreamAlways) isAllowMobileStreaming = true - startPlaying(allowStreamThisTime) - } -// return super.onStartCommand(intent, flags, startId) - return START_NOT_STICKY - } - else -> { - Logd(TAG, "onStartCommand case when not (keycode != -1 and playable != null)") - } + Logd(TAG, "onStartCommand is a redelivered intent, calling stopForeground now. return") + return START_NOT_STICKY + } + if (keycode == -1 && playable != null && status == PlayerStatus.PLAYING && mPlayer?.prevMedia?.getIdentifier() == playable.getIdentifier()) { +// pause button on notification also calls onStartCommand + Logd(TAG, "onStartCommand playing same media: $status, return") + return super.onStartCommand(intent, flags, startId) + } + + when { + keycode != -1 -> { + Logd(TAG, "onStartCommand Received hardware button event: $hardwareButton") + val handled = handleKeycode(keycode, !hardwareButton) + return super.onStartCommand(intent, flags, startId) } + playable != null -> { + Logd(TAG, "onStartCommand status: $status") + val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false + val allowStreamAlways = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_ALWAYS, false) ?: false + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0) + if (allowStreamAlways) isAllowMobileStreaming = true + startPlaying(allowStreamThisTime) +// return super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY + } + else -> Logd(TAG, "onStartCommand case when not (keycode != -1 and playable != null)") } // return super.onStartCommand(intent, flags, startId) return START_NOT_STICKY @@ -484,10 +728,10 @@ class PlaybackService : MediaSessionService() { val skipIntro = preferences?.introSkip ?: 0 val skipIntroMS = skipIntro * 1000 if (skipIntro > 0 && playable.getPosition() < skipIntroMS) { - val duration = duration + val duration = curDuration if (skipIntroMS < duration || duration <= 0) { Logd(TAG, "skipIntro " + playable.getEpisodeTitle()) - mediaPlayer?.seekTo(skipIntroMS) + mPlayer?.seekTo(skipIntroMS) val skipIntroMesg = applicationContext.getString(R.string.pref_feed_skip_intro_toast, skipIntro) val toast = Toast.makeText(applicationContext, skipIntroMesg, Toast.LENGTH_LONG) toast.show() @@ -508,18 +752,18 @@ class PlaybackService : MediaSessionService() { intentAllowThisTime.putExtra(EXTRA_ALLOW_STREAM_THIS_TIME, true) val pendingIntentAllowThisTime = if (Build.VERSION.SDK_INT >= VERSION_CODES.O) PendingIntent.getForegroundService(this, R.id.pending_intent_allow_stream_this_time, - intentAllowThisTime, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) + intentAllowThisTime, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) else PendingIntent.getService(this, R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, - PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) val intentAlwaysAllow = Intent(intentAllowThisTime) intentAlwaysAllow.setAction(EXTRA_ALLOW_STREAM_ALWAYS) intentAlwaysAllow.putExtra(EXTRA_ALLOW_STREAM_ALWAYS, true) val pendingIntentAlwaysAllow = if (Build.VERSION.SDK_INT >= VERSION_CODES.O) PendingIntent.getForegroundService(this, R.id.pending_intent_allow_stream_always, - intentAlwaysAllow, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) + intentAlwaysAllow, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) else PendingIntent.getService(this, R.id.pending_intent_allow_stream_always, intentAlwaysAllow, - PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) val builder = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_ID_USER_ACTION) .setSmallIcon(R.drawable.ic_notification_stream) @@ -541,17 +785,17 @@ class PlaybackService : MediaSessionService() { */ private fun handleKeycode(keycode: Int, notificationButton: Boolean): Boolean { Logd(TAG, "Handling keycode: $keycode") - val info = mediaPlayer?.playerInfo + val info = mPlayer?.playerInfo val status = info?.playerStatus when (keycode) { KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { when { - status == PlayerStatus.PLAYING -> mediaPlayer?.pause(!isPersistNotify, false) - status == PlayerStatus.FALLBACK || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> mediaPlayer?.resume() - status == PlayerStatus.PREPARING -> mediaPlayer?.startWhenPrepared?.set(!mediaPlayer!!.startWhenPrepared.get()) + status == PlayerStatus.PLAYING -> mPlayer?.pause(!isPersistNotify, false) + status == PlayerStatus.FALLBACK || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> mPlayer?.resume() + status == PlayerStatus.PREPARING -> mPlayer?.startWhenPrepared?.set(!mPlayer!!.startWhenPrepared.get()) status == PlayerStatus.INITIALIZED -> { - mediaPlayer?.startWhenPrepared?.set(true) - mediaPlayer?.prepare() + mPlayer?.startWhenPrepared?.set(true) + mPlayer?.prepare() } curMedia == null -> startPlayingFromPreferences() else -> return false @@ -561,10 +805,10 @@ class PlaybackService : MediaSessionService() { } KeyEvent.KEYCODE_MEDIA_PLAY -> { when { - status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> mediaPlayer?.resume() + status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> mPlayer?.resume() status == PlayerStatus.INITIALIZED -> { - mediaPlayer?.startWhenPrepared?.set(true) - mediaPlayer?.prepare() + mPlayer?.startWhenPrepared?.set(true) + mPlayer?.prepare() } curMedia == null -> startPlayingFromPreferences() else -> return false @@ -574,7 +818,7 @@ class PlaybackService : MediaSessionService() { } KeyEvent.KEYCODE_MEDIA_PAUSE -> { if (status == PlayerStatus.PLAYING) { - mediaPlayer?.pause(!isPersistNotify, false) + mPlayer?.pause(!isPersistNotify, false) return true } } @@ -582,15 +826,15 @@ class PlaybackService : MediaSessionService() { when { // Handle remapped button as notification button which is not remapped again. !notificationButton -> return handleKeycode(hardwareForwardButton, true) - this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED -> { - mediaPlayer?.skip() + status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED -> { + mPlayer?.skip() return true } } } KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - if (this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) { - mediaPlayer?.seekDelta(fastForwardSecs * 1000) + if (status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED) { + mPlayer?.seekDelta(fastForwardSecs * 1000) return true } } @@ -598,21 +842,20 @@ class PlaybackService : MediaSessionService() { when { // Handle remapped button as notification button which is not remapped again. !notificationButton -> return handleKeycode(hardwarePreviousButton, true) - this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED -> { - mediaPlayer?.seekTo(0) + status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED -> { + mPlayer?.seekTo(0) return true } } } KeyEvent.KEYCODE_MEDIA_REWIND -> { - if (this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) { - mediaPlayer?.seekDelta(-rewindSecs * 1000) + if (status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED) { + mPlayer?.seekDelta(-rewindSecs * 1000) return true } } KeyEvent.KEYCODE_MEDIA_STOP -> { - if (this.status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING) - mediaPlayer?.pause(abandonFocus = true, reinit = true) + if (status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING) mPlayer?.pause(abandonFocus = true, reinit = true) return true } else -> { @@ -640,6 +883,7 @@ class PlaybackService : MediaSessionService() { } private fun startPlaying(allowStreamThisTime: Boolean) { + Logd(TAG, "startPlaying called $allowStreamThisTime") val media = curMedia ?: return val localFeed = URLUtil.isContentUrl(media.getStreamUrl()) @@ -652,31 +896,10 @@ class PlaybackService : MediaSessionService() { if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed() - mediaPlayer?.playMediaObject(media, stream, startWhenPrepared = true, true) + mPlayer?.playMediaObject(media, stream, startWhenPrepared = true, true) recreateMediaSessionIfNeeded() val episode = (media as? EpisodeMedia)?.episode - if (playable is EpisodeMedia && episode != null) addToQueue(true, episode) - } - - private val taskManagerCallback: PSTMCallback = object : PSTMCallback { - override fun positionSaverTick() { - if (currentPosition != previousPosition) { -// Log.d(TAG, "positionSaverTick currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed") - if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(currentPosition, duration)) - skipEndingIfNecessary() - saveCurrentPosition(true, null, Playable.INVALID_TIME) - previousPosition = currentPosition - } - } - - override fun requestWidgetState(): WidgetState { - return WidgetState(this@PlaybackService.playable, this@PlaybackService.status, - this@PlaybackService.currentPosition, this@PlaybackService.duration, this@PlaybackService.currentPlaybackSpeed) - } - - override fun onChapterLoaded(media: Playable?) { - sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0) - } + if (curMedia is EpisodeMedia && episode != null) addToQueue(true, episode) } fun clearCurTempSpeed() { @@ -684,246 +907,6 @@ class PlaybackService : MediaSessionService() { upsertBlk(curState) {} } - private val mediaPlayerCallback: MediaPlayerCallback = object : MediaPlayerCallback { - override fun statusChanged(newInfo: MediaPlayerInfo?) { - currentMediaType = mediaPlayer?.mediaType ?: MediaType.UNKNOWN - Logd(TAG, "statusChanged called ${newInfo?.playerStatus}") - if (newInfo != null) { - when (newInfo.playerStatus) { - PlayerStatus.INITIALIZED -> - if (mediaPlayer != null) writeMediaPlaying(mediaPlayer!!.playerInfo.playable, mediaPlayer!!.playerInfo.playerStatus, currentitem) - PlayerStatus.PREPARED -> { - if (mediaPlayer != null) writeMediaPlaying(mediaPlayer!!.playerInfo.playable, mediaPlayer!!.playerInfo.playerStatus, currentitem) - taskManager.startChapterLoader(newInfo.playable!!) - } - PlayerStatus.PAUSED -> writePlayerStatus(MediaPlayerBase.status) - PlayerStatus.STOPPED -> {} - PlayerStatus.PLAYING -> { - writePlayerStatus(MediaPlayerBase.status) - - saveCurrentPosition(true, null, Playable.INVALID_TIME) - recreateMediaSessionIfNeeded() - // set sleep timer if auto-enabled - var autoEnableByTime = true - val fromSetting = autoEnableFrom() - val toSetting = autoEnableTo() - if (fromSetting != toSetting) { - val now: Calendar = GregorianCalendar() - now.timeInMillis = System.currentTimeMillis() - val currentHour = now[Calendar.HOUR_OF_DAY] - autoEnableByTime = isInTimeRange(fromSetting, toSetting, currentHour) - } - - if (newInfo.oldPlayerStatus != null && newInfo.oldPlayerStatus != PlayerStatus.SEEKING && autoEnable() && autoEnableByTime && !taskManager.isSleepTimerActive) { -// setSleepTimer(timerMillis()) - taskManager.setSleepTimer(timerMillis()) - EventFlow.postEvent(FlowEvent.MessageEvent( - getString(R.string.sleep_timer_enabled_label), { taskManager.disableSleepTimer() }, getString(R.string.undo))) - } - } - PlayerStatus.ERROR -> writeNoMediaPlaying() - else -> {} - } - } - if (Build.VERSION.SDK_INT >= VERSION_CODES.N) - TileService.requestListeningState(applicationContext, ComponentName(applicationContext, QuickSettingsTileService::class.java)) - - sendLocalBroadcast(applicationContext, ACTION_PLAYER_STATUS_CHANGED) - bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED) - bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED) - taskManager.requestWidgetUpdate() - } - - override fun onMediaChanged(reloadUI: Boolean) { - Logd(TAG, "reloadUI callback reached") - if (reloadUI) sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0) - } - - override fun onPostPlayback(playable: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean) { - if (playable == null) { - Log.e(TAG, "Cannot do post-playback processing: media was null") - return - } - Logd(TAG, "onPostPlayback(): ended=$ended skipped=$skipped playingNext=$playingNext media=${playable.getEpisodeTitle()} ") - - if (playable !is EpisodeMedia) { - Logd(TAG, "Not doing post-playback processing: media not of type EpisodeMedia") - if (ended) playable.onPlaybackCompleted(applicationContext) - else playable.onPlaybackPause(applicationContext) -// TODO: test -// return - } - val media = playable - val item = (media as? EpisodeMedia)?.episode ?: currentitem - val smartMarkAsPlayed = hasAlmostEnded(media) - if (!ended && smartMarkAsPlayed) Logd(TAG, "smart mark as played") - - var autoSkipped = false - if (autoSkippedFeedMediaId != null && autoSkippedFeedMediaId == item?.identifyingValue) { - autoSkippedFeedMediaId = null - autoSkipped = true - } - - if (media is EpisodeMedia) { - if (ended || smartMarkAsPlayed) { - SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, media, true) - media.onPlaybackCompleted(applicationContext) - } else { - SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, media, false) - media.onPlaybackPause(applicationContext) - } - } - - 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 (media is EpisodeMedia && shouldAutoDelete && (!item.isFavorite || !shouldFavoriteKeepEpisode())) { - deleteMediaOfEpisode(this@PlaybackService, item) - Logd(TAG, "Episode Deleted") - } - } - - if (media is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item) - } - } - - override fun onPlaybackStart(playable: Playable, position: Int) { - Logd(TAG, "onPlaybackStart position: $position") - taskManager.startWidgetUpdater() - if (position != Playable.INVALID_TIME) playable.setPosition(position) - else skipIntro(playable) - playable.onPlaybackStart() - taskManager.startPositionSaver() - } - - override fun onPlaybackPause(playable: Playable?, position: Int) { - Logd(TAG, "onPlaybackPause $position") - taskManager.cancelPositionSaver() - saveCurrentPosition(position == Playable.INVALID_TIME || playable == null, playable, position) - taskManager.cancelWidgetUpdater() - if (playable != null) { - if (playable is EpisodeMedia) SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, playable, false) - playable.onPlaybackPause(applicationContext) - } - } - - override fun getNextInQueue(currentMedia: Playable?): Playable? { - Logd(TAG, "call getNextInQueue currentMedia: ${currentMedia?.getEpisodeTitle()}") - if (currentMedia !is EpisodeMedia) { - Logd(TAG, "getNextInQueue(), but playable not an instance of EpisodeMedia, so not proceeding") - writeNoMediaPlaying() - return null - } - val item = currentMedia.episode - if (item == null) { - Log.w(TAG, "getNextInQueue() with EpisodeMedia object whose FeedItem is null") - writeNoMediaPlaying() - return null - } - val nextItem = getNextInQueue(item) - if (nextItem?.media == null) { - Logd(TAG, "getNextInQueue nextItem: $nextItem media is null") - writeNoMediaPlaying() - return null - } - - if (!isFollowQueue) { - Logd(TAG, "getNextInQueue(), but follow queue is not enabled.") - writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED, currentitem) - return null - } - - if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed != null && !nextItem.feed!!.isLocalFeed) { - displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent) - writeNoMediaPlaying() - return null - } - EventFlow.postEvent(FlowEvent.PlayEvent(item, FlowEvent.PlayEvent.Action.END)) - EventFlow.postEvent(FlowEvent.PlayEvent(nextItem)) - return nextItem.media - } - - private fun getNextInQueue(episode: Episode): Episode? { - Logd(TAG, "getNextInQueue() with: itemId ${episode.id}") - if (curQueue.episodes.isEmpty()) return null - - val i = curQueue.episodes.indexOf(episode) - var j = 0 - if (i >= 0 && i < curQueue.episodes.size-1) j = i+1 - - val itemNew = curQueue.episodes[j] - return if (itemNew.isManaged()) realm.copyFromRealm(itemNew) else itemNew - } - - override fun findMedia(url: String): Playable? { - val item = getEpisodeByGuidOrUrl(null, url) - return item?.media - } - - override fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean) { - Logd(TAG, "onPlaybackEnded mediaType: $mediaType stopPlaying: $stopPlaying") - clearCurTempSpeed() - if (stopPlaying) taskManager.cancelPositionSaver() - - if (mediaType == null) sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0) - else { - sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, - when { - isCasting -> EXTRA_CODE_CAST - mediaType == MediaType.VIDEO -> EXTRA_CODE_VIDEO - else -> EXTRA_CODE_AUDIO - }) - } - } - - override fun ensureMediaInfoLoaded(media: Playable) { -// if (media is EpisodeMedia && media.item == null) media.item = DBReader.getFeedItem(media.itemId) - } - - fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus, item: Episode? = null) { - Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}") - if (playable == null) { - writeNoMediaPlaying() - } else { - curState.curMediaType = playable.getPlayableType().toLong() - curState.curIsVideo = playable.getMediaType() == MediaType.VIDEO - if (playable is EpisodeMedia) { - val feedId = playable.episode?.feed?.id - if (feedId != null) curState.curFeedId = feedId - curState.curMediaId = playable.id - } else { - curState.curFeedId = NO_MEDIA_PLAYING - curState.curMediaId = NO_MEDIA_PLAYING - } - } - curState.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus) - upsertBlk(curState) {} - } - - fun writePlayerStatus(playerStatus: PlayerStatus) { - Logd(InTheatre.TAG, "Writing player status playback preferences") - curState.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus) - upsertBlk(curState) {} - } - - private fun getCurPlayerStatusAsInt(playerStatus: PlayerStatus): Int { - val playerStatusAsInt = when (playerStatus) { - PlayerStatus.PLAYING -> PLAYER_STATUS_PLAYING - PlayerStatus.PAUSED -> PLAYER_STATUS_PAUSED - else -> PLAYER_STATUS_OTHER - } - return playerStatusAsInt - } - } - private var eventSink: Job? = null private fun cancelFlowEvents() { eventSink?.cancel() @@ -938,7 +921,7 @@ class PlaybackService : MediaSessionService() { is FlowEvent.PlayerErrorEvent -> onPlayerError(event) is FlowEvent.BufferUpdateEvent -> onBufferUpdate(event) is FlowEvent.SleepTimerUpdatedEvent -> onSleepTimerUpdate(event) - is FlowEvent.VolumeAdaptionChangedEvent -> onVolumeAdaptionChanged(event) +// is FlowEvent.VolumeAdaptionChangedEvent -> onVolumeAdaptionChanged(event) is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChanged(event) // is FlowEvent.SkipIntroEndingChangedEvent -> skipIntroEndingPresetChanged(event) is FlowEvent.PlayEvent -> currentitem = event.episode @@ -950,14 +933,14 @@ class PlaybackService : MediaSessionService() { private fun onPlayerError(event: FlowEvent.PlayerErrorEvent) { if (MediaPlayerBase.status == PlayerStatus.PLAYING || MediaPlayerBase.status == PlayerStatus.FALLBACK) - mediaPlayer!!.pause(abandonFocus = true, reinit = false) + mPlayer!!.pause(abandonFocus = true, reinit = false) } private fun onBufferUpdate(event: FlowEvent.BufferUpdateEvent) { if (event.hasEnded()) { - if (playable is EpisodeMedia && playable!!.getDuration() <= 0 && (mediaPlayer?.getDuration()?:0) > 0) { + if (curMedia is EpisodeMedia && curMedia!!.getDuration() <= 0 && (mPlayer?.getDuration()?:0) > 0) { // Playable is being streamed and does not have a duration specified in the feed - playable!!.setDuration(mediaPlayer!!.getDuration()) + curMedia!!.setDuration(mPlayer!!.getDuration()) // DBWriter.persistEpisodeMedia(playable as EpisodeMedia) } } @@ -966,16 +949,16 @@ class PlaybackService : MediaSessionService() { private fun onSleepTimerUpdate(event: FlowEvent.SleepTimerUpdatedEvent) { when { event.isOver -> { - mediaPlayer?.pause(abandonFocus = true, reinit = true) - mediaPlayer?.setVolume(1.0f, 1.0f) + mPlayer?.pause(abandonFocus = true, reinit = true) + mPlayer?.setVolume(1.0f, 1.0f) } event.getTimeLeft() < TaskManager.NOTIFICATION_THRESHOLD -> { val multiplicators = floatArrayOf(0.1f, 0.2f, 0.3f, 0.3f, 0.3f, 0.4f, 0.4f, 0.4f, 0.6f, 0.8f) val multiplicator = multiplicators[max(0.0, (event.getTimeLeft().toInt() / 1000).toDouble()).toInt()] Logd(TAG, "onSleepTimerAlmostExpired: $multiplicator") - mediaPlayer?.setVolume(multiplicator, multiplicator) + mPlayer?.setVolume(multiplicator, multiplicator) } - event.isCancelled -> mediaPlayer?.setVolume(1.0f, 1.0f) + event.isCancelled -> mPlayer?.setVolume(1.0f, 1.0f) } } @@ -989,27 +972,20 @@ class PlaybackService : MediaSessionService() { } private fun skipEndingIfNecessary() { - val playable = curMedia as? EpisodeMedia + val remainingTime = curDuration - curPosition + val item = (curMedia as? EpisodeMedia)?.episode ?: currentitem ?: return - val duration = duration - val remainingTime = duration - currentPosition - - val item = playable?.episode ?: currentitem ?: return - val feed = item.feed - val preferences = feed?.preferences - - val skipEnd = preferences?.endingSkip?:0 + val skipEnd = item.feed?.preferences?.endingSkip?:0 val skipEndMS = skipEnd * 1000 // Log.d(TAG, "skipEndingIfNecessary: checking " + remainingTime + " " + skipEndMS + " speed " + currentPlaybackSpeed) - if (skipEnd > 0 && skipEndMS < this.duration && (remainingTime - skipEndMS < 0)) { - Logd(TAG, "skipEndingIfNecessary: Skipping the remaining $remainingTime $skipEndMS speed $currentPlaybackSpeed") + if (skipEnd > 0 && skipEndMS < curDuration && (remainingTime - skipEndMS < 0)) { + Logd(TAG, "skipEndingIfNecessary: Skipping the remaining $remainingTime $skipEndMS speed $curSpeed") val context = applicationContext val skipMesg = context.getString(R.string.pref_feed_skip_ending_toast, skipEnd) val toast = Toast.makeText(context, skipMesg, Toast.LENGTH_LONG) toast.show() - - this.autoSkippedFeedMediaId = item.identifyingValue - mediaPlayer?.skip() + autoSkippedFeedMediaId = item.identifyingValue + mPlayer?.skip() } } @@ -1017,37 +993,33 @@ class PlaybackService : MediaSessionService() { private fun saveCurrentPosition(fromMediaPlayer: Boolean, playable: Playable?, position: Int) { var playable = playable var position = position - val duration: Int + val duration_: Int if (fromMediaPlayer) { - position = currentPosition - duration = this.duration + position = curPosition + duration_ = this.curDuration playable = curMedia - } else duration = playable?.getDuration() ?: Playable.INVALID_TIME + } else duration_ = playable?.getDuration() ?: Playable.INVALID_TIME - if (position != Playable.INVALID_TIME && duration != Playable.INVALID_TIME && playable != null) { + if (position != Playable.INVALID_TIME && duration_ != Playable.INVALID_TIME && playable != null) { // Log.d(TAG, "Saving current position to $position $duration") playable.setPosition(position) playable.setLastPlayedTime(System.currentTimeMillis()) if (playable is EpisodeMedia) { val item = playable.episode - if (item != null && item.isNew) { - item.playState = Episode.UNPLAYED - } + if (item != null && item.isNew) item.playState = Episode.UNPLAYED if (playable.startPosition >= 0 && playable.getPosition() > playable.startPosition) playable.playedDuration = (playable.playedDurationWhenStarted + playable.getPosition() - playable.startPosition) persistEpisode(item, true) } - previousPosition = position + prevPosition = position } } private fun bluetoothNotifyChange(info: MediaPlayerInfo?, whatChanged: String) { Logd(TAG, "bluetoothNotifyChange $whatChanged") var isPlaying = false - if (info?.playerStatus == PlayerStatus.PLAYING || info?.playerStatus == PlayerStatus.FALLBACK) isPlaying = true - if (info?.playable != null) { val i = Intent(whatChanged) i.putExtra("id", 1L) @@ -1061,20 +1033,17 @@ class PlaybackService : MediaSessionService() { } } - /** - * Pauses playback if PREF_PAUSE_ON_HEADSET_DISCONNECT was set to true. - */ private fun pauseIfPauseOnDisconnect() { Logd(TAG, "pauseIfPauseOnDisconnect()") transientPause = (MediaPlayerBase.status == PlayerStatus.PLAYING || MediaPlayerBase.status == PlayerStatus.FALLBACK) - if (isPauseOnHeadsetDisconnect && !isCasting) mediaPlayer?.pause(!isPersistNotify, false) + if (isPauseOnHeadsetDisconnect && !isCasting) mPlayer?.pause(!isPersistNotify, false) } /** * @param bluetooth true if the event for unpausing came from bluetooth */ private fun unpauseIfPauseOnDisconnect(bluetooth: Boolean) { - if (mediaPlayer != null && mediaPlayer!!.isAudioChannelInUse) { + if (mPlayer != null && mPlayer!!.isAudioChannelInUse) { Logd(TAG, "unpauseIfPauseOnDisconnect() audio is in use") return } @@ -1082,56 +1051,30 @@ class PlaybackService : MediaSessionService() { transientPause = false if (Build.VERSION.SDK_INT >= 31) return when { - !bluetooth && isUnpauseOnHeadsetReconnect -> mediaPlayer?.resume() + !bluetooth && isUnpauseOnHeadsetReconnect -> mPlayer?.resume() bluetooth && isUnpauseOnBluetoothReconnect -> { // let the user know we've started playback again... val v = applicationContext.getSystemService(VIBRATOR_SERVICE) as? Vibrator v?.vibrate(500) - mediaPlayer?.resume() + mPlayer?.resume() } } } } - private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == ACTION_SHUTDOWN_PLAYBACK_SERVICE) - EventFlow.postEvent(FlowEvent.PlaybackServiceEvent(FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)) - } - } - - private fun onVolumeAdaptionChanged(event: FlowEvent.VolumeAdaptionChangedEvent) { - val volumeUpdater = VolumeUpdater() - if (mediaPlayer != null) volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, event.feedId, event.volumeAdaptionSetting) - } +// private fun onVolumeAdaptionChanged(event: FlowEvent.VolumeAdaptionChangedEvent) { +// if (mPlayer != null) updateVolumeIfNecessary(mPlayer!!, event.feedId, event.volumeAdaptionSetting) +// } private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) { - val item = (playable as? EpisodeMedia)?.episode ?: currentitem - if (item?.feed?.id == event.prefs.feedID) { - item.feed = unmanagedCopy(item.feed!!) - item.feed!!.preferences = event.prefs - } - } - - class VolumeUpdater { - fun updateVolumeIfNecessary(mediaPlayer: MediaPlayerBase, feedId: Long, volumeAdaptionSetting: VolumeAdaptionSetting) { - val playable = curMedia - if (playable is EpisodeMedia) updateFeedMediaVolumeIfNecessary(mediaPlayer, feedId, volumeAdaptionSetting, playable) - } - - private fun updateFeedMediaVolumeIfNecessary(mediaPlayer: MediaPlayerBase, feedId: Long, volumeAdaptionSetting: VolumeAdaptionSetting, episodeMedia: EpisodeMedia) { - if (episodeMedia.episode?.feed?.id == feedId) { - val preferences = episodeMedia.episode!!.feed!!.preferences - if (preferences != null) preferences.volumeAdaptionSetting = volumeAdaptionSetting - - if (MediaPlayerBase.status == PlayerStatus.PLAYING) forceUpdateVolume(mediaPlayer) + val item = (curMedia as? EpisodeMedia)?.episode ?: currentitem + if (item?.feed?.id == event.feed.id) { + item.feed = null + if (MediaPlayerBase.status == PlayerStatus.PLAYING) { + mPlayer?.pause(abandonFocus = false, reinit = false) + mPlayer?.resume() } } - - private fun forceUpdateVolume(mediaPlayer: MediaPlayerBase) { - mediaPlayer.pause(abandonFocus = false, reinit = false) - mediaPlayer.resume() - } } enum class NotificationCustomButton(val customAction: String, val commandButton: CommandButton) { @@ -1232,28 +1175,17 @@ class PlaybackService : MediaSessionService() { var currentMediaType: MediaType? = MediaType.UNKNOWN private set - /** - * Returns an intent which starts an audio- or videoplayer, depending on the - * type of media that is being played. If the playbackservice is not - * running, the type of the last played media will be looked up. - */ - @JvmStatic - fun getPlayerActivityIntent(context: Context): Intent { - val showVideoPlayer = if (isRunning) currentMediaType == MediaType.VIDEO && !isCasting - else curState.curIsVideo - - return if (showVideoPlayer) VideoPlayerActivityStarter(context).intent - else MainActivityStarter(context).withOpenPlayer().getIntent() - } - - /** - * Same as [.getPlayerActivityIntent], but here the type of activity - * depends on the medaitype that is provided as an argument. - */ - @JvmStatic - fun getPlayerActivityIntent(context: Context, mediaType: MediaType?): Intent { - return if (mediaType == MediaType.VIDEO && !isCasting) VideoPlayerActivityStarter(context).intent - else MainActivityStarter(context).withOpenPlayer().getIntent() + fun updateVolumeIfNecessary(mediaPlayer: MediaPlayerBase, feedId: Long, volumeAdaptionSetting: VolumeAdaptionSetting) { + val playable = curMedia + if (playable is EpisodeMedia) { + if (playable.episode?.feed?.id == feedId) { + playable.episode!!.feed!!.preferences?.volumeAdaptionSetting = volumeAdaptionSetting + if (MediaPlayerBase.status == PlayerStatus.PLAYING) { + mediaPlayer.pause(abandonFocus = false, reinit = false) + mediaPlayer.resume() + } + } + } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/QuickSettingsTileService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/QuickSettingsTileService.kt index 89582807..b7ae8031 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/QuickSettingsTileService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/QuickSettingsTileService.kt @@ -42,11 +42,10 @@ class QuickSettingsTileService : TileService() { return super.onBind(intent) } - fun updateTile() { + private fun updateTile() { val qsTile = qsTile if (qsTile == null) Logd(TAG, "Ignored call to update QS tile: getQsTile() returned null.") else { -// val isPlaying = PlaybackService.isRunning && MediaPlayerBase.status == PlayerStatus.PLAYING val isPlaying = (PlaybackService.isRunning && curState.curPlayerStatus == PLAYER_STATUS_PLAYING) qsTile.state = if (isPlaying) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE qsTile.updateTile() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt index b377cbd7..d3b8acda 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt @@ -351,7 +351,7 @@ class TaskManager(private val context: Context, private val callback: PSTMCallba /** * Notification interval of widget updater in milliseconds. */ - const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 1000 + const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 private const val SCHED_EX_POOL_SIZE = 2 private const val UPDATE_INTERVAL = 1000L const val NOTIFICATION_THRESHOLD: Long = 10000 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWriter.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWriter.kt new file mode 100644 index 00000000..ac9d5312 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/ExportWriter.kt @@ -0,0 +1,13 @@ +package ac.mdiq.podcini.preferences + +import ac.mdiq.podcini.storage.model.Feed +import android.content.Context +import java.io.IOException +import java.io.Writer + +interface ExportWriter { + @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) + fun writeDocument(feeds: List?, writer: Writer?, context: Context) + + fun fileExtension(): String? +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/backup/OpmlBackupAgent.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlBackupAgent.kt similarity index 96% rename from app/src/main/kotlin/ac/mdiq/podcini/storage/backup/OpmlBackupAgent.kt rename to app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlBackupAgent.kt index f86e37b2..f3ce4cf0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/backup/OpmlBackupAgent.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlBackupAgent.kt @@ -1,12 +1,12 @@ -package ac.mdiq.podcini.storage.backup +package ac.mdiq.podcini.preferences import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce import ac.mdiq.podcini.preferences.UserPreferences.isAutoBackupOPML import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.transport.OpmlReader -import ac.mdiq.podcini.storage.transport.OpmlWriter +import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlReader +import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter import ac.mdiq.podcini.util.Logd import android.app.backup.BackupAgentHelper import android.app.backup.BackupDataInputStream @@ -45,7 +45,7 @@ class OpmlBackupAgent : BackupAgentHelper() { */ private var mChecksum: ByteArray = byteArrayOf() - override fun performBackup(oldState: ParcelFileDescriptor, data: BackupDataOutput, newState: ParcelFileDescriptor) { + override fun performBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor) { Logd(TAG, "Performing backup") val byteStream = ByteArrayOutputStream() var digester: MessageDigest? = null @@ -66,17 +66,14 @@ class OpmlBackupAgent : BackupAgentHelper() { if (digester != null) { val newChecksum = digester.digest() Logd(TAG, "New checksum: " + BigInteger(1, newChecksum).toString(16)) - // Get the old checksum if (oldState != null) { val inState = FileInputStream(oldState.fileDescriptor) val len = inState.read() - if (len != -1) { val oldChecksum = ByteArray(len) IOUtils.read(inState, oldChecksum, 0, len) Logd(TAG, "Old checksum: " + BigInteger(1, oldChecksum).toString(16)) - if (oldChecksum.contentEquals(newChecksum)) { Logd(TAG, "Checksums are the same; won't backup") return @@ -99,22 +96,18 @@ class OpmlBackupAgent : BackupAgentHelper() { @OptIn(UnstableApi::class) override fun restoreEntity(data: BackupDataInputStream) { Logd(TAG, "Backup restore") - if (OPML_ENTITY_KEY != data.key) { Logd(TAG, "Unknown entity key: " + data.key) return } - var digester: MessageDigest? = null var reader: Reader - try { digester = MessageDigest.getInstance("MD5") reader = InputStreamReader(DigestInputStream(data, digester), Charset.forName("UTF-8")) } catch (e: NoSuchAlgorithmException) { reader = InputStreamReader(data, Charset.forName("UTF-8")) } - try { val opmlElements = OpmlReader().readDocument(reader) mChecksum = digester?.digest()?: byteArrayOf() @@ -139,7 +132,6 @@ class OpmlBackupAgent : BackupAgentHelper() { /** * Writes the new state description, which is the checksum of the OPML file. - * * @param newState * @param checksum */ diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlTransporter.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlTransporter.kt new file mode 100644 index 00000000..78f570dd --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/OpmlTransporter.kt @@ -0,0 +1,153 @@ +package ac.mdiq.podcini.preferences + +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.util.DateFormatter.formatRfc822Date +import ac.mdiq.podcini.util.Logd +import android.content.Context +import android.util.Log +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import org.xmlpull.v1.XmlPullParserFactory +import java.io.IOException +import java.io.Reader +import java.io.Writer +import java.util.* + +class OpmlTransporter { + + /** Represents a single feed in an OPML file. */ + class OpmlElement { + @JvmField + var text: String? = null + var xmlUrl: String? = null + var htmlUrl: String? = null + var type: String? = null + } + + /** Contains symbols for reading and writing OPML documents. */ + private object OpmlSymbols { + const val OPML: String = "opml" + const val OUTLINE: String = "outline" + const val TEXT: String = "text" + const val XMLURL: String = "xmlUrl" + const val HTMLURL: String = "htmlUrl" + const val TYPE: String = "type" + const val VERSION: String = "version" + const val DATE_CREATED: String = "dateCreated" + const val HEAD: String = "head" + const val BODY: String = "body" + const val TITLE: String = "title" + const val XML_FEATURE_INDENT_OUTPUT: String = "http://xmlpull.org/v1/doc/features.html#indent-output" + } + + /** Writes OPML documents. */ + class OpmlWriter : ExportWriter { + /** + * Takes a list of feeds and a writer and writes those into an OPML + * document. + */ + @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) + override fun writeDocument(feeds: List?, writer: Writer?, context: Context) { + val xs = Xml.newSerializer() + xs.setFeature(OpmlSymbols.XML_FEATURE_INDENT_OUTPUT, true) + xs.setOutput(writer) + + xs.startDocument(ENCODING, false) + xs.startTag(null, OpmlSymbols.OPML) + xs.attribute(null, OpmlSymbols.VERSION, OPML_VERSION) + + xs.startTag(null, OpmlSymbols.HEAD) + xs.startTag(null, OpmlSymbols.TITLE) + xs.text(OPML_TITLE) + xs.endTag(null, OpmlSymbols.TITLE) + xs.startTag(null, OpmlSymbols.DATE_CREATED) + xs.text(formatRfc822Date(Date())) + xs.endTag(null, OpmlSymbols.DATE_CREATED) + xs.endTag(null, OpmlSymbols.HEAD) + + xs.startTag(null, OpmlSymbols.BODY) + for (feed in feeds!!) { + xs.startTag(null, OpmlSymbols.OUTLINE) + xs.attribute(null, OpmlSymbols.TEXT, feed!!.title) + xs.attribute(null, OpmlSymbols.TITLE, feed.title) + if (feed.type != null) xs.attribute(null, OpmlSymbols.TYPE, feed.type) + xs.attribute(null, OpmlSymbols.XMLURL, feed.downloadUrl) + if (feed.link != null) xs.attribute(null, OpmlSymbols.HTMLURL, feed.link) + xs.endTag(null, OpmlSymbols.OUTLINE) + } + xs.endTag(null, OpmlSymbols.BODY) + xs.endTag(null, OpmlSymbols.OPML) + xs.endDocument() + } + + override fun fileExtension(): String { + return "opml" + } + + companion object { + private val TAG: String = OpmlWriter::class.simpleName ?: "Anonymous" + private const val ENCODING = "UTF-8" + private const val OPML_VERSION = "2.0" + private const val OPML_TITLE = "Podcini Subscriptions" + } + } + + /** Reads OPML documents. */ + class OpmlReader { + // ATTRIBUTES + private var isInOpml = false + private var elementList: ArrayList? = null + + /** + * Reads an Opml document and returns a list of all OPML elements it can find + * @throws IOException + * @throws XmlPullParserException + */ + @Throws(XmlPullParserException::class, IOException::class) + fun readDocument(reader: Reader?): ArrayList { + elementList = ArrayList() + val factory = XmlPullParserFactory.newInstance() + factory.isNamespaceAware = true + val xpp = factory.newPullParser() + xpp.setInput(reader) + var eventType = xpp.eventType + + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_DOCUMENT -> Logd(TAG, "Reached beginning of document") + XmlPullParser.START_TAG -> when { + xpp.name == OpmlSymbols.OPML -> { + isInOpml = true + Logd(TAG, "Reached beginning of OPML tree.") + } + isInOpml && xpp.name == OpmlSymbols.OUTLINE -> { +// TODO: check more about this, java.io.IOException: Underlying input stream returned zero bytes + val element = OpmlElement() + element.text = xpp.getAttributeValue(null, OpmlSymbols.TITLE) ?: xpp.getAttributeValue(null, OpmlSymbols.TEXT) + element.xmlUrl = xpp.getAttributeValue(null, OpmlSymbols.XMLURL) + element.htmlUrl = xpp.getAttributeValue(null, OpmlSymbols.HTMLURL) + element.type = xpp.getAttributeValue(null, OpmlSymbols.TYPE) + if (element.xmlUrl != null) { + if (element.text == null) element.text = element.xmlUrl + elementList!!.add(element) + } else Logd(TAG, "Skipping element because of missing xml url") + } + } + } +// TODO: on first install app: java.io.IOException: Underlying input stream returned zero bytes + try { + eventType = xpp.next() + } catch(e: Exception) { + Log.e(TAG, "xpp.next() invalid: $e") + break + } + } + return elementList!! + } + + companion object { + private val TAG: String = OpmlReader::class.simpleName ?: "Anonymous" + } + } +} \ No newline at end of file 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 e75c905a..b128c3ea 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -37,6 +37,7 @@ object UserPreferences { const val PREF_TINTED_COLORS: String = "prefTintedColors" const val PREF_HIDDEN_DRAWER_ITEMS: String = "prefHiddenDrawerItems" const val PREF_DRAWER_FEED_ORDER: String = "prefDrawerFeedOrder" + const val PREF_FEED_GRID_LAYOUT: String = "prefFeedGridLayout" const val PREF_DRAWER_FEED_COUNTER: String = "prefDrawerFeedIndicator" const val PREF_EXPANDED_NOTIFICATION: String = "prefExpandNotify" private const val PREF_USE_EPISODE_COVER: String = "prefEpisodeCover" @@ -239,6 +240,9 @@ object UserPreferences { .apply() } + val useGridLayout: Boolean + get() = appPrefs.getBoolean(PREF_FEED_GRID_LAYOUT, false) + /** * @return `true` if episodes should use their own cover, `false` otherwise */ 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 81e38233..d83c6d13 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 @@ -1,20 +1,28 @@ package ac.mdiq.podcini.preferences.fragments +import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.PodciniApp.Companion.forceRestart import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid +import ac.mdiq.podcini.net.sync.model.EpisodeAction +import ac.mdiq.podcini.net.sync.model.EpisodeAction.Companion.readFromJsonObject +import ac.mdiq.podcini.net.sync.model.SyncServiceException +import ac.mdiq.podcini.preferences.ExportWriter import ac.mdiq.podcini.preferences.UserPreferences.getDataFolder +import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl +import ac.mdiq.podcini.storage.database.Episodes.getEpisodes import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.transport.DatabaseTransporter -import ac.mdiq.podcini.storage.transport.PreferencesTransporter -import ac.mdiq.podcini.storage.transport.ExportWriter -import ac.mdiq.podcini.storage.transport.EpisodeProgressReader -import ac.mdiq.podcini.storage.transport.EpisodesProgressWriter -import ac.mdiq.podcini.storage.transport.FavoritesWriter -import ac.mdiq.podcini.storage.transport.HtmlWriter -import ac.mdiq.podcini.storage.transport.OpmlWriter +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.preferences.OpmlTransporter.* +import ac.mdiq.podcini.storage.utils.EpisodeFilter +import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded +import ac.mdiq.podcini.storage.utils.SortOrder import ac.mdiq.podcini.ui.activity.OpmlImportActivity import ac.mdiq.podcini.ui.activity.PreferenceActivity +import ac.mdiq.podcini.util.Logd import android.app.Activity.RESULT_OK import android.app.ProgressDialog import android.content.ActivityNotFoundException @@ -23,16 +31,20 @@ import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.text.format.Formatter import android.util.Log import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.annotation.OptIn import androidx.annotation.StringRes import androidx.core.app.ShareCompat.IntentBuilder import androidx.core.content.FileProvider import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -40,7 +52,12 @@ import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils +import org.json.JSONArray import java.io.* +import java.nio.channels.FileChannel import java.nio.charset.Charset import java.text.SimpleDateFormat import java.util.* @@ -87,10 +104,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.import_export_pref) } -// override fun onStop() { -// super.onStop() -// } - private fun dateStampFilename(fname: String): String { return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) } @@ -136,7 +149,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { exportPreferences() true } - findPreference(PREF_FAVORITE_EXPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { openExportPathPicker(Export.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter()) true @@ -166,7 +178,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { try { val output = worker.exportFile() withContext(Dispatchers.Main) { - showExportSuccessSnackbar(output?.uri, exportType.contentType) + showExportSuccessSnackbar(output.uri, exportType.contentType) } } catch (e: Exception) { showExportErrorDialog(e) @@ -412,7 +424,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { } class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) { - suspend fun exportFile(): DocumentFile { return withContext(Dispatchers.IO) { val output = DocumentFile.fromSingleUri(context, outputFileUri) @@ -429,20 +440,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { } catch (e: IOException) { throw e } finally { - if (writer != null) { - try { - writer.close() - } catch (e: IOException) { - throw e - } - } - if (outputStream != null) { - try { - outputStream.close() - } catch (e: IOException) { - throw e - } - } + if (writer != null) try { writer.close() } catch (e: IOException) { throw e } + if (outputStream != null) try { outputStream.close() } catch (e: IOException) { throw e } } } } @@ -452,15 +451,13 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { * Writes an OPML file into the export directory in the background. */ class ExportWorker private constructor(private val exportWriter: ExportWriter, private val output: File, private val context: Context) { - constructor(exportWriter: ExportWriter, context: Context) : this(exportWriter, File(getDataFolder(EXPORT_DIR), DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context) - suspend fun exportFile(): File? { return withContext(Dispatchers.IO) { if (output.exists()) { val success = output.delete() - Log.w(TAG, "Overwriting previously exported file: $success") + Logd(TAG, "Overwriting previously exported file: $success") } var writer: OutputStreamWriter? = null @@ -476,7 +473,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { } } } - companion object { private const val EXPORT_DIR = "export/" private val TAG: String = ExportWorker::class.simpleName ?: "Anonymous" @@ -484,6 +480,397 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { } } + object PreferencesTransporter { + private val TAG: String = PreferencesTransporter::class.simpleName ?: "Anonymous" + @Throws(IOException::class) + fun exportToDocument(uri: Uri, context: Context) { + try { + val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid") + val exportSubDir = chosenDir.createDirectory("Podcini-Prefs") ?: throw IOException("Error creating subdirectory Podcini-Prefs") + val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> + file.name.startsWith("shared_prefs") + }?.firstOrNull() + if (sharedPreferencesDir != null) { + sharedPreferencesDir.listFiles()!!.forEach { file -> + val destFile = exportSubDir.createFile("text/xml", file.name) + if (destFile != null) copyFile(file, destFile, context) + } + } else { + Log.e("Error", "shared_prefs directory not found") + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + } + private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { + try { + val inputStream = FileInputStream(sourceFile) + val outputStream = context.contentResolver.openOutputStream(destFile.uri) + if (outputStream != null) copyStream(inputStream, outputStream) + inputStream.close() + outputStream?.close() + } catch (e: IOException) { + Log.e("Error", "Error copying file: $e") + throw e + } + } + private fun copyFile(sourceFile: DocumentFile, destFile: File, context: Context) { + try { + val inputStream = context.contentResolver.openInputStream(sourceFile.uri) + val outputStream = FileOutputStream(destFile) + if (inputStream != null) copyStream(inputStream, outputStream) + inputStream?.close() + outputStream.close() + } catch (e: IOException) { + Log.e("Error", "Error copying file: $e") + throw e + } + } + private fun copyStream(inputStream: InputStream, outputStream: OutputStream) { + val buffer = ByteArray(1024) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + @Throws(IOException::class) + fun importBackup(uri: Uri, context: Context) { + try { + val exportedDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Backup directory is not valid") + val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> + file.name.startsWith("shared_prefs") + }?.firstOrNull() + if (sharedPreferencesDir != null) { + sharedPreferencesDir.listFiles()?.forEach { file -> +// val prefName = file.name.substring(0, file.name.lastIndexOf('.')) + file.delete() + } + } else Log.e("Error", "shared_prefs directory not found") + val files = exportedDir.listFiles() + var hasPodciniRPrefs = false + for (file in files) { + if (file?.isFile == true && file.name?.endsWith(".xml") == true && file.name!!.contains("podcini.R")) { + hasPodciniRPrefs = true + break + } + } + for (file in files) { + if (file?.isFile == true && file.name?.endsWith(".xml") == true) { + var destName = file.name!! +// contains info on existing widgets, no need to import + if (destName.contains("PlayerWidgetPrefs")) continue +// for importing from Podcini version 5 and below + if (!hasPodciniRPrefs) { + when { + destName.contains("podcini") -> destName = destName.replace("podcini", "podcini.R") + destName.contains("EpisodeItemListRecyclerView") -> destName = destName.replace("EpisodeItemListRecyclerView", "EpisodesRecyclerView") + } + } + when { +// for debug version importing release version + BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug") +// for release version importing debug version + !BuildConfig.DEBUG && destName.contains(".debug") -> destName = destName.replace(".debug", "") + } + val destFile = File(sharedPreferencesDir, destName) + copyFile(file, destFile, context) + } + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { } + + } + } + + object DatabaseTransporter { + private val TAG: String = DatabaseTransporter::class.simpleName ?: "Anonymous" + @Throws(IOException::class) + fun exportToDocument(uri: Uri?, context: Context) { + var pfd: ParcelFileDescriptor? = null + var fileOutputStream: FileOutputStream? = null + try { + pfd = context.contentResolver.openFileDescriptor(uri!!, "wt") + fileOutputStream = FileOutputStream(pfd!!.fileDescriptor) + exportToStream(fileOutputStream, context) + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + IOUtils.closeQuietly(fileOutputStream) + if (pfd != null) try { pfd.close() } catch (e: IOException) { Logd(TAG, "Unable to close ParcelFileDescriptor") } + } + } + @Throws(IOException::class) + fun exportToStream(outFileStream: FileOutputStream, context: Context) { + var src: FileChannel? = null + var dst: FileChannel? = null + try { + val realmPath = realm.configuration.path + Logd(TAG, "exportToStream realmPath: $realmPath") + val currentDB = File(realmPath) + if (currentDB.exists()) { + src = FileInputStream(currentDB).channel + dst = outFileStream.channel + val srcSize = src.size() + dst.transferFrom(src, 0, srcSize) + 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") + } + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + IOUtils.closeQuietly(src) + IOUtils.closeQuietly(dst) + } + } + @Throws(IOException::class) + fun importBackup(inputUri: Uri?, context: Context) { + val TEMP_DB_NAME = "temp.realm" + var inputStream: InputStream? = null + try { + val tempDB = context.getDatabasePath(TEMP_DB_NAME) + inputStream = context.contentResolver.openInputStream(inputUri!!) + FileUtils.copyInputStreamToFile(inputStream, tempDB) + val realmPath = realm.configuration.path + val currentDB = File(realmPath) + val success = currentDB.delete() + if (!success) throw IOException("Unable to delete old database") + FileUtils.moveFile(tempDB, currentDB) + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + IOUtils.closeQuietly(inputStream) + } + } + } + + /** Reads OPML documents. */ + object EpisodeProgressReader { + private const val TAG = "EpisodeProgressReader" + @OptIn(UnstableApi::class) + 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) + } +// 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 + } + if (feedItem.media == null) { + Logd(TAG, "Feed item has no media: $action") + return null + } + var idRemove = 0L + feedItem.media!!.setPosition(action.position * 1000) + feedItem.media!!.setLastPlayedTime(action.timestamp!!.time) + feedItem.isFavorite = action.isFavorite + feedItem.playState = action.playState + if (hasAlmostEnded(feedItem.media!!)) { + Logd(TAG, "Marking as played: $action") + feedItem.setPlayed(true) + feedItem.media!!.setPosition(0) + idRemove = feedItem.id + } else Logd(TAG, "Setting position: $action") + return Pair(idRemove, feedItem) + } + } + + /** Writes saved favorites to file. */ + class EpisodesProgressWriter : ExportWriter { + @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) + override fun writeDocument(feeds: List?, writer: Writer?, context: Context) { + Logd(TAG, "Starting to write document") + val queuedEpisodeActions: MutableList = mutableListOf() + val pausedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.PAUSED), SortOrder.DATE_NEW_OLD) + val readItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.PLAYED), SortOrder.DATE_NEW_OLD) + val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.IS_FAVORITE), SortOrder.DATE_NEW_OLD) + val comItems = mutableSetOf() + comItems.addAll(pausedItems) + comItems.addAll(readItems) + comItems.addAll(favoriteItems) + Logd(TAG, "Save state for all " + comItems.size + " played episodes") + for (item in comItems) { + val media = item.media ?: continue + val played = EpisodeAction.Builder(item, EpisodeAction.PLAY) + .timestamp(Date(media.getLastPlayedTime())) + .started(media.getPosition() / 1000) + .position(media.getPosition() / 1000) + .total(media.getDuration() / 1000) + .isFavorite(item.isFavorite) + .playState(item.playState) + .build() + queuedEpisodeActions.add(played) + } + if (queuedEpisodeActions.isNotEmpty()) { + try { + Logd(TAG, "Saving ${queuedEpisodeActions.size} actions: ${StringUtils.join(queuedEpisodeActions, ", ")}") + val list = JSONArray() + for (episodeAction in queuedEpisodeActions) { + val obj = episodeAction.writeToJsonObject() + if (obj != null) { + Logd(TAG, "saving EpisodeAction: $obj") + list.put(obj) + } + } + writer?.write(list.toString()) + } catch (e: Exception) { + e.printStackTrace() + throw SyncServiceException(e) + } + } + Logd(TAG, "Finished writing document") + } + override fun fileExtension(): String { + return "json" + } + companion object { + private const val TAG = "EpisodesProgressWriter" + } + } + + /** Writes saved favorites to file. */ + class FavoritesWriter : ExportWriter { + @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") + var template = IOUtils.toString(templateStream, UTF_8) + template = template.replace("\\{TITLE\\}".toRegex(), "Favorites") + val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val favTemplateStream = context.assets.open(FAVORITE_TEMPLATE) + val favTemplate = IOUtils.toString(favTemplateStream, UTF_8) + val feedTemplateStream = context.assets.open(FEED_TEMPLATE) + val feedTemplate = IOUtils.toString(feedTemplateStream, UTF_8) + val allFavorites = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.IS_FAVORITE), SortOrder.DATE_NEW_OLD) + val favoritesByFeed = buildFeedMap(allFavorites) + writer!!.append(templateParts[0]) + for (feedId in favoritesByFeed.keys) { + val favorites: List = favoritesByFeed[feedId]!! + writer.append("
  • \n") + writeFeed(writer, favorites[0].feed, feedTemplate) + writer.append("
      \n") + for (item in favorites) writeFavoriteItem(writer, item, favTemplate) + writer.append("
  • \n") + } + writer.append(templateParts[1]) + Logd(TAG, "Finished writing document") + } + /** + * Group favorite episodes by feed, sorting them by publishing date in descending order. + * @param favoritesList `List` of all favorite episodes. + * @return A `Map` favorite episodes, keyed by feed ID. + */ + private fun buildFeedMap(favoritesList: List): Map> { + val feedMap: MutableMap> = TreeMap() + for (item in favoritesList) { + var feedEpisodes = feedMap[item.feedId] + if (feedEpisodes == null) { + feedEpisodes = ArrayList() + if (item.feedId != null) feedMap[item.feedId!!] = feedEpisodes + } + feedEpisodes.add(item) + } + return feedMap + } + @Throws(IOException::class) + private fun writeFeed(writer: Writer?, feed: Feed?, feedTemplate: String) { + val feedInfo = feedTemplate + .replace("{FEED_IMG}", feed!!.imageUrl!!) + .replace("{FEED_TITLE}", feed.title!!) + .replace("{FEED_LINK}", feed.link!!) + .replace("{FEED_WEBSITE}", feed.downloadUrl!!) + writer!!.append(feedInfo) + } + @Throws(IOException::class) + private fun writeFavoriteItem(writer: Writer?, item: Episode, favoriteTemplate: String) { + var favItem = favoriteTemplate.replace("{FAV_TITLE}", item.title!!.trim { it <= ' ' }) + favItem = if (item.link != null) favItem.replace("{FAV_WEBSITE}", item.link!!) + else favItem.replace("{FAV_WEBSITE}", "") + favItem = + if (item.media != null && item.media!!.downloadUrl != null) favItem.replace("{FAV_MEDIA}", item.media!!.downloadUrl!!) + else favItem.replace("{FAV_MEDIA}", "") + writer!!.append(favItem) + } + override fun fileExtension(): String { + return "html" + } + companion object { + private val TAG: String = FavoritesWriter::class.simpleName ?: "Anonymous" + private const val FAVORITE_TEMPLATE = "html-export-favorites-item-template.html" + private const val FEED_TEMPLATE = "html-export-feed-template.html" + private const val UTF_8 = "UTF-8" + } + } + + /** Writes HTML documents. */ + class HtmlWriter : ExportWriter { + /** + * Takes a list of feeds and a writer and writes those into an HTML document. + */ + @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") + 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("
  • ") + writer.append(feed.title) + writer.append(" WebsiteFeed

  • \n") + } + writer.append(templateParts[1]) + Logd(TAG, "Finished writing document") + } + + override fun fileExtension(): String { + return "html" + } + + companion object { + private val TAG: String = HtmlWriter::class.simpleName ?: "Anonymous" + } + } + companion object { private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous" private const val PREF_OPML_EXPORT = "prefOpmlExport" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/receiver/PlayerWidget.kt b/app/src/main/kotlin/ac/mdiq/podcini/receiver/PlayerWidget.kt index 3f89e128..f2b3c9f4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/receiver/PlayerWidget.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/receiver/PlayerWidget.kt @@ -1,30 +1,30 @@ package ac.mdiq.podcini.receiver -import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.SWIPE_ACTIONS_PREF_NAME +import ac.mdiq.podcini.ui.widget.WidgetUpdaterWorker +import ac.mdiq.podcini.util.Logd import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context -import android.util.Log +import android.content.SharedPreferences import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager -import ac.mdiq.podcini.ui.widget.WidgetUpdaterWorker -import ac.mdiq.podcini.util.Logd -import android.content.SharedPreferences import java.util.concurrent.TimeUnit class PlayerWidget : AppWidgetProvider() { override fun onEnabled(context: Context) { super.onEnabled(context) + getSharedPrefs(context) Logd(TAG, "Widget enabled") - setEnabled(context, true) + setEnabled(true) WidgetUpdaterWorker.enqueueWork(context) scheduleWorkaround(context) } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { Logd(TAG, "onUpdate() called with: context = [$context], appWidgetManager = [$appWidgetManager], appWidgetIds = [${appWidgetIds.contentToString()}]") + getSharedPrefs(context) WidgetUpdaterWorker.enqueueWork(context) if (!prefs!!.getBoolean(KEY_WORKAROUND_ENABLED, false)) { @@ -36,7 +36,7 @@ class PlayerWidget : AppWidgetProvider() { override fun onDisabled(context: Context) { super.onDisabled(context) Logd(TAG, "Widget disabled") - setEnabled(context, false) + setEnabled(false) } override fun onDeleted(context: Context, appWidgetIds: IntArray) { @@ -57,7 +57,7 @@ class PlayerWidget : AppWidgetProvider() { super.onDeleted(context, appWidgetIds) } - private fun setEnabled(context: Context, enabled: Boolean) { + private fun setEnabled(enabled: Boolean) { prefs!!.edit().putBoolean(KEY_ENABLED, enabled).apply() } @@ -91,8 +91,7 @@ class PlayerWidget : AppWidgetProvider() { } @JvmStatic - fun isEnabled(context: Context): Boolean { -// val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + fun isEnabled(): Boolean { return prefs!!.getBoolean(KEY_ENABLED, false) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/receiver/PowerConnectionReceiver.kt b/app/src/main/kotlin/ac/mdiq/podcini/receiver/PowerConnectionReceiver.kt index 47f88c10..84372c21 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/receiver/PowerConnectionReceiver.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/receiver/PowerConnectionReceiver.kt @@ -7,7 +7,7 @@ import androidx.media3.common.util.UnstableApi import ac.mdiq.podcini.util.config.ClientConfigurator import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery -import ac.mdiq.podcini.storage.database.Episodes.autodownloadEpisodeMedia +import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia import ac.mdiq.podcini.util.Logd // modified from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/EpisodeCleanupAlgorithmFactory.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt similarity index 90% rename from app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/EpisodeCleanupAlgorithmFactory.kt rename to app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt index 624f9577..e80fe19b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/EpisodeCleanupAlgorithmFactory.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoCleanups.kt @@ -20,7 +20,19 @@ import kotlinx.coroutines.runBlocking import java.util.* import java.util.concurrent.ExecutionException -object EpisodeCleanupAlgorithmFactory { +object AutoCleanups { + + /** + * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller + * 'playbackCompletionDate'-value will be deleted first. + * This method should NOT be executed on the GUI thread. + * @param context Used for accessing the DB. + */ +// only used in tests + fun performAutoCleanup(context: Context) { + build().performCleanup(context) + } + @JvmStatic fun build(): EpisodeCleanupAlgorithm { if (!isEnableAutodownload) return APNullCleanupAlgorithm() @@ -37,18 +49,24 @@ object EpisodeCleanupAlgorithmFactory { * A cleanup algorithm that removes any item that isn't a favorite but only if space is needed. */ class ExceptFavoriteCleanupAlgorithm : EpisodeCleanupAlgorithm() { + private val candidates: List + get() { + val candidates: MutableList = ArrayList() + val downloadedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD) + for (item in downloadedItems) { + if (item.media != null && item.media!!.downloaded && !item.isFavorite) candidates.add(item) + } + return candidates + } /** * The maximum number of episodes that could be cleaned up. - * * @return the number of episodes that *could* be cleaned up, if needed */ override fun getReclaimableItems(): Int { return candidates.size } - - @OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numberOfEpisodesToDelete: Int): Int { + @OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numToRemove: Int): Int { var candidates = candidates - // in the absence of better data, we'll sort by item publication date candidates = candidates.sortedWith { lhs: Episode, rhs: Episode -> val l = lhs.getPubDate() @@ -56,9 +74,7 @@ object EpisodeCleanupAlgorithmFactory { if (l != null && r != null) return@sortedWith l.compareTo(r) else return@sortedWith lhs.id.compareTo(rhs.id) // No date - compare by id which should be always incremented } - - val delete = if (candidates.size > numberOfEpisodesToDelete) candidates.subList(0, numberOfEpisodesToDelete) else candidates - + val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates for (item in delete) { if (item.media == null) continue try { @@ -69,23 +85,10 @@ object EpisodeCleanupAlgorithmFactory { e.printStackTrace() } } - val counter = delete.size - Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numberOfEpisodesToDelete)) - + Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numToRemove)) return counter } - - private val candidates: List - get() { - val candidates: MutableList = ArrayList() - val downloadedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD) - for (item in downloadedItems) { - if (item.media != null && item.media!!.downloaded && !item.isFavorite) candidates.add(item) - } - return candidates - } - public override fun getDefaultCleanupParameter(): Int { val cacheSize = episodeCacheSize if (cacheSize != UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) { @@ -94,7 +97,6 @@ object EpisodeCleanupAlgorithmFactory { } return 0 } - companion object { private val TAG: String = ExceptFavoriteCleanupAlgorithm::class.simpleName ?: "Anonymous" } @@ -105,47 +107,6 @@ object EpisodeCleanupAlgorithmFactory { * but only if space is needed. */ class APQueueCleanupAlgorithm : EpisodeCleanupAlgorithm() { - /** - * @return the number of episodes that *could* be cleaned up, if needed - */ - override fun getReclaimableItems(): Int { - return candidates.size - } - - @OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numberOfEpisodesToDelete: Int): Int { - var candidates = candidates - - // in the absence of better data, we'll sort by item publication date - candidates = candidates.sortedWith { lhs: Episode, rhs: Episode -> - var l = lhs.getPubDate() - var r = rhs.getPubDate() - - if (l == null) l = Date() - if (r == null) r = Date() - - l.compareTo(r) - } - - val delete = if (candidates.size > numberOfEpisodesToDelete) candidates.subList(0, numberOfEpisodesToDelete) else candidates - - for (item in delete) { - if (item.media == null) continue - try { - runBlocking { deleteMediaOfEpisode(context, item).join() } - } catch (e: InterruptedException) { - e.printStackTrace() - } catch (e: ExecutionException) { - e.printStackTrace() - } - } - - val counter = delete.size - - Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numberOfEpisodesToDelete)) - - return counter - } - private val candidates: List get() { val candidates: MutableList = ArrayList() @@ -157,11 +118,40 @@ object EpisodeCleanupAlgorithmFactory { } return candidates } - + /** + * @return the number of episodes that *could* be cleaned up, if needed + */ + override fun getReclaimableItems(): Int { + return candidates.size + } + @OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numToRemove: Int): Int { + var candidates = candidates + // in the absence of better data, we'll sort by item publication date + candidates = candidates.sortedWith { lhs: Episode, rhs: Episode -> + var l = lhs.getPubDate() + var r = rhs.getPubDate() + if (l == null) l = Date() + if (r == null) r = Date() + l.compareTo(r) + } + val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates + for (item in delete) { + if (item.media == null) continue + try { + runBlocking { deleteMediaOfEpisode(context, item).join() } + } catch (e: InterruptedException) { + e.printStackTrace() + } catch (e: ExecutionException) { + e.printStackTrace() + } + } + val counter = delete.size + Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numToRemove)) + return counter + } public override fun getDefaultCleanupParameter(): Int { return getNumEpisodesToCleanup(0) } - companion object { private val TAG: String = APQueueCleanupAlgorithm::class.simpleName ?: "Anonymous" } @@ -171,20 +161,17 @@ object EpisodeCleanupAlgorithmFactory { * A cleanup algorithm that never removes anything */ class APNullCleanupAlgorithm : EpisodeCleanupAlgorithm() { - public override fun performCleanup(context: Context, parameter: Int): Int { + public override fun performCleanup(context: Context, numToRemove: Int): Int { // never clean anything up Log.i(TAG, "performCleanup: Not removing anything") return 0 } - public override fun getDefaultCleanupParameter(): Int { return 0 } - override fun getReclaimableItems(): Int { return 0 } - companion object { private val TAG: String = APNullCleanupAlgorithm::class.simpleName ?: "Anonymous" } @@ -197,49 +184,6 @@ object EpisodeCleanupAlgorithmFactory { * Fractional for number of hours, e.g., 0.5 = 12 hours, 0.0416 = 1 hour. */ class APCleanupAlgorithm(@JvmField @get:VisibleForTesting val numberOfHoursAfterPlayback: Int) : EpisodeCleanupAlgorithm() { - /** - * @return the number of episodes that *could* be cleaned up, if needed - */ - override fun getReclaimableItems(): Int { - return candidates.size - } - - @OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numberOfEpisodesToDelete: Int): Int { - val candidates = candidates.toMutableList() - - candidates.sortWith { lhs: Episode, rhs: Episode -> - var l = lhs.media!!.playbackCompletionDate - var r = rhs.media!!.playbackCompletionDate - - if (l == null) l = Date() - if (r == null) r = Date() - - l.compareTo(r) - } - - val delete = if (candidates.size > numberOfEpisodesToDelete) candidates.subList(0, numberOfEpisodesToDelete) else candidates - - for (item in delete) { - try { - runBlocking { deleteMediaOfEpisode(context, item).join() } - } catch (e: InterruptedException) { - e.printStackTrace() - } catch (e: ExecutionException) { - e.printStackTrace() - } - } - - val counter = delete.size - Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numberOfEpisodesToDelete)) - - return counter - } - - @VisibleForTesting - fun calcMostRecentDateForDeletion(currentDate: Date): Date { - return minusHours(currentDate, numberOfHoursAfterPlayback) - } - private val candidates: List get() { val candidates: MutableList = ArrayList() @@ -249,27 +193,54 @@ object EpisodeCleanupAlgorithmFactory { for (item in downloadedItems) { if (item.media != null && item.media!!.downloaded && !idsInQueues.contains(item.id) && item.isPlayed() && !item.isFavorite) { val media = item.media - // make sure this candidate was played at least the proper amount of days prior - // to now - if (media?.playbackCompletionDate != null && media.playbackCompletionDate!!.before(mostRecentDateForDeletion)) - candidates.add(item) + // make sure this candidate was played at least the proper amount of days prior to now + if (media?.playbackCompletionDate != null && media.playbackCompletionDate!!.before(mostRecentDateForDeletion)) candidates.add(item) } } return candidates } - + /** + * @return the number of episodes that *could* be cleaned up, if needed + */ + override fun getReclaimableItems(): Int { + return candidates.size + } + @OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numToRemove: Int): Int { + val candidates = candidates.toMutableList() + candidates.sortWith { lhs: Episode, rhs: Episode -> + var l = lhs.media!!.playbackCompletionDate + var r = rhs.media!!.playbackCompletionDate + if (l == null) l = Date() + if (r == null) r = Date() + l.compareTo(r) + } + val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates + for (item in delete) { + try { + runBlocking { deleteMediaOfEpisode(context, item).join() } + } catch (e: InterruptedException) { + e.printStackTrace() + } catch (e: ExecutionException) { + e.printStackTrace() + } + } + val counter = delete.size + Log.i(TAG, String.format(Locale.US, "Auto-delete deleted %d episodes (%d requested)", counter, numToRemove)) + return counter + } + @VisibleForTesting + fun calcMostRecentDateForDeletion(currentDate: Date): Date { + return minusHours(currentDate, numberOfHoursAfterPlayback) + } public override fun getDefaultCleanupParameter(): Int { return getNumEpisodesToCleanup(0) } - companion object { private val TAG: String = APCleanupAlgorithm::class.simpleName ?: "Anonymous" private fun minusHours(baseDate: Date, numberOfHours: Int): Date { val cal = Calendar.getInstance() cal.time = baseDate - cal.add(Calendar.HOUR_OF_DAY, -1 * numberOfHours) - return cal.time } } @@ -286,22 +257,17 @@ object EpisodeCleanupAlgorithmFactory { * @return The number of episodes that were deleted. */ protected abstract fun performCleanup(context: Context, numToRemove: Int): Int - fun performCleanup(context: Context): Int { return performCleanup(context, getDefaultCleanupParameter()) } - - protected abstract fun getDefaultCleanupParameter(): Int /** * Returns a parameter for performCleanup. The implementation of this interface should decide how much * space to free to satisfy the episode cache conditions. If the conditions are already satisfied, this * method should not have any effects. */ - - + protected abstract fun getDefaultCleanupParameter(): Int /** * Cleans up just enough episodes to make room for the requested number - * * @param context Can be used for accessing the database * @param amountOfRoomNeeded the number of episodes we need space for * @return The number of epiosdes that were deleted @@ -309,12 +275,10 @@ object EpisodeCleanupAlgorithmFactory { fun makeRoomForEpisodes(context: Context, amountOfRoomNeeded: Int): Int { return performCleanup(context, getNumEpisodesToCleanup(amountOfRoomNeeded)) } - /** * @return the number of episodes/items that *could* be cleaned up, if needed */ abstract fun getReclaimableItems(): Int - /** * @param amountOfRoomNeeded the number of episodes we want to download * @return the number of episodes to delete in order to make room diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt new file mode 100644 index 00000000..f4bf2a16 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt @@ -0,0 +1,130 @@ +package ac.mdiq.podcini.storage.algorithms + +import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface +import ac.mdiq.podcini.net.utils.NetworkUtils.isAutoDownloadAllowed +import ac.mdiq.podcini.playback.base.InTheatre.curQueue +import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia +import ac.mdiq.podcini.preferences.UserPreferences +import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize +import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload +import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery +import ac.mdiq.podcini.storage.database.Episodes.getEpisodes +import ac.mdiq.podcini.storage.database.Episodes.getEpisodesCount +import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.utils.EpisodeFilter +import ac.mdiq.podcini.storage.utils.SortOrder +import ac.mdiq.podcini.util.Logd +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import androidx.media3.common.util.UnstableApi +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future + +object AutoDownloads { + private val TAG: String = AutoDownloads::class.simpleName ?: "Anonymous" + + /** + * Executor service used by the autodownloadUndownloadedEpisodes method. + */ + private val autodownloadExec: ExecutorService = Executors.newSingleThreadExecutor { r: Runnable? -> + val t = Thread(r) + t.priority = Thread.MIN_PRIORITY + t + } + + var downloadAlgorithm = AutoDownloadAlgorithm() + + /** + * Looks for non-downloaded episodes in the queue or list of unread episodes and request a download if + * 1. Network is available + * 2. The device is charging or the user allows auto download on battery + * 3. There is free space in the episode cache + * This method is executed on an internal single thread executor. + * @param context Used for accessing the DB. + * @return A Future that can be used for waiting for the methods completion. + */ + @UnstableApi + fun autodownloadEpisodeMedia(context: Context): Future<*> { + Logd(TAG, "autodownloadEpisodeMedia") + return autodownloadExec.submit(downloadAlgorithm.autoDownloadEpisodeMedia(context)) + } + + /** + * Implements the automatic download algorithm used by Podcini. This class assumes that + * the client uses the [EpisodeCleanupAlgorithm]. + */ + open class AutoDownloadAlgorithm { + /** + * Looks for undownloaded episodes in the queue or list of new items and request a download if + * 1. Network is available + * 2. The device is charging or the user allows auto download on battery + * 3. There is free space in the episode cache + * This method is executed on an internal single thread executor. + * @param context Used for accessing the DB. + * @return A Runnable that will be submitted to an ExecutorService. + */ + @UnstableApi + open fun autoDownloadEpisodeMedia(context: Context): Runnable? { + return Runnable { + // true if we should auto download based on network status +// val networkShouldAutoDl = (isAutoDownloadAllowed) + val networkShouldAutoDl = (isAutoDownloadAllowed && isEnableAutodownload) + // true if we should auto download based on power status + val powerShouldAutoDl = (deviceCharging(context) || isEnableAutodownloadOnBattery) + Logd(TAG, "prepare autoDownloadUndownloadedItems $networkShouldAutoDl $powerShouldAutoDl") + // we should only auto download if both network AND power are happy + if (networkShouldAutoDl && powerShouldAutoDl) { + Logd(TAG, "Performing auto-dl of undownloaded episodes") + val candidates: MutableList + val queue = curQueue.episodes + val newItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.NEW), SortOrder.DATE_NEW_OLD) + Logd(TAG, "newItems: ${newItems.size}") + candidates = ArrayList(queue.size + newItems.size) + candidates.addAll(queue) + for (newItem in newItems) { + val feedPrefs = newItem.feed!!.preferences + if (feedPrefs!!.autoDownload && !candidates.contains(newItem) && feedPrefs.filter.shouldAutoDownload(newItem)) candidates.add(newItem) + } + // filter items that are not auto downloadable + val it = candidates.iterator() + while (it.hasNext()) { + val item = it.next() + if (!item.isAutoDownloadEnabled || item.isDownloaded || item.media == null || isCurMedia(item.media) || item.feed?.isLocalFeed == true) + it.remove() + } + val autoDownloadableEpisodes = candidates.size + val downloadedEpisodes = getEpisodesCount(EpisodeFilter(EpisodeFilter.DOWNLOADED)) + val deletedEpisodes = AutoCleanups.build().makeRoomForEpisodes(context, autoDownloadableEpisodes) + val cacheIsUnlimited = episodeCacheSize == UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED + val episodeCacheSize = episodeCacheSize + val episodeSpaceLeft = + if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) autoDownloadableEpisodes + else episodeCacheSize - (downloadedEpisodes - deletedEpisodes) + val itemsToDownload: List = candidates.subList(0, episodeSpaceLeft) + if (itemsToDownload.isNotEmpty()) { + Logd(TAG, "Enqueueing " + itemsToDownload.size + " items for download") + for (episode in itemsToDownload) DownloadServiceInterface.get()?.download(context, episode) + } + } + else Logd(TAG, "not auto downloaded networkShouldAutoDl: $networkShouldAutoDl powerShouldAutoDl $powerShouldAutoDl") + } + } + /** + * @return true if the device is charging + */ + private fun deviceCharging(context: Context): Boolean { + // from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html + val iFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = context.registerReceiver(null, iFilter) + + val status = batteryStatus!!.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + return (status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL) + } + companion object { + private val TAG: String = AutoDownloadAlgorithm::class.simpleName ?: "Anonymous" + } + } +} \ No newline at end of file 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 72c109a3..71be3ed4 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 @@ -5,18 +5,11 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.net.feed.LocalFeedUpdater.updateFeed import ac.mdiq.podcini.net.sync.model.EpisodeAction import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink -import ac.mdiq.podcini.net.utils.NetworkUtils.isAutoDownloadAllowed import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curState -import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia 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 -import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize -import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload -import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueues import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync import ac.mdiq.podcini.storage.database.RealmDB.realm @@ -29,7 +22,6 @@ import ac.mdiq.podcini.storage.utils.EpisodeFilter import ac.mdiq.podcini.storage.utils.SortOrder import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.PowerUtils.deviceCharging import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.sorting.EpisodesPermutors.getPermutor @@ -43,28 +35,12 @@ import androidx.documentfile.provider.DocumentFile import androidx.media3.common.util.UnstableApi import kotlinx.coroutines.Job import java.io.File -import java.text.DateFormat import java.util.* -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.Future -import kotlin.math.abs import kotlin.math.min object Episodes { private val TAG: String = Episodes::class.simpleName ?: "Anonymous" - /** - * Executor service used by the autodownloadUndownloadedEpisodes method. - */ - private val autodownloadExec: ExecutorService = Executors.newSingleThreadExecutor { r: Runnable? -> - val t = Thread(r) - t.priority = Thread.MIN_PRIORITY - t - } - - var downloadAlgorithm = AutomaticDownloadAlgorithm() - /** * @param offset The first episode that should be loaded. * @param limit The maximum number of episodes that should be loaded. @@ -113,101 +89,6 @@ object Episodes { return if (media != null) realm.copyFromRealm(media) else null } - /** - * Looks for non-downloaded episodes in the queue or list of unread episodes and request a download if - * 1. Network is available - * 2. The device is charging or the user allows auto download on battery - * 3. There is free space in the episode cache - * This method is executed on an internal single thread executor. - * @param context Used for accessing the DB. - * @return A Future that can be used for waiting for the methods completion. - */ - @UnstableApi - fun autodownloadEpisodeMedia(context: Context): Future<*> { - Logd(TAG, "autodownloadEpisodeMedia") - return autodownloadExec.submit(downloadAlgorithm.autoDownloadEpisodeMedia(context)) - } - - /** - * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller - * 'playbackCompletionDate'-value will be deleted first. - * This method should NOT be executed on the GUI thread. - * @param context Used for accessing the DB. - */ - fun performAutoCleanup(context: Context) { - EpisodeCleanupAlgorithmFactory.build().performCleanup(context) - } - - /** - * Implements the automatic download algorithm used by Podcini. This class assumes that - * the client uses the [EpisodeCleanupAlgorithm]. - */ - open class AutomaticDownloadAlgorithm { - /** - * Looks for undownloaded episodes in the queue or list of new items and request a download if - * 1. Network is available - * 2. The device is charging or the user allows auto download on battery - * 3. There is free space in the episode cache - * This method is executed on an internal single thread executor. - * @param context Used for accessing the DB. - * @return A Runnable that will be submitted to an ExecutorService. - */ - @UnstableApi open fun autoDownloadEpisodeMedia(context: Context): Runnable? { - return Runnable { - // true if we should auto download based on network status -// val networkShouldAutoDl = (isAutoDownloadAllowed) - val networkShouldAutoDl = (isAutoDownloadAllowed && isEnableAutodownload) - - // true if we should auto download based on power status - val powerShouldAutoDl = (deviceCharging(context) || isEnableAutodownloadOnBattery) - Logd(TAG, "prepare autoDownloadUndownloadedItems $networkShouldAutoDl $powerShouldAutoDl") - - // we should only auto download if both network AND power are happy - if (networkShouldAutoDl && powerShouldAutoDl) { - Logd(TAG, "Performing auto-dl of undownloaded episodes") - - val candidates: MutableList - val queue = curQueue.episodes - - val newItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.NEW), SortOrder.DATE_NEW_OLD) - Logd(TAG, "newItems: ${newItems.size}") - candidates = ArrayList(queue.size + newItems.size) - candidates.addAll(queue) - for (newItem in newItems) { - val feedPrefs = newItem.feed!!.preferences - if (feedPrefs!!.autoDownload && !candidates.contains(newItem) && feedPrefs.filter.shouldAutoDownload(newItem)) candidates.add(newItem) - } - - // filter items that are not auto downloadable - val it = candidates.iterator() - while (it.hasNext()) { - val item = it.next() - if (!item.isAutoDownloadEnabled || item.isDownloaded || item.media == null || isCurMedia(item.media) || item.feed?.isLocalFeed == true) - it.remove() - } - - val autoDownloadableEpisodes = candidates.size - val downloadedEpisodes = getEpisodesCount(EpisodeFilter(EpisodeFilter.DOWNLOADED)) - val deletedEpisodes = EpisodeCleanupAlgorithmFactory.build().makeRoomForEpisodes(context, autoDownloadableEpisodes) - val cacheIsUnlimited = episodeCacheSize == UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED - val episodeCacheSize = episodeCacheSize - val episodeSpaceLeft = if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) autoDownloadableEpisodes - else episodeCacheSize - (downloadedEpisodes - deletedEpisodes) - - val itemsToDownload: List = candidates.subList(0, episodeSpaceLeft) - if (itemsToDownload.isNotEmpty()) { - Logd(TAG, "Enqueueing " + itemsToDownload.size + " items for download") - for (episode in itemsToDownload) DownloadServiceInterface.get()?.download(context, episode) - } - } - else Logd(TAG, "not auto downloaded networkShouldAutoDl: $networkShouldAutoDl powerShouldAutoDl $powerShouldAutoDl") - } - } - companion object { - private val TAG: String = AutomaticDownloadAlgorithm::class.simpleName ?: "Anonymous" - } - } - // @JvmStatic is needed because some Runnable blocks call this @OptIn(UnstableApi::class) @JvmStatic fun deleteMediaOfEpisode(context: Context, episode: Episode) : Job { @@ -228,7 +109,7 @@ object Episodes { private fun deleteMediaSync(context: Context, episode: Episode): Boolean { Logd(TAG, "deleteMediaSync called") val media = episode.media ?: return false - Log.i(TAG, String.format(Locale.US, "Requested to delete EpisodeMedia [id=%d, title=%s, downloaded=%s", media.id, media.getEpisodeTitle(), media.downloaded)) + 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 when { @@ -312,17 +193,7 @@ object Episodes { } } } - if (removedFromQueue.isNotEmpty()) { - curQueue.episodes.clear() - curQueue.episodes.addAll(queueItems) -// upsertBlk(curQueue) {} - } -// TODO: need to update download logs? -// val adapter = getInstance() -// adapter.open() -// if (removedFromQueue.isNotEmpty()) adapter.setQueue(queueItems) -// adapter.removeFeedItems(episodes) -// adapter.close() + if (removedFromQueue.isNotEmpty()) removeFromAllQueues(*removedFromQueue.toTypedArray()) for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode)) @@ -372,7 +243,6 @@ object Episodes { * Adds a Episode object to the playback history. A Episode object is in the playback history if * its playback completion date is set to a non-null value. This method will set the playback completion date to the * current date regardless of the current value. - * * @param episode Episode that should be added to the playback history. * @param date PlaybackCompletionDate for `media` */ @@ -414,67 +284,4 @@ object Episodes { } } } - - /** - * 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, - * even if their feed explicitly says that the episodes are different. - */ - 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 - .trim { it <= ' ' } - .replace('“', '"') - .replace('”', '"') - .replace('„', '"') - .replace('—', '-') - } - } } \ 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 428e9cd1..0bc850f5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -4,7 +4,6 @@ import ac.mdiq.podcini.net.download.DownloadError import ac.mdiq.podcini.net.sync.model.EpisodeAction import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.Episodes.EpisodeDuplicateGuesser import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet @@ -12,30 +11,28 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk -import ac.mdiq.podcini.storage.model.DownloadResult -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.FeedPreferences +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.TAG_ROOT import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting 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.EpisodePubdateComparator import android.app.backup.BackupManager import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile -import kotlinx.coroutines.Job -import kotlinx.coroutines.runBlocking +import io.realm.kotlin.ext.asFlow +import io.realm.kotlin.notifications.* +import kotlinx.coroutines.* import java.io.File +import java.text.DateFormat import java.util.* import java.util.concurrent.ExecutionException +import kotlin.math.abs object Feeds { private val TAG: String = Feeds::class.simpleName ?: "Anonymous" -// internal val feeds: MutableList = mutableListOf() private val feedMap: MutableMap = mutableMapOf() private val tags: MutableList = mutableListOf() @@ -47,11 +44,22 @@ object Feeds { return tags } - fun updateFeedMap() { - Logd(TAG, "updateFeedMap called") - val feeds_ = realm.query(Feed::class).find() - feedMap.clear() - feedMap.putAll(feeds_.associateBy { it.id }) + fun updateFeedMap(feeds: List = listOf(), wipe: Boolean = false) { + Logd(TAG, "updateFeedMap called feeds: ${feeds.size} wipe: $wipe") + when { + feeds.isEmpty() -> { + val feeds_ = realm.query(Feed::class).find() + feedMap.clear() + feedMap.putAll(feeds_.associateBy { it.id }) + } + wipe -> { + feedMap.clear() + feedMap.putAll(feeds.associateBy { it.id }) + } + else -> { + for (f in feeds) feedMap[f.id] = f + } + } buildTags() } @@ -59,21 +67,87 @@ object Feeds { val tagsSet = mutableSetOf() val feedsCopy = feedMap.values for (feed in feedsCopy) { - if (feed.preferences != null) { - for (tag in feed.preferences!!.tags) { - if (tag != TAG_ROOT) tagsSet.add(tag) - } - } + if (feed.preferences != null) tagsSet.addAll(feed.preferences!!.tags.filter { it != TAG_ROOT }) } tags.clear() tags.addAll(tagsSet) tags.sort() } + fun monitorFeeds() { + val feeds = realm.query(Feed::class).find() + for (f in feeds) monitorFeed(f) + + val feedQuery = realm.query(Feed::class) + CoroutineScope(Dispatchers.Default).launch { + val feedsFlow = feedQuery.asFlow() + feedsFlow.collect { changes: ResultsChange -> + when (changes) { + is UpdatedResults -> { + when { + changes.insertions.isNotEmpty() -> { + for (i in changes.insertions) { + Logd(TAG, "monitorFeeds inserted feed: ${changes.list[i].title}") + updateFeedMap(listOf(changes.list[i])) + monitorFeed(changes.list[i]) + EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED, changes.list[i].id)) + } + } +// changes.changes.isNotEmpty() -> { +// for (i in changes.changes) { +// Logd(TAG, "monitorFeeds feed changed: ${changes.list[i].title}") +// } +// } + changes.deletions.isNotEmpty() -> { + Logd(TAG, "monitorFeeds feed deleted: ${changes.deletions.size}") + updateFeedMap(changes.list, true) + } + } + } + else -> { + // types other than UpdatedResults are not changes -- ignore them + } + } + } + } + } + + private fun monitorFeed(feed: Feed) { + CoroutineScope(Dispatchers.Default).launch { + val feedPrefsFlow = feed.asFlow(listOf("preferences.*")) + feedPrefsFlow.collect { changes: SingleQueryChange -> + when (changes) { + is UpdatedObject -> { + Logd(TAG, "monitorFeed UpdatedObject0 ${changes.obj.title} ${changes.changedFields.joinToString()}") + updateFeedMap(listOf(changes.obj)) + if (changes.isFieldChanged("preferences")) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(changes.obj)) + } + else -> {} + } + } + } + CoroutineScope(Dispatchers.Default).launch { + val feedFlow = feed.asFlow() + feedFlow.collect { changes: SingleQueryChange -> + when (changes) { + is UpdatedObject -> { + Logd(TAG, "monitorFeed UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}") + 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)) + } + else -> {} + } + } + } + } + fun getFeedListDownloadUrls(): List { Logd(TAG, "getFeedListDownloadUrls called") val result: MutableList = mutableListOf() -// val feeds = realm.query(Feed::class).find() for (f in feedMap.values) { val url = f.downloadUrl if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) result.add(url) @@ -81,11 +155,7 @@ object Feeds { return result } -// TODO: some callers don't need to copy fun getFeed(feedId: Long, copy: Boolean = false): Feed? { -// Logd(TAG, "getFeed() called with: $feedId") -// val f = realm.query(Feed::class).query("id == $0", feedId).first().find() -// return if (f != null && f.isManaged()) realm.copyFromRealm(f) else null val f = feedMap[feedId] return if (f != null) { if (copy) realm.copyFromRealm(f) @@ -118,14 +188,13 @@ object Feeds { @Synchronized fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? { Logd(TAG, "updateFeed called") -// TODO: check further on enclosing in realm write block var resultFeed: Feed? val unlistedItems: MutableList = ArrayList() // Look up feed in the feedslist val savedFeed = searchFeedByIdentifyingValueOrID(newFeed, true) if (savedFeed == null) { - Logd(TAG, "Found no existing Feed with title " + newFeed.title + ". Adding as new one.") + Logd(TAG, "Found no existing Feed with title ${newFeed.title}. Adding as new one.") Logd(TAG, "newFeed.episodes: ${newFeed.episodes.size}") resultFeed = newFeed } else { @@ -217,7 +286,6 @@ object Feeds { Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate") episode.setNew() } -// idLong += 1 } } @@ -246,16 +314,16 @@ object Feeds { // Update with default values that are set in database resultFeed = searchFeedByIdentifyingValueOrID(newFeed) } else persistFeedsSync(savedFeed) - updateFeedMap() +// updateFeedMap() if (removeUnlistedItems) runBlocking { deleteEpisodes(context, unlistedItems).join() } } catch (e: InterruptedException) { e.printStackTrace() } catch (e: ExecutionException) { e.printStackTrace() } - - if (savedFeed != null) EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(savedFeed)) - else EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(emptyList())) +// TODO: feedMonitor likely takes care of this +// if (savedFeed != null) EventFlow.postEvent(FlowEvent.FeedListEvent(savedFeed)) +// else EventFlow.postEvent(FlowEvent.FeedListEvent(emptyList())) return resultFeed } @@ -302,7 +370,7 @@ object Feeds { return runOnIOScope { feed.lastUpdateFailed = lastUpdateFailed upsert(feed) {} - EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feed.id)) + EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ERROR, feed.id)) } } @@ -339,32 +407,38 @@ object Feeds { } copyToRealm(feed) } +// updateFeedMap(feeds.toList()) } for (feed in feeds) { - if (!feed.isLocalFeed && feed.downloadUrl != null) - SynchronizationQueueSink.enqueueFeedAddedIfSyncActive(context, feed.downloadUrl!!) + if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedAddedIfSyncActive(context, feed.downloadUrl!!) } val backupManager = BackupManager(context) backupManager.dataChanged() } private fun persistFeedsSync(vararg feeds: Feed) { - Logd(TAG, "persistCompleteFeeds called") + Logd(TAG, "persistFeedsSync called") for (feed in feeds) { upsertBlk(feed) {} } } fun persistFeedPreferences(feed: Feed) : Job { - Logd(TAG, "persistCompleteFeeds called") + Logd(TAG, "persistFeedPreferences called") 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 } + findLatest(feed_)?.let { + it.preferences = feed.preferences +// updateFeedMap(listOf(it)) + } } - } else upsert(feed) {} - if (feed.preferences != null) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(feed.preferences!!)) + } else { + upsert(feed) {} +// updateFeedMap(listOf(feed)) + } +// if (feed.preferences != null) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(feed.preferences!!)) } } @@ -389,11 +463,14 @@ object Feeds { val episodes = feed_.episodes.toList() if (episodes.isNotEmpty()) episodes.forEach { e -> delete(e) } val feedToDelete = findLatest(feed_) - if (feedToDelete != null) delete(feedToDelete) + if (feedToDelete != null) { + delete(feedToDelete) + feedMap.remove(feedId) + } } } if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!) - if (postEvent) EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feed)) +// if (postEvent) EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id)) } } } @@ -426,4 +503,77 @@ object Feeds { if (!UserPreferences.isAutoDelete) return false return !feed.isLocalFeed || UserPreferences.isAutoDeleteLocal } + + /** + * Compares the pubDate of two FeedItems for sorting in reverse order + */ + class EpisodePubdateComparator : Comparator { + override fun compare(lhs: Episode, rhs: Episode): Int { + return rhs.pubDate.compareTo(lhs.pubDate) + } + } + + /** + * 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, + * even if their feed explicitly says that the episodes are different. + */ + 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 + .trim { it <= ' ' } + .replace('“', '"') + .replace('”', '"') + .replace('„', '"') + .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 bbada2fd..9ba6f984 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 @@ -42,9 +42,7 @@ object LogsAndStats { Logd(TAG, "getStatistics called") val medias = realm.query(EpisodeMedia::class).find() - val groupdMedias = medias.groupBy { - it.episode?.feedId ?: 0L - } + val groupdMedias = medias.groupBy { it.episode?.feedId ?: 0L } val result = StatisticsResult() result.oldestDate = Long.MAX_VALUE for (fid in groupdMedias.keys) { 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 5ae7f182..953a0d54 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 @@ -7,7 +7,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.EnqueueLocation 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.database.Episodes.autodownloadEpisodeMedia +import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia import ac.mdiq.podcini.storage.database.Episodes.markPlayed import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope @@ -186,7 +186,7 @@ object Queues { queue.episodes.addAll(qItems) } for (event in events) EventFlow.postEvent(event) - } else Log.w(TAG, "Queue was not modified by call to removeQueueItem") + } else Logd(TAG, "Queue was not modified by call to removeQueueItem") // TODO: what's this for? if (queue.id == curQueue.id && context != null) autodownloadEpisodeMedia(context) 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 dab75d68..e4c2f274 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 @@ -16,9 +16,12 @@ import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor object RealmDB { - val TAG: String = RealmDB::class.simpleName ?: "Anonymous" + private val TAG: String = RealmDB::class.simpleName ?: "Anonymous" + + private const val SCHEMA_VERSION_NUMBER = 4L + + private val ioScope = CoroutineScope(Dispatchers.IO) - val ioScope = CoroutineScope(Dispatchers.IO) val realm: Realm init { @@ -33,7 +36,7 @@ object RealmDB { DownloadResult::class, Chapter::class)) .name("Podcini.realm") - .schemaVersion(3) + .schemaVersion(SCHEMA_VERSION_NUMBER) .build() realm = Realm.open(config) } 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 9114b6c2..50fce0ed 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 @@ -1,7 +1,6 @@ package ac.mdiq.podcini.storage.model import ac.mdiq.podcini.storage.database.Feeds.getFeed -import ac.mdiq.podcini.util.Logd import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.ext.realmSetOf import io.realm.kotlin.types.RealmList @@ -13,7 +12,6 @@ import io.realm.kotlin.types.annotations.Index import io.realm.kotlin.types.annotations.PrimaryKey import org.apache.commons.lang3.builder.ToStringBuilder import org.apache.commons.lang3.builder.ToStringStyle - import java.util.* /** @@ -54,7 +52,6 @@ class Episode : RealmObject { @Ignore var feed: Feed? = null get() { -// Logd(TAG, "feed.get() ${field == null} ${title}") if (field == null && feedId != null) field = getFeed(feedId!!) return field } @@ -138,26 +135,6 @@ class Episode : RealmObject { // this.hasChapters = false } - /** - * This constructor is used by DBReader. - */ -// constructor(id: Long, title: String?, link: String?, pubDate: Date?, paymentLink: String?, feedId: Long, -// hasChapters: Boolean, imageUrl: String?, state: Int, -// itemIdentifier: String?, autoDownloadEnabled: Boolean, podcastIndexChapterUrl: String?) { -// this.id = id -// this.title = title -// this.link = link -// this.pubDate = pubDate?.time ?: 0 -// this.paymentLink = paymentLink -// this.feedId = feedId -//// this.hasChapters = hasChapters -// this.imageUrl = imageUrl -// this.playState = state -// this.identifier = itemIdentifier -// this.isAutoDownloadEnabled = autoDownloadEnabled -// this.podcastIndexChapterUrl = podcastIndexChapterUrl -// } - /** * This constructor should be used for creating test objects. */ 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 a305eb3a..90162c24 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,10 @@ package ac.mdiq.podcini.storage.model -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk -import ac.mdiq.podcini.storage.utils.MediaType +import ac.mdiq.podcini.net.feed.parser.media.id3.ChapterReader 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 @@ -116,14 +116,14 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { this.downloaded = downloaded } - constructor(id: Long, item: Episode?, duration: Int, position: Int, - size: Long, mime_type: String?, file_url: String?, download_url: String?, - downloaded: Boolean, playbackCompletionDate: Date?, played_duration: Int, - hasEmbeddedPicture: Boolean?, lastPlayedTime: Long) - : this(id, item, duration, position, size, mime_type, file_url, download_url, downloaded, playbackCompletionDate, played_duration, lastPlayedTime) { - - this.hasEmbeddedPicture = hasEmbeddedPicture - } +// constructor(id: Long, item: Episode?, duration: Int, position: Int, +// size: Long, mime_type: String?, file_url: String?, download_url: String?, +// downloaded: Boolean, playbackCompletionDate: Date?, played_duration: Int, +// hasEmbeddedPicture: Boolean?, lastPlayedTime: Long) +// : this(id, item, duration, position, size, mime_type, file_url, download_url, downloaded, playbackCompletionDate, played_duration, lastPlayedTime) { +// +// this.hasEmbeddedPicture = hasEmbeddedPicture +// } fun getHumanReadableIdentifier(): String? { return if (episode?.title != null) episode!!.title else downloadUrl @@ -165,16 +165,16 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { return duration } - override fun setDuration(duration: Int) { - this.duration = duration + override fun setDuration(newDuration: Int) { + this.duration = newDuration } override fun getPosition(): Int { return position } - override fun setPosition(position: Int) { - this.position = position - if (position > 0 && episode != null && episode!!.isNew) episode!!.setPlayed(false) + override fun setPosition(newPosition: Int) { + this.position = newPosition + if (newPosition > 0 && episode != null && episode!!.isNew) episode!!.setPlayed(false) } override fun getLastPlayedTime(): Long { @@ -247,8 +247,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { // } override fun getEpisodeTitle(): String { - if (episode == null) return "No title" - return if (episode!!.title != null) episode!!.title!! else episode!!.identifyingValue?:"No title" + return episode?.title ?: episode?.identifyingValue ?: "No title" } override fun getChapters(): List { @@ -264,8 +263,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { } override fun getFeedTitle(): String { - if (episode == null) return "" - return episode!!.feed?.title?:"" + return episode?.feed?.title?:"" } override fun getIdentifier(): Any { @@ -368,6 +366,8 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { } companion object { + private val TAG: String = EpisodeMedia::class.simpleName ?: "Anonymous" + const val FEEDFILETYPE_FEEDMEDIA: Int = 2 const val PLAYABLE_TYPE_FEEDMEDIA: Int = 1 const val FILENAME_PREFIX_EMBEDDED_COVER: String = "metadata-retriever:" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt index 1c929196..4d535ba6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt @@ -29,7 +29,7 @@ class Feed : RealmObject { var fileUrl: String? = null var downloadUrl: String? = null - var downloaded: Boolean = false +// var downloaded: Boolean = false /** * title as defined by the feed. @@ -156,65 +156,33 @@ class Feed : RealmObject { } /** - * This constructor is used for restoring a feed from the database. + * This constructor is used for test purposes. */ - constructor(id: Long, lastUpdate: String?, title: String?, customTitle: String?, link: String?, - description: String?, paymentLinks: String?, author: String?, language: String?, - type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?, - downloadUrl: String?, downloaded: Boolean, paged: Boolean, nextPageLink: String?, - filter: String?, sortOrder: SortOrder?, lastUpdateFailed: Boolean) { + constructor(id: Long, lastUpdate: String?, title: String?, link: String?, description: String?, paymentLink: String?, + author: String?, language: String?, type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?, + downloadUrl: String?) { this.id = id this.fileUrl = fileUrl this.downloadUrl = downloadUrl - this.downloaded = downloaded this.eigenTitle = title this.customTitle = customTitle this.lastUpdate = lastUpdate this.link = link this.description = description - this.paymentLinks = extractPaymentLinks(paymentLinks) + this.paymentLinks = extractPaymentLinks(paymentLink) this.author = author this.language = language this.type = type this.identifier = feedIdentifier this.imageUrl = imageUrl - this.isPaged = paged + this.isPaged = false this.nextPageLink = nextPageLink -// if (filter != null) this.episodeFilter = EpisodeFilter(filter) -// else this.episodeFilter = EpisodeFilter() - this.preferences?.filterString = filter ?: "" + this.preferences?.filterString = "" this.sortOrder = sortOrder this.preferences?.sortOrderCode = sortOrder?.code ?: 0 this.lastUpdateFailed = lastUpdateFailed } - /** - * This constructor is used for test purposes. - */ - constructor(id: Long, lastUpdate: String?, title: String?, link: String?, description: String?, paymentLink: String?, - author: String?, language: String?, type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?, - downloadUrl: String?, downloaded: Boolean) - : this(id, - lastUpdate, - title, - null, - link, - description, - paymentLink, - author, - language, - type, - feedIdentifier, - imageUrl, - fileUrl, - downloadUrl, - downloaded, - false, - null, - null, - null, - false) - /** * This constructor can be used when parsing feed data. Only the 'lastUpdate' and 'items' field are initialized. */ @@ -228,7 +196,6 @@ class Feed : RealmObject { this.lastUpdate = lastUpdate fileUrl = null this.downloadUrl = url - downloaded = false } /** @@ -247,7 +214,7 @@ class Feed : RealmObject { preferences = FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password) } - fun getHumanReadableIdentifier(): String? { + fun getTextIdentifier(): String? { return when { !customTitle.isNullOrEmpty() -> customTitle !eigenTitle.isNullOrEmpty() -> eigenTitle diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt index c6cede50..7ba462a4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt @@ -22,9 +22,6 @@ class FeedPreferences(@Index var feedID: Long, @Ignore @JvmField var volumeAdaptionSetting: VolumeAdaptionSetting?, var username: String?, var password: String?, - /** - * @return the filter for this feed - */ @Ignore @JvmField var filter: FeedEpisodesFilter, var playSpeed: Float, var introSkip: Int, diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/CommonSymbols.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/CommonSymbols.kt deleted file mode 100644 index 3c2f322d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/CommonSymbols.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ac.mdiq.podcini.storage.transport - -open class CommonSymbols { - companion object { - const val HEAD: String = "head" - const val BODY: String = "body" - const val TITLE: String = "title" - - const val XML_FEATURE_INDENT_OUTPUT: String = "http://xmlpull.org/v1/doc/features.html#indent-output" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/DatabaseTransporter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/DatabaseTransporter.kt deleted file mode 100644 index 097a63c8..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/DatabaseTransporter.kt +++ /dev/null @@ -1,93 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.util.Logd -import android.content.Context -import android.net.Uri -import android.os.ParcelFileDescriptor -import android.text.format.Formatter -import android.util.Log -import org.apache.commons.io.FileUtils -import org.apache.commons.io.IOUtils -import java.io.* -import java.nio.channels.FileChannel - -object DatabaseTransporter { - private val TAG: String = DatabaseTransporter::class.simpleName ?: "Anonymous" - - @Throws(IOException::class) - fun exportToDocument(uri: Uri?, context: Context) { - var pfd: ParcelFileDescriptor? = null - var fileOutputStream: FileOutputStream? = null - try { - pfd = context.contentResolver.openFileDescriptor(uri!!, "wt") - fileOutputStream = FileOutputStream(pfd!!.fileDescriptor) - exportToStream(fileOutputStream, context) - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - IOUtils.closeQuietly(fileOutputStream) - if (pfd != null) { - try { - pfd.close() - } catch (e: IOException) { - Logd(TAG, "Unable to close ParcelFileDescriptor") - } - } - } - } - - @Throws(IOException::class) - fun exportToStream(outFileStream: FileOutputStream, context: Context) { - var src: FileChannel? = null - var dst: FileChannel? = null - try { - val realmPath = realm.configuration.path - Logd(TAG, "exportToStream realmPath: $realmPath") - val currentDB = File(realmPath) - - if (currentDB.exists()) { - src = FileInputStream(currentDB).channel - dst = outFileStream.channel - val srcSize = src.size() - dst.transferFrom(src, 0, srcSize) - - 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") - } - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - IOUtils.closeQuietly(src) - IOUtils.closeQuietly(dst) - } - } - - @Throws(IOException::class) - fun importBackup(inputUri: Uri?, context: Context) { - val TEMP_DB_NAME = "temp.realm" - var inputStream: InputStream? = null - try { - val tempDB = context.getDatabasePath(TEMP_DB_NAME) - inputStream = context.contentResolver.openInputStream(inputUri!!) - FileUtils.copyInputStreamToFile(inputStream, tempDB) - - val realmPath = realm.configuration.path - val currentDB = File(realmPath) - val success = currentDB.delete() - if (!success) throw IOException("Unable to delete old database") - - FileUtils.moveFile(tempDB, currentDB) - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - IOUtils.closeQuietly(inputStream) - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodeProgressReader.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodeProgressReader.kt deleted file mode 100644 index 0ab03053..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodeProgressReader.kt +++ /dev/null @@ -1,77 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid -import ac.mdiq.podcini.net.sync.model.EpisodeAction -import ac.mdiq.podcini.net.sync.model.EpisodeAction.Companion.readFromJsonObject -import ac.mdiq.podcini.storage.database.Episodes -import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl -import ac.mdiq.podcini.storage.database.Episodes.persistEpisodes -import ac.mdiq.podcini.storage.database.RealmDB.upsert -import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded -import ac.mdiq.podcini.util.Logd -import android.util.Log -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import org.json.JSONArray -import java.io.Reader - -/** Reads OPML documents. */ -object EpisodeProgressReader { - private const val TAG = "EpisodeProgressReader" - - @OptIn(UnstableApi::class) - 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) - } -// 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) { - Log.i(TAG, "Unknown feed item: $action") - return null - } - if (feedItem.media == null) { - Log.i(TAG, "Feed item has no media: $action") - return null - } - var idRemove = 0L - feedItem.media!!.setPosition(action.position * 1000) - feedItem.media!!.setLastPlayedTime(action.timestamp!!.time) - feedItem.isFavorite = action.isFavorite - feedItem.playState = action.playState - if (hasAlmostEnded(feedItem.media!!)) { - Logd(TAG, "Marking as played: $action") - feedItem.setPlayed(true) - feedItem.media!!.setPosition(0) - idRemove = feedItem.id - } else Logd(TAG, "Setting position: $action") - - return Pair(idRemove, feedItem) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodesProgressWriter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodesProgressWriter.kt deleted file mode 100644 index adeab3d6..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/EpisodesProgressWriter.kt +++ /dev/null @@ -1,73 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import ac.mdiq.podcini.net.sync.model.EpisodeAction -import ac.mdiq.podcini.net.sync.model.SyncServiceException -import ac.mdiq.podcini.storage.database.Episodes.getEpisodes -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.utils.EpisodeFilter -import ac.mdiq.podcini.storage.utils.SortOrder -import ac.mdiq.podcini.util.Logd -import android.content.Context -import org.apache.commons.lang3.StringUtils -import org.json.JSONArray -import java.io.IOException -import java.io.Writer -import java.util.* - -/** Writes saved favorites to file. */ -class EpisodesProgressWriter : ExportWriter { - - @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) - override fun writeDocument(feeds: List?, writer: Writer?, context: Context) { - Logd(TAG, "Starting to write document") - val queuedEpisodeActions: MutableList = mutableListOf() - val pausedItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.PAUSED), SortOrder.DATE_NEW_OLD) - val readItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.PLAYED), SortOrder.DATE_NEW_OLD) - val favoriteItems = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.IS_FAVORITE), SortOrder.DATE_NEW_OLD) - val comItems = mutableSetOf() - comItems.addAll(pausedItems) - comItems.addAll(readItems) - comItems.addAll(favoriteItems) - Logd(TAG, "Save state for all " + comItems.size + " played episodes") - for (item in comItems) { - val media = item.media ?: continue - val played = EpisodeAction.Builder(item, EpisodeAction.PLAY) - .timestamp(Date(media.getLastPlayedTime())) - .started(media.getPosition() / 1000) - .position(media.getPosition() / 1000) - .total(media.getDuration() / 1000) - .isFavorite(item.isFavorite) - .playState(item.playState) - .build() - queuedEpisodeActions.add(played) - } - - if (queuedEpisodeActions.isNotEmpty()) { - try { - Logd(TAG, "Saving ${queuedEpisodeActions.size} actions: ${StringUtils.join(queuedEpisodeActions, ", ")}") - val list = JSONArray() - for (episodeAction in queuedEpisodeActions) { - val obj = episodeAction.writeToJsonObject() - if (obj != null) { - Logd(TAG, "saving EpisodeAction: $obj") - list.put(obj) - } - } - writer?.write(list.toString()) - } catch (e: Exception) { - e.printStackTrace() - throw SyncServiceException(e) - } - } - Logd(TAG, "Finished writing document") - } - - override fun fileExtension(): String { - return "json" - } - - companion object { - private const val TAG = "EpisodesProgressWriter" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/ExportWriter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/ExportWriter.kt deleted file mode 100644 index 5698d1e9..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/ExportWriter.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ac.mdiq.podcini.storage.transport - -import android.content.Context -import ac.mdiq.podcini.storage.model.Feed -import java.io.IOException -import java.io.Writer - -interface ExportWriter { - @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) - fun writeDocument(feeds: List?, writer: Writer?, context: Context) - - fun fileExtension(): String? -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/FavoritesWriter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/FavoritesWriter.kt deleted file mode 100644 index fd6e792e..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/FavoritesWriter.kt +++ /dev/null @@ -1,110 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import ac.mdiq.podcini.storage.database.Episodes.getEpisodes -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.utils.EpisodeFilter -import ac.mdiq.podcini.storage.utils.SortOrder -import ac.mdiq.podcini.util.Logd -import android.content.Context -import org.apache.commons.io.IOUtils -import java.io.IOException -import java.io.Writer -import java.util.* - -/** Writes saved favorites to file. */ -class FavoritesWriter : ExportWriter { - @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") - var template = IOUtils.toString(templateStream, UTF_8) - template = template.replace("\\{TITLE\\}".toRegex(), "Favorites") - val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - - val favTemplateStream = context.assets.open(FAVORITE_TEMPLATE) - val favTemplate = IOUtils.toString(favTemplateStream, UTF_8) - - val feedTemplateStream = context.assets.open(FEED_TEMPLATE) - val feedTemplate = IOUtils.toString(feedTemplateStream, UTF_8) - - val allFavorites = getEpisodes(0, Int.MAX_VALUE, EpisodeFilter(EpisodeFilter.IS_FAVORITE), SortOrder.DATE_NEW_OLD) - val favoriteByFeed = getFeedMap(allFavorites) - - writer!!.append(templateParts[0]) - - for (feedId in favoriteByFeed.keys) { - val favorites: List = favoriteByFeed[feedId]!! - writer.append("
  • \n") - writeFeed(writer, favorites[0].feed, feedTemplate) - - writer.append("
      \n") - for (item in favorites) { - writeFavoriteItem(writer, item, favTemplate) - } - writer.append("
  • \n") - } - - writer.append(templateParts[1]) - - Logd(TAG, "Finished writing document") - } - - /** - * Group favorite episodes by feed, sorting them by publishing date in descending order. - * - * @param favoritesList `List` of all favorite episodes. - * @return A `Map` favorite episodes, keyed by feed ID. - */ - private fun getFeedMap(favoritesList: List): Map> { - val feedMap: MutableMap> = TreeMap() - - for (item in favoritesList) { - var feedEpisodes = feedMap[item.feedId] - - if (feedEpisodes == null) { - feedEpisodes = ArrayList() - if (item.feedId != null) feedMap[item.feedId!!] = feedEpisodes - } - - feedEpisodes.add(item) - } - - return feedMap - } - - @Throws(IOException::class) - private fun writeFeed(writer: Writer?, feed: Feed?, feedTemplate: String) { - val feedInfo = feedTemplate - .replace("{FEED_IMG}", feed!!.imageUrl!!) - .replace("{FEED_TITLE}", feed.title!!) - .replace("{FEED_LINK}", feed.link!!) - .replace("{FEED_WEBSITE}", feed.downloadUrl!!) - - writer!!.append(feedInfo) - } - - @Throws(IOException::class) - private fun writeFavoriteItem(writer: Writer?, item: Episode, favoriteTemplate: String) { - var favItem = favoriteTemplate.replace("{FAV_TITLE}", item.title!!.trim { it <= ' ' }) - favItem = if (item.link != null) favItem.replace("{FAV_WEBSITE}", item.link!!) - else favItem.replace("{FAV_WEBSITE}", "") - - favItem = if (item.media != null && item.media!!.downloadUrl != null) favItem.replace("{FAV_MEDIA}", item.media!!.downloadUrl!!) - else favItem.replace("{FAV_MEDIA}", "") - - writer!!.append(favItem) - } - - override fun fileExtension(): String { - return "html" - } - - companion object { - private val TAG: String = FavoritesWriter::class.simpleName ?: "Anonymous" - private const val FAVORITE_TEMPLATE = "html-export-favorites-item-template.html" - private const val FEED_TEMPLATE = "html-export-feed-template.html" - private const val UTF_8 = "UTF-8" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/HtmlWriter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/HtmlWriter.kt deleted file mode 100644 index 25a0776d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/HtmlWriter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import android.content.Context -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.util.Logd -import org.apache.commons.io.IOUtils -import java.io.IOException -import java.io.Writer - -/** Writes HTML documents. */ -class HtmlWriter : ExportWriter { - /** - * Takes a list of feeds and a writer and writes those into an HTML - * document. - */ - @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") - 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("
  • ") - writer.append(feed.title) - writer.append(" WebsiteFeed

  • \n") - } - writer.append(templateParts[1]) - Logd(TAG, "Finished writing document") - } - - override fun fileExtension(): String { - return "html" - } - - companion object { - private val TAG: String = HtmlWriter::class.simpleName ?: "Anonymous" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlElement.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlElement.kt deleted file mode 100644 index 8d07268a..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlElement.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -/** Represents a single feed in an OPML file. */ -class OpmlElement { - @JvmField - var text: String? = null - var xmlUrl: String? = null - var htmlUrl: String? = null - var type: String? = null -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlReader.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlReader.kt deleted file mode 100644 index 8608e456..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlReader.kt +++ /dev/null @@ -1,85 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import ac.mdiq.podcini.util.Logd -import android.util.Log -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserException -import org.xmlpull.v1.XmlPullParserFactory -import java.io.IOException -import java.io.Reader - -/** Reads OPML documents. */ -class OpmlReader { - // ATTRIBUTES - private var isInOpml = false - private var elementList: ArrayList? = null - - /** - * Reads an Opml document and returns a list of all OPML elements it can - * find - * - * @throws IOException - * @throws XmlPullParserException - */ - @Throws(XmlPullParserException::class, IOException::class) - fun readDocument(reader: Reader?): ArrayList { - elementList = ArrayList() - val factory = XmlPullParserFactory.newInstance() - factory.isNamespaceAware = true - val xpp = factory.newPullParser() - xpp.setInput(reader) - var eventType = xpp.eventType - - while (eventType != XmlPullParser.END_DOCUMENT) { - when (eventType) { - XmlPullParser.START_DOCUMENT -> Logd(TAG, "Reached beginning of document") - XmlPullParser.START_TAG -> when { - xpp.name == OpmlSymbols.OPML -> { - isInOpml = true - Logd(TAG, "Reached beginning of OPML tree.") - } - isInOpml && xpp.name == OpmlSymbols.OUTLINE -> { -// TODO: check more about this, java.io.IOException: Underlying input stream returned zero bytes - Logd(TAG, "Found new Opml element") - val element = OpmlElement() - - val title = xpp.getAttributeValue(null, CommonSymbols.TITLE) - if (title != null) { - Log.i(TAG, "Using title: $title") - element.text = title - } else { - Log.i(TAG, "Title not found, using text") - element.text = xpp.getAttributeValue(null, OpmlSymbols.TEXT) - } - element.xmlUrl = xpp.getAttributeValue(null, OpmlSymbols.XMLURL) - element.htmlUrl = xpp.getAttributeValue(null, OpmlSymbols.HTMLURL) - element.type = xpp.getAttributeValue(null, OpmlSymbols.TYPE) - if (element.xmlUrl != null) { - if (element.text == null) { - Log.i(TAG, "Opml element has no text attribute.") - element.text = element.xmlUrl - } - elementList!!.add(element) - } else { - Logd(TAG, "Skipping element because of missing xml url") - } - } - } - } - try { -// TODO: on first install app: java.io.IOException: Underlying input stream returned zero bytes - eventType = xpp.next() - } catch(e: Exception) { - Log.e(TAG, "xpp.next() invalid: $e") - break - } - } - Logd(TAG, "Parsing finished.") - - return elementList!! - } - - companion object { - private val TAG: String = OpmlReader::class.simpleName ?: "Anonymous" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlSymbols.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlSymbols.kt deleted file mode 100644 index d5859f82..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlSymbols.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -/** Contains symbols for reading and writing OPML documents. */ -internal object OpmlSymbols : CommonSymbols() { - const val OPML: String = "opml" - const val OUTLINE: String = "outline" - const val TEXT: String = "text" - const val XMLURL: String = "xmlUrl" - const val HTMLURL: String = "htmlUrl" - const val TYPE: String = "type" - const val VERSION: String = "version" - const val DATE_CREATED: String = "dateCreated" -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlWriter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlWriter.kt deleted file mode 100644 index 3daa2a5c..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/OpmlWriter.kt +++ /dev/null @@ -1,64 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import android.content.Context -import android.util.Xml -import ac.mdiq.podcini.util.DateFormatter.formatRfc822Date -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.util.Logd -import java.io.IOException -import java.io.Writer -import java.util.* - -/** Writes OPML documents. */ -class OpmlWriter : ExportWriter { - /** - * Takes a list of feeds and a writer and writes those into an OPML - * document. - */ - @Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class) - override fun writeDocument(feeds: List?, writer: Writer?, context: Context) { - Logd(TAG, "Starting to write document") - val xs = Xml.newSerializer() - xs.setFeature(CommonSymbols.XML_FEATURE_INDENT_OUTPUT, true) - xs.setOutput(writer) - - xs.startDocument(ENCODING, false) - xs.startTag(null, OpmlSymbols.OPML) - xs.attribute(null, OpmlSymbols.VERSION, OPML_VERSION) - - xs.startTag(null, CommonSymbols.HEAD) - xs.startTag(null, CommonSymbols.TITLE) - xs.text(OPML_TITLE) - xs.endTag(null, CommonSymbols.TITLE) - xs.startTag(null, OpmlSymbols.DATE_CREATED) - xs.text(formatRfc822Date(Date())) - xs.endTag(null, OpmlSymbols.DATE_CREATED) - xs.endTag(null, CommonSymbols.HEAD) - - xs.startTag(null, CommonSymbols.BODY) - for (feed in feeds!!) { - xs.startTag(null, OpmlSymbols.OUTLINE) - xs.attribute(null, OpmlSymbols.TEXT, feed!!.title) - xs.attribute(null, CommonSymbols.TITLE, feed.title) - if (feed.type != null) xs.attribute(null, OpmlSymbols.TYPE, feed.type) - xs.attribute(null, OpmlSymbols.XMLURL, feed.downloadUrl) - if (feed.link != null) xs.attribute(null, OpmlSymbols.HTMLURL, feed.link) - xs.endTag(null, OpmlSymbols.OUTLINE) - } - xs.endTag(null, CommonSymbols.BODY) - xs.endTag(null, OpmlSymbols.OPML) - xs.endDocument() - Logd(TAG, "Finished writing document") - } - - override fun fileExtension(): String { - return "opml" - } - - companion object { - private val TAG: String = OpmlWriter::class.simpleName ?: "Anonymous" - private const val ENCODING = "UTF-8" - private const val OPML_VERSION = "2.0" - private const val OPML_TITLE = "Podcini Subscriptions" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/PreferencesTransporter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/PreferencesTransporter.kt deleted file mode 100644 index dfefb93f..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/transport/PreferencesTransporter.kt +++ /dev/null @@ -1,118 +0,0 @@ -package ac.mdiq.podcini.storage.transport - -import ac.mdiq.podcini.BuildConfig -import android.content.Context -import android.net.Uri -import android.util.Log -import androidx.documentfile.provider.DocumentFile -import java.io.* - -object PreferencesTransporter { - private val TAG: String = PreferencesTransporter::class.simpleName ?: "Anonymous" - - @Throws(IOException::class) - fun exportToDocument(uri: Uri, context: Context) { - try { - val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid") - val exportSubDir = chosenDir.createDirectory("Podcini-Prefs") ?: throw IOException("Error creating subdirectory Podcini-Prefs") - val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> - file.name.startsWith("shared_prefs") - }?.firstOrNull() - if (sharedPreferencesDir != null) { - sharedPreferencesDir.listFiles()!!.forEach { file -> - val destFile = exportSubDir.createFile("text/xml", file.name) - if (destFile != null) copyFile(file, destFile, context) - } - } else { - Log.e("Error", "shared_prefs directory not found") - } - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { } - } - - private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) { - try { - val inputStream = FileInputStream(sourceFile) - val outputStream = context.contentResolver.openOutputStream(destFile.uri) - if (outputStream != null) copyStream(inputStream, outputStream) - inputStream.close() - outputStream?.close() - } catch (e: IOException) { - Log.e("Error", "Error copying file: $e") - throw e - } - } - - private fun copyFile(sourceFile: DocumentFile, destFile: File, context: Context) { - try { - val inputStream = context.contentResolver.openInputStream(sourceFile.uri) - val outputStream = FileOutputStream(destFile) - if (inputStream != null) copyStream(inputStream, outputStream) - inputStream?.close() - outputStream.close() - } catch (e: IOException) { - Log.e("Error", "Error copying file: $e") - throw e - } - } - - private fun copyStream(inputStream: InputStream, outputStream: OutputStream) { - val buffer = ByteArray(1024) - var bytesRead: Int - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - } - } - - @Throws(IOException::class) - fun importBackup(uri: Uri, context: Context) { - try { - val exportedDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Backup directory is not valid") - val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file -> - file.name.startsWith("shared_prefs") - }?.firstOrNull() - if (sharedPreferencesDir != null) { - sharedPreferencesDir.listFiles()?.forEach { file -> -// val prefName = file.name.substring(0, file.name.lastIndexOf('.')) - file.delete() - } - } else { - Log.e("Error", "shared_prefs directory not found") - } - val files = exportedDir.listFiles() - var hasPodciniRPrefs = false - for (file in files) { - if (file?.isFile == true && file.name?.endsWith(".xml") == true && file.name!!.contains("podcini.R")) { - hasPodciniRPrefs = true - break - } - } - for (file in files) { - if (file?.isFile == true && file.name?.endsWith(".xml") == true) { - var destName = file.name!! -// for importing from Podcini version 5 and below - if (!hasPodciniRPrefs) { - when { - destName.contains("podcini") -> destName = destName.replace("podcini", "podcini.R") - destName.contains("EpisodeItemListRecyclerView") -> destName = destName.replace("EpisodeItemListRecyclerView", "EpisodesRecyclerView") - } - } - when { -// for debug version importing release version - BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug") -// for release version importing debug version - !BuildConfig.DEBUG && destName.contains(".debug") -> destName = destName.replace(".debug", "") - } - val destFile = File(sharedPreferencesDir, destName) - copyFile(file, destFile, context) - } - } - } catch (e: IOException) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { } - - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt index e670e4fe..3109b59e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt @@ -67,10 +67,7 @@ object ChapterUtils { try { openStream(playable, context).use { inVal -> val chapters = readId3ChaptersFrom(inVal) - if (chapters.isNotEmpty()) { - Log.i(TAG, "Chapters loaded") - return chapters - } + if (chapters.isNotEmpty()) return chapters } } catch (e: IOException) { Log.e(TAG, "Unable to load ID3 chapters: " + e.message) @@ -81,10 +78,7 @@ object ChapterUtils { try { openStream(playable, context).use { inVal -> val chapters = readOggChaptersFromInputStream(inVal) - if (chapters.isNotEmpty()) { - Log.i(TAG, "Chapters loaded") - return chapters - } + if (chapters.isNotEmpty()) return chapters } } catch (e: IOException) { Log.e(TAG, "Unable to load vorbis chapters: " + e.message) @@ -151,7 +145,7 @@ object ChapterUtils { chapters = chapters.sortedWith(ChapterStartTimeComparator()) enumerateEmptyChapterTitles(chapters) if (!chaptersValid(chapters)) { - Log.i(TAG, "Chapter data was invalid") + Logd(TAG, "Chapter data was invalid") return emptyList() } return chapters diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt index 4888365d..1559ad9b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt @@ -1,10 +1,10 @@ package ac.mdiq.podcini.ui.actions.actionbutton import ac.mdiq.podcini.R +import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.base.InTheatre -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia @@ -40,7 +40,7 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) { } if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) { - playbackService?.mediaPlayer?.resume() + playbackService?.mPlayer?.resume() playbackService?.taskManager?.restartSleepTimer() } else { PlaybackServiceStarter(context, media).callEvenIfRunning(true).start() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt index 8883904d..f3d14115 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt @@ -1,10 +1,10 @@ package ac.mdiq.podcini.ui.actions.actionbutton import ac.mdiq.podcini.R +import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.base.InTheatre -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.util.Logd @@ -30,7 +30,7 @@ class PlayLocalActionButton(item: Episode) : EpisodeActionButton(item) { } if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) { - playbackService?.mediaPlayer?.resume() + playbackService?.mPlayer?.resume() playbackService?.taskManager?.restartSleepTimer() } else { PlaybackServiceStarter(context, media).callEvenIfRunning(true).start() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt index 4c76b7ec..3f171c70 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt @@ -2,7 +2,6 @@ package ac.mdiq.podcini.ui.actions.actionbutton import ac.mdiq.podcini.R import ac.mdiq.podcini.playback.PlaybackServiceStarter -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent import ac.mdiq.podcini.preferences.UsageStatistics import ac.mdiq.podcini.preferences.UsageStatistics.logAction import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming @@ -11,6 +10,7 @@ import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.RemoteMedia import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed +import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import android.content.Context 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 220c2940..f74c23af 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -19,6 +19,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.defaultPage import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent import ac.mdiq.podcini.receiver.PlayerWidget +import ac.mdiq.podcini.storage.database.Feeds.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 @@ -95,7 +96,7 @@ class MainActivity : CastEnabledActivity() { private lateinit var mainView: View private lateinit var navDrawerFragment: NavDrawerFragment private lateinit var audioPlayerFragment: AudioPlayerFragment - private lateinit var audioPlayerFragmentView: View + private lateinit var audioPlayerView: View private lateinit var controllerFuture: ListenableFuture private lateinit var navDrawer: View private lateinit var dummyView : View @@ -130,6 +131,7 @@ class MainActivity : CastEnabledActivity() { SwipeActions.getSharedPrefs(this@MainActivity) QueueFragment.getSharedPrefs(this@MainActivity) updateFeedMap() + monitorFeeds() // InTheatre.apply { } PlayerDetailsFragment.getSharedPrefs(this@MainActivity) PlayerWidget.getSharedPrefs(this@MainActivity) @@ -196,11 +198,11 @@ class MainActivity : CastEnabledActivity() { transaction.replace(R.id.audioplayerFragment, audioPlayerFragment, AudioPlayerFragment.TAG) transaction.commit() navDrawer = findViewById(R.id.navDrawerFragment) - audioPlayerFragmentView = findViewById(R.id.audioplayerFragment) + audioPlayerView = findViewById(R.id.audioplayerFragment) runOnIOScope { checkFirstLaunch() } - this.bottomSheet = BottomSheetBehavior.from(audioPlayerFragmentView) as LockableBottomSheetBehavior<*> + this.bottomSheet = BottomSheetBehavior.from(audioPlayerView) as LockableBottomSheetBehavior<*> this.bottomSheet.isHideable = false this.bottomSheet.isDraggable = false this.bottomSheet.setBottomSheetCallback(bottomSheetCallback) @@ -382,7 +384,7 @@ class MainActivity : CastEnabledActivity() { get() = drawerLayout?.isDrawerOpen(navDrawer)?:false private fun updateInsets() { - setPlayerVisible(audioPlayerFragmentView.visibility == View.VISIBLE) + setPlayerVisible(audioPlayerView.visibility == View.VISIBLE) val playerHeight = resources.getDimension(R.dimen.external_player_height).toInt() bottomSheet.peekHeight = playerHeight + navigationBarInsets.bottom } @@ -403,7 +405,11 @@ class MainActivity : CastEnabledActivity() { val playerParams = playerView?.layoutParams as? MarginLayoutParams playerParams?.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, 0) playerView.layoutParams = playerParams - audioPlayerFragmentView.visibility = if (visible) View.VISIBLE else View.GONE + audioPlayerView.visibility = if (visible) View.VISIBLE else View.GONE + } + + fun isPlayerVisible(): Boolean { + return audioPlayerView.visibility == View.VISIBLE } fun loadFragment(tag: String?, args: Bundle?) { @@ -669,7 +675,7 @@ class MainActivity : CastEnabledActivity() { val s: Snackbar if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) { s = Snackbar.make(mainView, text!!, duration) - if (audioPlayerFragmentView.visibility == View.VISIBLE) s.setAnchorView(audioPlayerFragmentView) + if (audioPlayerView.visibility == View.VISIBLE) s.setAnchorView(audioPlayerView) } else s = Snackbar.make(binding.root, text!!, duration) s.show() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OpmlImportActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OpmlImportActivity.kt index 791712db..db6e292a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OpmlImportActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OpmlImportActivity.kt @@ -5,8 +5,8 @@ import ac.mdiq.podcini.databinding.OpmlSelectionBinding import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme import ac.mdiq.podcini.storage.database.Feeds.updateFeed -import ac.mdiq.podcini.storage.transport.OpmlElement -import ac.mdiq.podcini.storage.transport.OpmlReader +import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlElement +import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlReader import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.util.Logd import android.Manifest diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt index 453396a9..537d0293 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt @@ -5,12 +5,12 @@ import ac.mdiq.podcini.databinding.AudioControlsBinding import ac.mdiq.podcini.databinding.VideoplayerActivityBinding import ac.mdiq.podcini.playback.PlaybackController import ac.mdiq.podcini.playback.PlaybackController.Companion.duration +import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.cast.CastEnabledActivity -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isCasting import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.storage.database.Episodes.setFavorite @@ -83,7 +83,7 @@ class VideoplayerActivity : CastEnabledActivity() { finish() } if (videoMode != VideoMode.FULL_SCREEN_VIEW && videoMode != VideoMode.WINDOW_VIEW) { - Log.i(TAG, "videoMode not selected, use window mode") + Logd(TAG, "videoMode not selected, use window mode") videoMode = VideoMode.WINDOW_VIEW } } @@ -434,7 +434,7 @@ class VideoplayerActivity : CastEnabledActivity() { butAudioTracks.text = audioTracks[selectedAudioTrack] butAudioTracks.setOnClickListener { // setAudioTrack((selectedAudioTrack + 1) % audioTracks.size) - playbackService?.mediaPlayer?.setAudioTrack((selectedAudioTrack + 1) % audioTracks.size) + playbackService?.mPlayer?.setAudioTrack((selectedAudioTrack + 1) % audioTracks.size) Handler(Looper.getMainLooper()).postDelayed({ this.setupAudioTracks() }, 500) } } @@ -455,13 +455,13 @@ class VideoplayerActivity : CastEnabledActivity() { private val audioTracks: List get() { - val tracks = playbackService?.mediaPlayer?.getAudioTracks() + val tracks = playbackService?.mPlayer?.getAudioTracks() if (tracks.isNullOrEmpty()) return emptyList() return tracks.filterNotNull().map { it } } private val selectedAudioTrack: Int - get() = playbackService?.mediaPlayer?.getSelectedAudioTrack() ?: -1 + get() = playbackService?.mPlayer?.getSelectedAudioTrack() ?: -1 private fun getWebsiteLinkWithFallback(media: Playable?): String? { return when { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt index f94efcdb..069e2f33 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt @@ -18,6 +18,7 @@ import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme import ac.mdiq.podcini.receiver.PlayerWidget import ac.mdiq.podcini.receiver.PlayerWidget.Companion.prefs import ac.mdiq.podcini.ui.widget.WidgetUpdaterWorker +import ac.mdiq.podcini.util.Logd class WidgetConfigActivity : AppCompatActivity() { @@ -63,9 +64,7 @@ class WidgetConfigActivity : AppCompatActivity() { val color = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress) widgetPreview.setBackgroundColor(color) } - override fun onStartTrackingTouch(seekBar: SeekBar) {} - override fun onStopTrackingTouch(seekBar: SeekBar) {} }) @@ -96,6 +95,8 @@ class WidgetConfigActivity : AppCompatActivity() { } private fun setInitialState() { + PlayerWidget.getSharedPrefs(this) + // val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE) ckPlaybackSpeed.isChecked = prefs!!.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, true) ckRewind.isChecked = prefs!!.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, true) @@ -123,6 +124,7 @@ class WidgetConfigActivity : AppCompatActivity() { private fun confirmCreateWidget() { val backgroundColor = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress) + Logd("WidgetConfigActivity", "confirmCreateWidget appWidgetId $appWidgetId") // val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE) val editor = prefs!!.edit() editor.putInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, backgroundColor) 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 04e6e6ae..4090ecd3 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 @@ -4,6 +4,7 @@ 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.model.Episode +import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment @@ -25,12 +26,27 @@ open class EpisodesAdapter(mainActivity: MainActivity) private val TAG: String = this::class.simpleName ?: "Anonymous" val mainActivityRef: WeakReference = WeakReference(mainActivity) + protected val activity: Activity? + get() = mainActivityRef.get() private var episodes: List = ArrayList() + private var feed: Feed? = null var longPressedItem: Episode? = null private var longPressedPosition: Int = 0 // used to init actionMode private var dummyViews = 0 + val selectedItems: List + get() { + val items: MutableList = ArrayList() + for (i in 0 until itemCount) { + if (i < episodes.size && isSelected(i)) { + val item = getItem(i) + if (item != null) items.add(item) + } + } + return items + } + init { setHasStableIds(true) } @@ -40,8 +56,9 @@ open class EpisodesAdapter(mainActivity: MainActivity) notifyDataSetChanged() } - fun updateItems(items: List) { + fun updateItems(items: List, feed_: Feed? = null) { episodes = items + feed = feed_ notifyDataSetChanged() updateTitle() } @@ -72,6 +89,7 @@ open class EpisodesAdapter(mainActivity: MainActivity) beforeBindViewHolder(holder, pos) val item: Episode = unmanagedCopy(episodes[pos]) + if (feed != null) item.feed = feed holder.bind(item) // holder.infoCard.setOnCreateContextMenuListener(this) @@ -154,9 +172,6 @@ open class EpisodesAdapter(mainActivity: MainActivity) return item } - protected val activity: Activity? - get() = mainActivityRef.get() - @UnstableApi override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { val inflater: MenuInflater = activity!!.menuInflater if (inActionMode()) { @@ -188,16 +203,4 @@ open class EpisodesAdapter(mainActivity: MainActivity) else -> return false } } - - val selectedItems: List - get() { - val items: MutableList = ArrayList() - for (i in 0 until itemCount) { - if (i < episodes.size && isSelected(i)) { - val item = getItem(i) - if (item != null) items.add(item) - } - } - return items - } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/OnlineFeedsAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/OnlineFeedsAdapter.kt index 239b29a6..f33c0a24 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/OnlineFeedsAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/OnlineFeedsAdapter.kt @@ -58,17 +58,6 @@ class OnlineFeedsAdapter(private val context: Context, objects: List(private val cont val binding = SimpleIconListItemBinding.bind(view!!) binding.title.text = item.title binding.subtitle.text = item.subtitle -// if (item.imageUrl.isNotBlank()) Glide.with(context) -// .load(item.imageUrl) -// .apply(RequestOptions() -// .diskCacheStrategy(DiskCacheStrategy.NONE) -// .fitCenter() -// .dontAnimate()) -// .into(binding.icon) binding.icon.load(item.imageUrl) return view } 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 eb76c6b2..ec9872ae 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 @@ -48,7 +48,7 @@ object RemoveFeedDialog { for (feed in feeds) { deleteFeed(context, feed.id, false) } - EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feeds)) +// EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds)) } withContext(Dispatchers.Main) { Logd(TAG, "Feed(s) deleted") 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 040582c5..a5d9bd41 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 @@ -4,6 +4,8 @@ import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.SpeedSelectDialogBinding import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService +import ac.mdiq.podcini.playback.base.InTheatre.curEpisode +import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType import ac.mdiq.podcini.preferences.UserPreferences @@ -138,7 +140,7 @@ import java.util.* skipSilence.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> isSkipSilence = isChecked // setSkipSilence(isChecked) - playbackService?.mediaPlayer?.setPlaybackParams(playbackService!!.currentPlaybackSpeed, isChecked) + playbackService?.mPlayer?.setPlaybackParams(playbackService!!.curSpeed, isChecked) } return binding.root @@ -217,13 +219,13 @@ import java.util.* if (currentMediaType == MediaType.VIDEO) { curState.curTempSpeed = speed videoPlaybackSpeed = speed - playbackService!!.mediaPlayer?.setPlaybackParams(speed, isSkipSilence) + playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) } else { if (codeArray != null && codeArray.size == 3) { Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}") if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed) if (codeArray[1]) { - val episode = (playbackService!!.playable as? EpisodeMedia)?.episode ?: playbackService!!.currentitem + val episode = (curMedia as? EpisodeMedia)?.episode ?: curEpisode if (episode != null) { var feed = episode.feed if (feed != null) { @@ -240,11 +242,11 @@ import java.util.* } if (codeArray[0]) { curState.curTempSpeed = speed - playbackService!!.mediaPlayer?.setPlaybackParams(speed, isSkipSilence) + playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) } } else { curState.curTempSpeed = speed - playbackService!!.mediaPlayer?.setPlaybackParams(speed, isSkipSilence) + playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence) } } } 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 ea0ae35c..1794a037 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 @@ -62,20 +62,14 @@ import kotlin.math.min override fun loadData(): List { allEpisodes = getEpisodes(0, Int.MAX_VALUE, getFilter(), allEpisodesSortOrder, false) - Logd(TAG, "loadData() allEpisodes.size ${allEpisodes.size}") - return allEpisodes.subList(0, page * EPISODES_PER_PAGE) -// return getEpisodes(0, page * EPISODES_PER_PAGE, getFilter(), allEpisodesSortOrder) + return allEpisodes.subList(0, min(allEpisodes.size-1, page * EPISODES_PER_PAGE)) } override fun loadMoreData(page: Int): List { val offset = (page - 1) * EPISODES_PER_PAGE - Logd(TAG, "loadMoreData() page: $page $offset ${allEpisodes.size}") if (offset >= allEpisodes.size) return listOf() val toIndex = offset + EPISODES_PER_PAGE - Logd(TAG, "loadMoreData() $offset $toIndex ${min(allEpisodes.size, toIndex)}") return allEpisodes.subList(offset, min(allEpisodes.size, toIndex)) -// return allEpisodes.subList((page - 1) * EPISODES_PER_PAGE, EPISODES_PER_PAGE) -// return getEpisodes((page - 1) * EPISODES_PER_PAGE, EPISODES_PER_PAGE, getFilter(), allEpisodesSortOrder) } override fun loadTotalItemCount(): Int { @@ -86,10 +80,6 @@ import kotlin.math.min return EpisodeFilter(prefFilterAllEpisodes) } - override fun getFragmentTag(): String { - return TAG - } - override fun getPrefName(): String { return PREF_NAME } @@ -162,7 +152,7 @@ import kotlin.math.min override fun onSelectionChanged() { super.onSelectionChanged() allEpisodesSortOrder = sortOrder - EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(0)) + EventFlow.postEvent(FlowEvent.FeedsSortedEvent()) } } 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 88fe708a..62dc7e68 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 @@ -2,33 +2,31 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding -import ac.mdiq.podcini.databinding.InternalPlayerFragmentBinding -import ac.mdiq.podcini.storage.utils.ChapterUtils -import ac.mdiq.podcini.storage.utils.ImageResourceUtils +import ac.mdiq.podcini.databinding.PlayerUiFragmentBinding import ac.mdiq.podcini.playback.PlaybackController +import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier import ac.mdiq.podcini.playback.PlaybackController.Companion.duration import ac.mdiq.podcini.playback.PlaybackController.Companion.fallbackSpeed +import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.PlaybackController.Companion.isPlayingVideoLocally import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService -import ac.mdiq.podcini.playback.PlaybackController.Companion.position import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.base.InTheatre.loadPlayableFromPreferences import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.cast.CastEnabledActivity -import ac.mdiq.podcini.playback.service.PlaybackService 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.model.Chapter -import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Playable +import ac.mdiq.podcini.storage.utils.ChapterUtils +import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity @@ -41,7 +39,6 @@ import ac.mdiq.podcini.ui.dialog.SleepTimerDialog import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog import ac.mdiq.podcini.ui.view.ChapterSeekBar import ac.mdiq.podcini.ui.view.PlayButton -import ac.mdiq.podcini.ui.view.PlaybackSpeedIndicatorView import ac.mdiq.podcini.util.Converter import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.TimeSpeedConverter @@ -52,7 +49,6 @@ import android.content.Intent import android.os.Bundle import android.util.Log import android.view.* -import android.widget.ImageButton import android.widget.ImageView import android.widget.SeekBar import android.widget.TextView @@ -93,23 +89,20 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar private var playerDetailsFragment: PlayerDetailsFragment? = null private lateinit var toolbar: MaterialToolbar - private var playerFragment1: InternalPlayerFragment? = null - private var playerFragment2: InternalPlayerFragment? = null - private var playerFragment: InternalPlayerFragment? = null - private var playerView1: View? = null - private var playerView2: View? = null + private var playerUI1: PlayerUIFragment? = null + private var playerUI2: PlayerUIFragment? = null + private var playerUI: PlayerUIFragment? = null + private var playerUIView1: View? = null + private var playerUIView2: View? = null private lateinit var cardViewSeek: CardView - private lateinit var txtvSeek: TextView private var controller: PlaybackController? = null private var seekedToChapterStart = false // private var currentChapterIndex = -1 - private var duration = 0 private var currentMedia: Playable? = null - private var currentitem: Episode? = null private var isShowPlay: Boolean = false var isCollapsed = true @@ -136,25 +129,21 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar controller = createController() controller!!.init() - playerFragment1 = InternalPlayerFragment.newInstance(controller!!) + playerUI1 = PlayerUIFragment.newInstance(controller!!) childFragmentManager.beginTransaction() - .replace(R.id.playerFragment1, playerFragment1!!, "InternalPlayerFragment1") + .replace(R.id.playerFragment1, playerUI1!!, "InternalPlayerFragment1") .commit() - playerView1 = binding.root.findViewById(R.id.playerFragment1) - playerView1?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) + playerUIView1 = binding.root.findViewById(R.id.playerFragment1) + playerUIView1?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) - playerFragment2 = InternalPlayerFragment.newInstance(controller!!) + playerUI2 = PlayerUIFragment.newInstance(controller!!) childFragmentManager.beginTransaction() - .replace(R.id.playerFragment2, playerFragment2!!, "InternalPlayerFragment2") + .replace(R.id.playerFragment2, playerUI2!!, "InternalPlayerFragment2") .commit() - playerView2 = binding.root.findViewById(R.id.playerFragment2) - playerView2?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) - + playerUIView2 = binding.root.findViewById(R.id.playerFragment2) + playerUIView2?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) onCollaped() - cardViewSeek = binding.cardViewSeek - txtvSeek = binding.txtvSeek - return binding.root } @@ -184,18 +173,18 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar transaction.replace(R.id.itemDescription, playerDetailsFragment!!).commit() } isCollapsed = false - playerFragment = playerFragment2 - playerFragment?.updateUi(currentMedia) - playerFragment?.butPlay?.setIsShowPlay(isShowPlay) - playerDetailsFragment?.load() + playerUI = playerUI2 + playerUI?.updateUi(currentMedia) + playerUI?.butPlay?.setIsShowPlay(isShowPlay) + playerDetailsFragment?.updateInfo() } fun onCollaped() { Logd(TAG, "onCollaped()") isCollapsed = true - playerFragment = playerFragment1 - playerFragment?.updateUi(currentMedia) - playerFragment?.butPlay?.setIsShowPlay(isShowPlay) + playerUI = playerUI1 + playerUI?.updateUi(currentMedia) + playerUI?.butPlay?.setIsShowPlay(isShowPlay) } private fun setChapterDividers(media: Playable?) { @@ -218,11 +207,15 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar // } fun loadMediaInfo(includingChapters: Boolean) { + val actMain = (activity as MainActivity) if (curMedia == null) { - (activity as MainActivity).setPlayerVisible(false) + if (actMain.isPlayerVisible()) actMain.setPlayerVisible(false) return } - (activity as MainActivity).setPlayerVisible(true) + if (!actMain.isPlayerVisible()) actMain.setPlayerVisible(true) + + if (!isCollapsed && (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier())) playerDetailsFragment?.updateInfo() + if (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier() || (includingChapters && !curMedia!!.chaptersLoaded())) { Logd(TAG, "loadMediaInfo loading details ${curMedia?.getIdentifier()} chapter: $includingChapters") lifecycleScope.launch { @@ -232,9 +225,14 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } } currentMedia = media + if (currentMedia is EpisodeMedia) { + val item = (currentMedia as EpisodeMedia).episode + if (item != null) playerDetailsFragment?.setItem(item) + } updateUi() - playerFragment?.updateUi(currentMedia) - if (!includingChapters) loadMediaInfo(true) + playerUI?.updateUi(currentMedia) +// TODO: disable for now +// if (!includingChapters) loadMediaInfo(true) }.invokeOnCompletion { throwable -> if (throwable!= null) { Log.e(TAG, Log.getStackTraceString(throwable)) @@ -247,16 +245,16 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar return object : PlaybackController(requireActivity()) { override fun updatePlayButtonShowsPlay(showPlay: Boolean) { isShowPlay = showPlay - playerFragment?.butPlay?.setIsShowPlay(showPlay) + playerUI?.butPlay?.setIsShowPlay(showPlay) // playerFragment2?.butPlay?.setIsShowPlay(showPlay) } override fun loadMediaInfo() { this@AudioPlayerFragment.loadMediaInfo(false) - if (!isCollapsed) playerDetailsFragment?.load() + if (!isCollapsed) playerDetailsFragment?.updateInfo() } override fun onPlaybackEnd() { isShowPlay = true - playerFragment?.butPlay?.setIsShowPlay(true) + playerUI?.butPlay?.setIsShowPlay(true) // playerFragment2?.butPlay?.setIsShowPlay(true) (activity as MainActivity).setPlayerVisible(false) } @@ -274,13 +272,20 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar retainInstance = true } + override fun onResume() { + Logd(TAG, "onResume() isCollapsed: $isCollapsed") + super.onResume() + } + override fun onStart() { + Logd(TAG, "onStart() isCollapsed: $isCollapsed") super.onStart() procFlowEvents() loadMediaInfo(false) } override fun onStop() { + Logd(TAG, "onStop()") super.onStop() cancelFlowEvents() // progressIndicator.visibility = View.GONE // Controller released; we will not receive buffering updates @@ -307,19 +312,23 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar private fun onEvenStartPlay(event: FlowEvent.PlayEvent) { Logd(TAG, "onEvenStartPlay ${event.episode.title}") - currentitem = event.episode - if (currentMedia?.getIdentifier() == null || currentitem!!.media?.getIdentifier() != currentMedia?.getIdentifier()) - playerDetailsFragment?.setItem(currentitem!!) + val currentitem = event.episode + if (currentMedia?.getIdentifier() == null || currentitem.media?.getIdentifier() != currentMedia?.getIdentifier()) { + currentMedia = currentitem.media + playerDetailsFragment?.setItem(currentitem) + } (activity as MainActivity).setPlayerVisible(true) } private var eventSink: Job? = null private fun cancelFlowEvents() { + Logd(TAG, "cancelFlowEvents") eventSink?.cancel() eventSink = null } private fun procFlowEvents() { if (eventSink != null) return + Logd(TAG, "procFlowEvents") eventSink = lifecycleScope.launch { EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") @@ -327,25 +336,27 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar is FlowEvent.PlaybackServiceEvent -> { if (event.action == FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) (activity as MainActivity).bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED - playerFragment?.onPlaybackServiceChanged(event) + playerUI?.onPlaybackServiceChanged(event) } is FlowEvent.PlayEvent -> onEvenStartPlay(event) is FlowEvent.PlayerErrorEvent -> MediaPlayerErrorDialog.show(activity as Activity, event) is FlowEvent.FavoritesEvent -> loadMediaInfo(false) is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) loadMediaInfo(false) - - is FlowEvent.PlaybackPositionEvent -> playerFragment?.onPositionUpdate(event) - is FlowEvent.SpeedChangedEvent -> playerFragment?.updatePlaybackSpeedButton(event) - + is FlowEvent.PlaybackPositionEvent -> onPositionUpdate(event) + is FlowEvent.SpeedChangedEvent -> playerUI?.updatePlaybackSpeedButton(event) else -> {} } } } } + private fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) { +// if (!isCollapsed) loadMediaInfo(false) + playerUI?.onPositionUpdate(event) + } + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { if (controller == null) return - when { fromUser -> { val prog: Float = progress / (seekBar.max.toFloat()) @@ -362,10 +373,10 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar // updateUi(controller!!.getMedia) // sbPosition.highlightCurrentChapter() // } - txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${Converter.getDurationStringLong(position)}") - } else txtvSeek.text = Converter.getDurationStringLong(position) + binding.txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${Converter.getDurationStringLong(position)}") + } else binding.txtvSeek.text = Converter.getDurationStringLong(position) } - duration != playbackService?.duration -> updateUi() + duration != playbackService?.curDuration -> updateUi() } } @@ -403,8 +414,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val isEpisodeMedia = media is EpisodeMedia toolbar.menu?.findItem(R.id.open_feed_item)?.setVisible(isEpisodeMedia) - var item = currentitem - if (item == null && isEpisodeMedia) item = (media as EpisodeMedia).episode + val item = if (isEpisodeMedia) (media as EpisodeMedia).episode else null EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item) val mediaType = curMedia?.getMediaType() @@ -419,15 +429,16 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar override fun onMenuItemClick(menuItem: MenuItem): Boolean { val media: Playable = curMedia ?: return false - - var feedItem = currentitem - if (feedItem == null && media is EpisodeMedia) feedItem = media.episode -// feedItem: FeedItem? = if (media is EpisodeMedia) media.item else null + val feedItem = if (media is EpisodeMedia) media.episode else null if (feedItem != null && EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, feedItem)) return true val itemId = menuItem.itemId when (itemId) { - R.id.show_home_reader_view -> playerDetailsFragment?.buildHomeReaderText() + R.id.show_home_reader_view -> { + if (playerDetailsFragment?.showHomeText == true) menuItem.setIcon(R.drawable.ic_home) + else menuItem.setIcon(R.drawable.outline_home_24) + playerDetailsFragment?.buildHomeReaderText() + } R.id.show_video -> { controller!!.playPause() VideoPlayerActivityStarter(requireContext(), VideoMode.FULL_SCREEN_VIEW).start() @@ -463,7 +474,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar fun fadePlayerToToolbar(slideOffset: Float) { val playerFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.2f).toDouble())) / 0.2f).toFloat() - val player = playerView1 + val player = playerUIView1 player?.alpha = 1 - playerFadeProgress player?.visibility = if (playerFadeProgress > 0.99f) View.INVISIBLE else View.VISIBLE val toolbarFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.6f).toDouble())) / 0.2f).toFloat() @@ -471,65 +482,37 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar toolbar.visibility = if (toolbarFadeProgress < 0.01f) View.INVISIBLE else View.VISIBLE } - class InternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener { + class PlayerUIFragment : Fragment(), SeekBar.OnSeekBarChangeListener { val TAG = this::class.simpleName ?: "Anonymous" - private var _binding: InternalPlayerFragmentBinding? = null + private var _binding: PlayerUiFragmentBinding? = null private val binding get() = _binding!! - private lateinit var imgvCover: ImageView var butPlay: PlayButton? = null - private var isControlButtonsSet = false - - private lateinit var butPlaybackSpeed: PlaybackSpeedIndicatorView - private lateinit var txtvPlaybackSpeed: TextView - - private lateinit var episodeTitle: TextView - private lateinit var butRev: ImageButton - private lateinit var txtvRev: TextView - private lateinit var butFF: ImageButton - private lateinit var txtvFF: TextView - private lateinit var butSkip: ImageButton - private lateinit var txtvSkip: TextView - - private lateinit var txtvPosition: TextView private lateinit var txtvLength: TextView private lateinit var sbPosition: ChapterSeekBar - private var prevMedia: Playable? = null - private var showTimeLeft = false @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = InternalPlayerFragmentBinding.inflate(inflater) + _binding = PlayerUiFragmentBinding.inflate(inflater) Logd(TAG, "fragment onCreateView") - episodeTitle = binding.titleView - butPlaybackSpeed = binding.butPlaybackSpeed - txtvPlaybackSpeed = binding.txtvPlaybackSpeed imgvCover = binding.imgvCover butPlay = binding.butPlay - butRev = binding.butRev - txtvRev = binding.txtvRev - butFF = binding.butFF - txtvFF = binding.txtvFF - butSkip = binding.butSkip - txtvSkip = binding.txtvSkip sbPosition = binding.sbPosition - txtvPosition = binding.txtvPosition txtvLength = binding.txtvLength setupLengthTextView() setupControlButtons() - butPlaybackSpeed.setOnClickListener { + binding.butPlaybackSpeed.setOnClickListener { VariableSpeedDialog.newInstance(booleanArrayOf(true, true, true), null)?.show(childFragmentManager, null) } sbPosition.setOnSeekBarChangeListener(this) - - binding.internalPlayerFragment.setOnClickListener { - Logd(TAG, "internalPlayerFragment was clicked") + binding.playerUiFragment.setOnClickListener { + Logd(TAG, "playerUiFragment was clicked") val media = curMedia if (media != null) { val mediaType = media.getMediaType() @@ -540,32 +523,28 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } else { controller?.playPause() // controller!!.ensureService() - val intent = PlaybackService.getPlayerActivityIntent(requireContext(), mediaType) + val intent = getPlayerActivityIntent(requireContext(), mediaType) startActivity(intent) } } } return binding.root } - @OptIn(UnstableApi::class) override fun onDestroyView() { super.onDestroyView() _binding = null } - @UnstableApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) butPlay?.setOnClickListener { if (controller == null) return@setOnClickListener - - val media = curMedia - if (media != null) { - if (media.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING) { +// val media = curMedia + if (curMedia != null) { + if (curMedia?.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING) { controller!!.playPause() - requireContext().startActivity(PlaybackService.getPlayerActivityIntent(requireContext(), media.getMediaType())) + requireContext().startActivity(getPlayerActivityIntent(requireContext(), curMedia!!.getMediaType())) } else controller!!.playPause() - if (!isControlButtonsSet) { sbPosition.visibility = View.VISIBLE isControlButtonsSet = true @@ -573,17 +552,15 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } } } - @OptIn(UnstableApi::class) private fun setupControlButtons() { - butRev.setOnClickListener { - if (controller != null && isPlaybackServiceReady()) { - val curr: Int = position - seekTo(curr - UserPreferences.rewindSecs * 1000) + binding.butRev.setOnClickListener { + if (controller != null && playbackService?.isServiceReady() == true) { + playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000) sbPosition.visibility = View.VISIBLE } } - butRev.setOnLongClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, txtvRev) + binding.butRev.setOnLongClickListener { + SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, binding.txtvRev) true } butPlay?.setOnLongClickListener { @@ -593,61 +570,53 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } true } - butFF.setOnClickListener { - if (controller != null && isPlaybackServiceReady()) { - val curr: Int = position - seekTo(curr + UserPreferences.fastForwardSecs * 1000) + binding.butFF.setOnClickListener { + if (controller != null && playbackService?.isServiceReady() == true) { + playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000) sbPosition.visibility = View.VISIBLE } } - butFF.setOnLongClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, txtvFF) + binding.butFF.setOnLongClickListener { + SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, binding.txtvFF) true } - butSkip.setOnClickListener { + binding.butSkip.setOnClickListener { if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { val speedForward = UserPreferences.speedforwardSpeed if (speedForward > 0.1f) speedForward(speedForward) } } - butSkip.setOnLongClickListener { + binding.butSkip.setOnLongClickListener { activity?.sendBroadcast(MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT)) true } } - private fun speedForward(speed: Float) { // playbackService?.speedForward(speed) - if (playbackService?.mediaPlayer == null || playbackService?.isFallbackSpeed == true) return - + if (playbackService?.mPlayer == null || playbackService?.isFallbackSpeed == true) return if (playbackService?.isSpeedForward == false) { - playbackService?.normalSpeed = playbackService?.mediaPlayer!!.getPlaybackSpeed() - playbackService?.mediaPlayer!!.setPlaybackParams(speed, isSkipSilence) - } else playbackService?.mediaPlayer?.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) - + playbackService?.normalSpeed = playbackService?.mPlayer!!.getPlaybackSpeed() + playbackService?.mPlayer!!.setPlaybackParams(speed, isSkipSilence) + } else playbackService?.mPlayer?.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) playbackService!!.isSpeedForward = !playbackService!!.isSpeedForward } - @OptIn(UnstableApi::class) private fun setupLengthTextView() { showTimeLeft = UserPreferences.shouldShowRemainingTime() txtvLength.setOnClickListener(View.OnClickListener { if (controller == null) return@OnClickListener showTimeLeft = !showTimeLeft UserPreferences.setShowRemainTimeSetting(showTimeLeft) - onPositionUpdate(FlowEvent.PlaybackPositionEvent(position, duration)) + onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia, curPosition, duration)) }) } - fun updatePlaybackSpeedButton(event: FlowEvent.SpeedChangedEvent) { val speedStr: String = DecimalFormat("0.00").format(event.newSpeed.toDouble()) - txtvPlaybackSpeed.text = speedStr - butPlaybackSpeed.setSpeed(event.newSpeed) + binding.txtvPlaybackSpeed.text = speedStr + binding.butPlaybackSpeed.setSpeed(event.newSpeed) } - @UnstableApi fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) { - if (controller == null || position == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) return - + if (curMedia?.getIdentifier() != event.media?.getIdentifier() || controller == null || curPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) return val converter = TimeSpeedConverter(curSpeedMultiplier) val currentPosition: Int = converter.convert(event.position) val duration: Int = converter.convert(event.duration) @@ -656,9 +625,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar Log.w(TAG, "Could not react to position observer update because of invalid time") return } - - txtvPosition.text = Converter.getDurationStringLong(currentPosition) - txtvPosition.setContentDescription(getString(R.string.position, + binding.txtvPosition.text = Converter.getDurationStringLong(currentPosition) + binding.txtvPosition.setContentDescription(getString(R.string.position, Converter.getDurationStringLocalized(requireContext(), currentPosition.toLong()))) val showTimeLeft = UserPreferences.shouldShowRemainingTime() if (showTimeLeft) { @@ -670,7 +638,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar Converter.getDurationStringLocalized(requireContext(), duration.toLong()))) txtvLength.text = Converter.getDurationStringLong(duration) } - if (sbPosition.visibility == View.INVISIBLE && isPlaybackServiceReady()) sbPosition.visibility = View.VISIBLE + if (sbPosition.visibility == View.INVISIBLE && playbackService?.isServiceReady() == true) sbPosition.visibility = View.VISIBLE if (!sbPosition.isPressed) { val progress: Float = (event.position.toFloat()) / event.duration @@ -678,7 +646,6 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar sbPosition.progress = (progress * sbPosition.max).toInt() } } - fun onPlaybackServiceChanged(event: FlowEvent.PlaybackServiceEvent) { when (event.action) { FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN -> (activity as MainActivity).setPlayerVisible(false) @@ -686,46 +653,37 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar // PlaybackServiceEvent.Action.SERVICE_RESTARTED -> (activity as MainActivity).setPlayerVisible(true) } } - @OptIn(UnstableApi::class) override fun onStart() { Logd(TAG, "onStart() called") super.onStart() - - txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()) - txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()) - if (UserPreferences.speedforwardSpeed > 0.1f) txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed) - else txtvSkip.visibility = View.GONE + binding.txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()) + binding.txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()) + if (UserPreferences.speedforwardSpeed > 0.1f) binding.txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed) + else binding.txtvSkip.visibility = View.GONE val media = curMedia ?: return updatePlaybackSpeedButton(FlowEvent.SpeedChangedEvent(getCurrentPlaybackSpeed(media))) } - @UnstableApi override fun onPause() { Logd(TAG, "onPause() called") super.onPause() controller?.pause() } - @OptIn(UnstableApi::class) override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} - override fun onStartTrackingTouch(seekBar: SeekBar) {} - @OptIn(UnstableApi::class) override fun onStopTrackingTouch(seekBar: SeekBar) { - if (isPlaybackServiceReady()) { + if (playbackService?.isServiceReady() == true) { val prog: Float = seekBar.progress / (seekBar.max.toFloat()) seekTo((prog * duration).toInt()) } } - @UnstableApi fun updateUi(media: Playable?) { Logd(TAG, "updateUi called $media") if (media == null) return - - episodeTitle.text = media.getEpisodeTitle() + binding.titleView.text = media.getEpisodeTitle() // (activity as MainActivity).setPlayerVisible(true) - onPositionUpdate(FlowEvent.PlaybackPositionEvent(media.getPosition(), media.getDuration())) - + onPositionUpdate(FlowEvent.PlaybackPositionEvent(media, media.getPosition(), media.getDuration())) if (prevMedia?.getIdentifier() != media.getIdentifier()) { val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(media) val imgLocFB = ImageResourceUtils.getFallbackImageLocation(media) @@ -761,18 +719,14 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar companion object { var controller: PlaybackController? = null - fun newInstance(controller_: PlaybackController) : InternalPlayerFragment { + fun newInstance(controller_: PlaybackController) : PlayerUIFragment { controller = controller_ - return InternalPlayerFragment() + return PlayerUIFragment() } } } companion object { val TAG = AudioPlayerFragment::class.simpleName ?: "Anonymous" - - fun isPlaybackServiceReady() : Boolean { - return playbackService?.isServiceReady() == true - } } } 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 7dc5e974..c04f3d2f 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 @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.collectLatest @UnstableApi abstract class BaseEpisodesFragment : Fragment(), SelectableAdapter.OnSelectModeListener, Toolbar.OnMenuItemClickListener { - val TAG = this::class.simpleName ?: "Anonymous" @JvmField @@ -96,25 +95,19 @@ import kotlinx.coroutines.flow.collectLatest setupLoadMoreScrollListener() recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) - swipeActions = SwipeActions(this, getFragmentTag()).attachTo(recyclerView) + swipeActions = SwipeActions(this, TAG).attachTo(recyclerView) lifecycle.addObserver(swipeActions) swipeActions.setFilter(getFilter()) refreshSwipeTelltale() - binding.leftActionIcon.setOnClickListener { - swipeActions.showDialog() - } - binding.rightActionIcon.setOnClickListener { - swipeActions.showDialog() - } + binding.leftActionIcon.setOnClickListener { swipeActions.showDialog() } + binding.rightActionIcon.setOnClickListener { swipeActions.showDialog() } val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false swipeRefreshLayout = binding.swipeRefresh swipeRefreshLayout.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance)) - swipeRefreshLayout.setOnRefreshListener { - FeedUpdateManager.runOnceOrAsk(requireContext()) - } + swipeRefreshLayout.setOnRefreshListener { FeedUpdateManager.runOnceOrAsk(requireContext()) } createListAdaptor() @@ -232,7 +225,7 @@ import kotlinx.coroutines.flow.collectLatest // 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 -> { - Log.i(TAG, "Selected item or listAdapter was null, ignoring selection") + Logd(TAG, "Selected item or listAdapter was null, ignoring selection") return super.onContextItemSelected(item) } listAdapter.onContextItemSelected(item) -> return true @@ -328,7 +321,7 @@ import kotlinx.coroutines.flow.collectLatest speedDialView.visibility = View.GONE } - fun onEventMainThread(event: FlowEvent.EpisodeEvent) { + private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { // Logd(TAG, "onEventMainThread() called with ${event.TAG}") for (item in event.episodes) { val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) @@ -342,15 +335,14 @@ import kotlinx.coroutines.flow.collectLatest } } - fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { + private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { // Logd(TAG, "onEventMainThread() called with ${event.TAG}") - if (currentPlaying != null && currentPlaying!!.isCurMedia) - currentPlaying!!.notifyPlaybackPositionUpdated(event) + 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 && holder.isCurMedia) { + if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { currentPlaying = holder holder.notifyPlaybackPositionUpdated(event) break @@ -361,7 +353,6 @@ import kotlinx.coroutines.flow.collectLatest private fun onKeyUp(event: KeyEvent) { if (!isAdded || !isVisible || !isMenuVisible) return - when (event.keyCode) { KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0) KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(listAdapter.itemCount) @@ -369,7 +360,7 @@ import kotlinx.coroutines.flow.collectLatest } } - fun onEventMainThread(event: FlowEvent.EpisodeDownloadEvent) { + private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { for (downloadUrl in event.urls) { val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl) if (pos >= 0) listAdapter.notifyItemChangedCompat(pos) @@ -393,9 +384,9 @@ import kotlinx.coroutines.flow.collectLatest Logd(TAG, "Received event: ${event.TAG}") when (event) { is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() - is FlowEvent.FeedListUpdateEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> loadItems() - is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event) - is FlowEvent.EpisodeEvent -> onEventMainThread(event) + is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> loadItems() + is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(event) + is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) else -> {} } } @@ -404,8 +395,8 @@ import kotlinx.coroutines.flow.collectLatest EventFlow.stickyEvents.collectLatest { event -> Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { - is FlowEvent.EpisodeDownloadEvent -> onEventMainThread(event) - is FlowEvent.FeedUpdateRunningEvent -> onEventMainThread(event) + is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) + is FlowEvent.FeedUpdateRunningEvent -> onFeedUpdateRunningEvent(event) else -> {} } } @@ -456,15 +447,15 @@ import kotlinx.coroutines.flow.collectLatest protected abstract fun loadTotalItemCount(): Int - protected abstract fun getFilter(): EpisodeFilter - - protected abstract fun getFragmentTag(): String + open fun getFilter(): EpisodeFilter { + return EpisodeFilter.unfiltered() + } protected abstract fun getPrefName(): String protected open fun updateToolbar() {} - fun onEventMainThread(event: FlowEvent.FeedUpdateRunningEvent) { + private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) { swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning } @@ -475,6 +466,6 @@ import kotlinx.coroutines.flow.collectLatest companion object { private const val KEY_UP_ARROW = "up_arrow" - const val EPISODES_PER_PAGE: Int = 150 + const val EPISODES_PER_PAGE: Int = 50 } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt index bca06f77..ede16434 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt @@ -11,7 +11,7 @@ import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.ChapterUtils.getCurrentChapterIndex import ac.mdiq.podcini.storage.utils.ChapterUtils.loadChapters import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.PlaybackController.Companion.position +import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.storage.model.Chapter import ac.mdiq.podcini.storage.utils.EmbeddedChapterImage @@ -159,6 +159,7 @@ class ChaptersFragment : AppCompatDialogFragment() { } fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { + if (event.media?.getIdentifier() != media?.getIdentifier()) return updateChapterSelection(getCurrentChapter(media), false) adapter.notifyTimeChanged(event.position.toLong()) } @@ -166,7 +167,7 @@ class ChaptersFragment : AppCompatDialogFragment() { private fun getCurrentChapter(media: Playable?): Int { if (controller == null) return -1 - return getCurrentChapterIndex(media, position) + return getCurrentChapterIndex(media, curPosition) } private fun loadMediaInfo(forceRefresh: Boolean) { @@ -274,12 +275,6 @@ class ChaptersFragment : AppCompatDialogFragment() { } else { if (media != null) { val imgUrl = EmbeddedChapterImage.getModelFor(media!!,position) -// if (imgUrl != null) Glide.with(context) -// .load(imgUrl) -// .apply(RequestOptions() -// .dontAnimate() -// .transform(FitCenter(), RoundedCorners((4 * context.resources.displayMetrics.density).toInt()))) -// .into(holder.image) holder.image.load(imgUrl) } } 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 367e0c7f..4e1a195e 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 @@ -33,8 +33,6 @@ import ac.mdiq.podcini.util.event.FlowEvent import android.os.Bundle import android.util.Log import android.view.* -import android.widget.ProgressBar -import android.widget.TextView import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -63,13 +61,11 @@ import java.util.* private var runningDownloads: Set = HashSet() private var items: MutableList = mutableListOf() - private lateinit var infoBar: TextView private lateinit var adapter: DownloadsListAdapter private lateinit var toolbar: MaterialToolbar private lateinit var recyclerView: EpisodesRecyclerView private lateinit var swipeActions: SwipeActions private lateinit var speedDialView: SpeedDialView - private lateinit var progressBar: ProgressBar private lateinit var emptyView: EmptyViewHandler private var displayUpArrow = false @@ -114,10 +110,7 @@ import java.util.* val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false - progressBar = binding.progLoading - progressBar.visibility = View.VISIBLE - - infoBar = binding.infoBar + binding.progLoading.visibility = View.VISIBLE val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root) speedDialView = multiSelectDial.fabSD @@ -243,7 +236,7 @@ import java.util.* override fun onContextItemSelected(item: MenuItem): Boolean { val selectedItem: Episode? = adapter.longPressedItem if (selectedItem == null) { - Log.i(TAG, "Selected item at current position was null, ignoring selection") + Logd(TAG, "Selected item at current position was null, ignoring selection") return super.onContextItemSelected(item) } if (adapter.onContextItemSelected(item)) return true @@ -287,12 +280,12 @@ import java.util.* private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { // Logd(TAG, "onPlaybackPositionEvent called with ${event.TAG}") - if (currentPlaying != null && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) + 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 && holder.isCurMedia) { + if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { currentPlaying = holder holder.notifyPlaybackPositionUpdated(event) break @@ -334,7 +327,7 @@ import java.util.* withContext(Dispatchers.Main) { items = result.toMutableList() // adapter.setDummyViews(0) - progressBar.visibility = View.GONE + binding.progLoading.visibility = View.GONE adapter.updateItems(result) refreshInfoBar() } @@ -362,12 +355,10 @@ import java.util.* var info = String.format(Locale.getDefault(), "%d%s", items.size, getString(R.string.episodes_suffix)) if (items.isNotEmpty()) { var sizeMB: Long = 0 - for (item in items) { - sizeMB += item.media?.size?:0 - } + for (item in items) sizeMB += item.media?.size ?: 0 info += " • " + (sizeMB / 1000000) + " MB" } - infoBar.text = info + binding.infoBar.text = info } override fun onStartSelectMode() { 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 3174cdaf..2cd3a7a4 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 @@ -35,8 +35,6 @@ class EpisodeHomeFragment : Fragment() { private var _binding: EpisodeHomeFragmentBinding? = null private val binding get() = _binding!! -// private val ioScope = CoroutineScope(Dispatchers.IO) // IO dispatcher for initialization - private var startIndex = 0 private var ttsSpeed = 1.0f @@ -181,6 +179,9 @@ class EpisodeHomeFragment : Fragment() { } menu.findItem(R.id.share_notes)?.setVisible(readMode) menu.findItem(R.id.switchJS)?.setVisible(!readMode) + val btn = menu.findItem(R.id.switch_home) + if (readMode) btn?.setIcon(R.drawable.baseline_home_24) + else btn?.setIcon(R.drawable.outline_home_24) } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -191,12 +192,10 @@ class EpisodeHomeFragment : Fragment() { @OptIn(UnstableApi::class) override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.switch_home -> { - Logd(TAG, "switch_home selected") switchMode() return true } R.id.switchJS -> { - Logd(TAG, "switchJS selected") jsEnabled = !jsEnabled showWebContent() return true @@ -287,11 +286,7 @@ class EpisodeHomeFragment : Fragment() { fun newInstance(item: Episode): EpisodeHomeFragment { val fragment = EpisodeHomeFragment() Logd(TAG, "item.itemIdentifier ${item.identifier}") - if (item.identifier != currentItem?.identifier) { - currentItem = item - } else { -// currentItem?.feed = item.feed - } + if (item.identifier != currentItem?.identifier) currentItem = 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 06351065..016599aa 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,6 +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.upsert import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia @@ -79,20 +80,11 @@ import kotlin.math.max private lateinit var shownotesCleaner: ShownotesCleaner private lateinit var toolbar: MaterialToolbar - private lateinit var root: ViewGroup private lateinit var webvDescription: ShownotesWebView - private lateinit var txtvPodcast: TextView - private lateinit var txtvTitle: TextView - private lateinit var txtvDuration: TextView - private lateinit var txtvPublished: TextView private lateinit var imgvCover: ImageView - private lateinit var progbarDownload: CircularProgressBar - private lateinit var progbarLoading: ProgressBar - private lateinit var homeButtonAction: View private lateinit var butAction1: ImageView private lateinit var butAction2: ImageView - private lateinit var noMediaLabel: View private var actionButton1: EpisodeActionButton? = null private var actionButton2: EpisodeActionButton? = null @@ -101,7 +93,7 @@ import kotlin.math.max super.onCreateView(inflater, container, savedInstanceState) _binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false) - root = binding.root +// root = binding.root Logd(TAG, "fragment onCreateView") toolbar = binding.toolbar @@ -110,13 +102,9 @@ import kotlin.math.max toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } toolbar.setOnMenuItemClickListener(this) - txtvPodcast = binding.txtvPodcast - txtvPodcast.setOnClickListener { openPodcast() } - txtvTitle = binding.txtvTitle - txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) - txtvDuration = binding.txtvDuration - txtvPublished = binding.txtvPublished - txtvTitle.ellipsize = TextUtils.TruncateAt.END + binding.txtvPodcast.setOnClickListener { openPodcast() } + binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) + binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END webvDescription = binding.webvDescription webvDescription.setTimecodeSelectedListener { time: Int? -> val cMedia = curMedia @@ -127,14 +115,10 @@ import kotlin.math.max imgvCover = binding.imgvCover imgvCover.setOnClickListener { openPodcast() } - progbarDownload = binding.circularProgressBar - progbarLoading = binding.progbarLoading - homeButtonAction = binding.homeButton butAction1 = binding.butAction1 butAction2 = binding.butAction2 - noMediaLabel = binding.noMediaLabel - homeButtonAction.setOnClickListener { + binding.homeButton.setOnClickListener { if (!item?.link.isNullOrEmpty()) { homeFragment = EpisodeHomeFragment.newInstance(item!!) (activity as MainActivity).loadChildFragment(homeFragment!!) @@ -242,7 +226,7 @@ import kotlin.math.max @UnstableApi override fun onResume() { super.onResume() if (itemLoaded) { - progbarLoading.visibility = View.GONE + binding.progbarLoading.visibility = View.GONE updateAppearance() } } @@ -250,13 +234,14 @@ import kotlin.math.max @OptIn(UnstableApi::class) override fun onDestroyView() { super.onDestroyView() Logd(TAG, "onDestroyView") - _binding = null - - root.removeView(webvDescription) + + binding.root.removeView(webvDescription) webvDescription.clearHistory() webvDescription.clearCache(true) webvDescription.clearView() webvDescription.destroy() + + _binding = null } @UnstableApi private fun onFragmentLoaded() { @@ -276,14 +261,14 @@ import kotlin.math.max // these are already available via button1 and button2 else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item) - if (item!!.feed != null) txtvPodcast.text = item!!.feed!!.title - txtvTitle.text = item!!.title + if (item!!.feed != null) binding.txtvPodcast.text = item!!.feed!!.title + binding.txtvTitle.text = item!!.title binding.itemLink.text = item!!.link if (item?.pubDate != null) { val pubDateStr = DateFormatter.formatAbbrev(context, Date(item!!.pubDate)) - txtvPublished.text = pubDateStr - txtvPublished.setContentDescription(DateFormatter.formatForAccessibility(Date(item!!.pubDate))) + binding.txtvPublished.text = pubDateStr + binding.txtvPublished.setContentDescription(DateFormatter.formatForAccessibility(Date(item!!.pubDate))) } val media = item?.media @@ -326,14 +311,14 @@ import kotlin.math.max } @UnstableApi private fun updateButtons() { - progbarDownload.visibility = View.GONE + binding.circularProgressBar.visibility = View.GONE val dls = DownloadServiceInterface.get() if (item != null && item!!.media != null && item!!.media!!.downloadUrl != null) { val url = item!!.media!!.downloadUrl!! if (dls != null && dls.isDownloadingEpisode(url)) { - progbarDownload.visibility = View.VISIBLE - progbarDownload.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), item) - progbarDownload.setIndeterminate(dls.isEpisodeQueued(url)) + binding.circularProgressBar.visibility = View.VISIBLE + binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), item) + binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) } } @@ -344,12 +329,12 @@ import kotlin.math.max butAction1.visibility = View.INVISIBLE actionButton2 = VisitWebsiteActionButton(item!!) } - noMediaLabel.visibility = View.VISIBLE + binding.noMediaLabel.visibility = View.VISIBLE } else { - noMediaLabel.visibility = View.GONE + binding.noMediaLabel.visibility = View.GONE if (media.getDuration() > 0) { - txtvDuration.text = Converter.getDurationStringLong(media.getDuration()) - txtvDuration.setContentDescription(Converter.getDurationStringLocalized(requireContext(), media.getDuration().toLong())) + binding.txtvDuration.text = Converter.getDurationStringLong(media.getDuration()) + binding.txtvDuration.setContentDescription(Converter.getDurationStringLocalized(requireContext(), media.getDuration().toLong())) } if (item != null) { actionButton1 = when { @@ -439,7 +424,7 @@ import kotlin.math.max } @UnstableApi private fun load() { - if (!itemLoaded) progbarLoading.visibility = View.VISIBLE + if (!itemLoaded) binding.progbarLoading.visibility = View.VISIBLE Logd(TAG, "load() called") lifecycleScope.launch { @@ -453,7 +438,7 @@ import kotlin.math.max feedItem } withContext(Dispatchers.Main) { - progbarLoading.visibility = View.GONE + binding.progbarLoading.visibility = View.GONE item = result onFragmentLoaded() itemLoaded = true @@ -465,7 +450,7 @@ import kotlin.math.max } fun setItem(item_: Episode) { - item = item_ + item = unmanagedCopy(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 4dd58960..f9267f17 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 @@ -10,7 +10,8 @@ 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.upsert +import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy +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.Feed @@ -89,6 +90,7 @@ import java.util.concurrent.Semaphore private var enableFilter: Boolean = true private val ioScope = CoroutineScope(Dispatchers.IO) + private var onInit: Boolean = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -194,7 +196,6 @@ import java.util.concurrent.Semaphore super.onStart() procFlowEvents() loadItems() -// realmFeedMonitor() } override fun onStop() { @@ -269,11 +270,14 @@ import java.util.concurrent.Semaphore R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext(), feed) R.id.refresh_complete_item -> { Thread { - feed!!.nextPageLink = feed!!.downloadUrl - feed!!.pageNr = 0 try { - runBlocking { resetPagedFeedPage(feed).join() } - FeedUpdateManager.runOnce(requireContext(), feed) + if (feed != null) { + val feed_ = unmanagedCopy(feed!!) + feed_.nextPageLink = feed_.downloadUrl + feed_.pageNr = 0 + upsertBlk(feed_) {} + FeedUpdateManager.runOnce(requireContext(), feed_) + } } catch (e: ExecutionException) { throw RuntimeException(e) } catch (e: InterruptedException) { @@ -302,19 +306,10 @@ import java.util.concurrent.Semaphore return true } - private fun resetPagedFeedPage(feed: Feed?) : Job { - return runOnIOScope { - if (feed != null) { - feed.nextPageLink = feed.downloadUrl - upsert(feed) {} - } - } - } - override fun onContextItemSelected(item: MenuItem): Boolean { val selectedItem: Episode? = adapter.longPressedItem if (selectedItem == null) { - Log.i(TAG, "Selected item at current position was null, ignoring selection") + Logd(TAG, "Selected item at current position was null, ignoring selection") return super.onContextItemSelected(item) } if (adapter.onContextItemSelected(item)) return true @@ -330,33 +325,6 @@ import java.util.concurrent.Semaphore } } - private fun onEpisodesFilterSortEvent(event: FlowEvent.EpisodesFilterOrSortEvent) { -// Logd(TAG, "onEvent() called with: event = [$event]") - if (event.feed.id == feed?.id) { - when (event.action) { - FlowEvent.EpisodesFilterOrSortEvent.Action.SORT_ORDER_CHANGED -> { - val sortOrder = fromCode(feed!!.preferences?.sortOrderCode ?: 0) - if (sortOrder != null) getPermutor(sortOrder).reorder(episodes) - adapter.updateItems(episodes) - } - FlowEvent.EpisodesFilterOrSortEvent.Action.FILTER_CHANGED -> { - episodes.clear() - if (enableFilter) { - feed!!.preferences?.filterString = event.feed.preferences?.filterString ?: "" - val episodes_ = feed!!.episodes.filter { feed!!.episodeFilter.matches(it) } - episodes.addAll(episodes_) - } else { - episodes.addAll(feed!!.episodes) - } - val sortOrder = fromCode(feed!!.preferences?.sortOrderCode ?: 0) - if (sortOrder != null) getPermutor(sortOrder).reorder(episodes) - binding.header.counts.text = episodes.size.toString() - adapter.updateItems(episodes) - } - } - } - } - private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { // Logd(TAG, "onEpisodeEvent() called with ${event.TAG}") if (feed == null || episodes.isEmpty()) return @@ -367,19 +335,8 @@ import java.util.concurrent.Semaphore while (i < size) { val item = event.episodes[i] if (item.feedId != feed!!.id) continue -// Unmanaged embedded objects don't support parent access -// Logd(TAG, "item.media.parent: ${item.media?.parent()?.title}") val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) if (pos >= 0) { -// Logd(TAG, "replacing episode: ${item.title} ${item.media?.downloaded} ${item.media?.fileUrl}") -// val item_ = getEpisode(item.id) -// if (item_ != null) Logd(TAG, "episode in DB: ${item_.title} ${item_.media?.downloaded} ${item_.media?.fileUrl}") -// val feed_ = getFeed(item.feedId?:0) -// if (feed_ != null) { -// for (item_1 in feed_.episodes) { -// Logd(TAG, "episode in Feed: ${item_1.title} ${item_1.media?.downloaded} ${item_1.media?.fileUrl != null}") -// } -// } episodes.removeAt(pos) episodes.add(pos, item) adapter.notifyItemChangedCompat(pos) @@ -449,12 +406,12 @@ import java.util.concurrent.Semaphore private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { // Logd(TAG, "onEventMainThread() called with ${event.TAG}") - if (currentPlaying != null && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) + 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 && holder.isCurMedia) { + if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { currentPlaying = holder holder.notifyPlaybackPositionUpdated(event) break @@ -483,12 +440,11 @@ import java.util.concurrent.Semaphore is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) is FlowEvent.PlayEvent -> onEvenStartPlay(event) is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(event) - is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChanged(event) + is FlowEvent.FeedPrefsChangeEvent -> if (feed?.id == event.feed.id) loadItems() is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) - is FlowEvent.EpisodesFilterOrSortEvent -> onEpisodesFilterSortEvent(event) is FlowEvent.PlayerSettingsEvent -> loadItems() is FlowEvent.EpisodePlayedEvent -> onEpisodePlayedEvent(event) - is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event) + is FlowEvent.FeedListEvent -> if (feed != null && event.contains(feed!!)) loadItems() is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() else -> {} } @@ -512,16 +468,6 @@ import java.util.concurrent.Semaphore } } - private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) { - Log.d(TAG,"onFeedPrefsChanged called") - if (feed?.id == event.prefs.feedID) { - feed!!.preferences = event.prefs - for (item in episodes) { - item.feed?.preferences = event.prefs - } - } - } - override fun onStartSelectMode() { swipeActions.detach() if (feed != null && feed!!.isLocalFeed) dialBinding.fabSD.removeActionItemById(R.id.download_batch) @@ -542,13 +488,6 @@ import java.util.concurrent.Semaphore swipeActions.attachTo(binding.recyclerView) } - private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent) { - if (feed != null && event.contains(feed!!)) { - Logd(TAG, "onFeedListChanged called") - loadItems() - } - } - private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) { nextPageLoader.setLoadingState(event.isFeedUpdateRunning) if (!event.isFeedUpdateRunning) nextPageLoader.root.visibility = View.GONE @@ -612,13 +551,22 @@ import java.util.concurrent.Semaphore binding.header.butFilter.setOnLongClickListener { if (feed != null) { enableFilter = !enableFilter - if (enableFilter) binding.header.butFilter.setColorFilter(Color.WHITE) - else binding.header.butFilter.setColorFilter(Color.RED) - onEpisodesFilterSortEvent(FlowEvent.EpisodesFilterOrSortEvent(FlowEvent.EpisodesFilterOrSortEvent.Action.FILTER_CHANGED, feed!!)) + episodes.clear() + if (enableFilter) { + binding.header.butFilter.setColorFilter(Color.WHITE) + val episodes_ = feed!!.episodes.filter { feed!!.episodeFilter.matches(it) } + episodes.addAll(episodes_) + } else { + binding.header.butFilter.setColorFilter(Color.RED) + episodes.addAll(feed!!.episodes) + } + val sortOrder = fromCode(feed!!.preferences?.sortOrderCode ?: 0) + if (sortOrder != null) getPermutor(sortOrder).reorder(episodes) + binding.header.counts.text = episodes.size.toString() + adapter.updateItems(episodes, feed) } true } - binding.header.txtvFailure.setOnClickListener { showErrorDetails() } binding.header.counts.text = adapter.itemCount.toString() headerCreated = true @@ -659,31 +607,34 @@ import java.util.concurrent.Semaphore lifecycleScope.launch { try { feed = withContext(Dispatchers.IO) { - val feed_ = getFeed(feedID, true) + val feed_ = getFeed(feedID) if (feed_ != null) { episodes.clear() - if (!feed_.preferences?.filterString.isNullOrEmpty()) { + if (enableFilter && !feed_.preferences?.filterString.isNullOrEmpty()) { val episodes_ = feed_.episodes.filter { feed_.episodeFilter.matches(it) } episodes.addAll(episodes_) } else episodes.addAll(feed_.episodes) val sortOrder = fromCode(feed_.preferences?.sortOrderCode?:0) if (sortOrder != null) getPermutor(sortOrder).reorder(episodes) - var hasNonMediaItems = false - for (item in episodes) { -// TODO: perhaps shouldn't set for all items, do it in the adaptor? - item.feed = feed_ - if (item.media == null) hasNonMediaItems = true -// Logd(TAG, "loadItems ${item.media?.downloaded} ${item.title}") - } - if (hasNonMediaItems) { - ioScope.launch { - withContext(Dispatchers.IO) { - if (!ttsReady) { - initializeTTS(requireContext()) - semaphore.acquire() + if (onInit) { + var hasNonMediaItems = false + for (item in episodes) { + if (item.media == null) { + hasNonMediaItems = true + break + } + } + if (hasNonMediaItems) { + ioScope.launch { + withContext(Dispatchers.IO) { + if (!ttsReady) { + initializeTTS(requireContext()) + semaphore.acquire() + } } } } + onInit = false } } feed_ @@ -695,7 +646,7 @@ import java.util.concurrent.Semaphore binding.progressBar.visibility = View.GONE adapter.setDummyViews(0) if (feed != null && episodes.isNotEmpty()) { - adapter.updateItems(episodes) + adapter.updateItems(episodes, feed) binding.header.counts.text = episodes.size.toString() } updateToolbar() @@ -737,14 +688,14 @@ import java.util.concurrent.Semaphore if (feed != null) { Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]") runOnIOScope { - feed.preferences?.filterString = newFilterValues.joinToString() val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() if (feed_ != null) { realm.write { - findLatest(feed_)?.let { it.preferences?.filterString = feed.preferences?.filterString ?: "" } + findLatest(feed_)?.let { + it.preferences?.filterString = newFilterValues.joinToString() + } } - } else upsert(feed) {} - EventFlow.postEvent(FlowEvent.EpisodesFilterOrSortEvent(FlowEvent.EpisodesFilterOrSortEvent.Action.FILTER_CHANGED, feed)) + } } } } @@ -756,7 +707,6 @@ import java.util.concurrent.Semaphore sortOrder = if (feed?.sortOrder == null) SortOrder.DATE_NEW_OLD else feed.sortOrder } - override fun onAddItem(title: Int, ascending: SortOrder, descending: SortOrder, ascendingIsDefault: Boolean) { if (ascending == SortOrder.DATE_OLD_NEW || ascending == SortOrder.PLAYED_DATE_OLD_NEW || ascending == SortOrder.COMPLETED_DATE_OLD_NEW || ascending == SortOrder.DURATION_SHORT_LONG || ascending == SortOrder.RANDOM @@ -765,24 +715,19 @@ import java.util.concurrent.Semaphore super.onAddItem(title, ascending, descending, ascendingIsDefault) } } - @UnstableApi override fun onSelectionChanged() { super.onSelectionChanged() if (feed != null) { -// val sortOrder = fromCode(feed.sortOrderCode) -// if (sortOrder != null) getPermutor(sortOrder).reorder(feed.episodes) -// EventFlow.postEvent(FlowEvent.EpisodesFilterOrSortEvent(FlowEvent.EpisodesFilterOrSortEvent.Action.SORT_ORDER_CHANGED, feed.id)) -// persistEpisodeSortOrder(feed, sortOrder) Logd(TAG, "persist Episode SortOrder") runOnIOScope { - feed.sortOrder = sortOrder val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() if (feed_ != null) { realm.write { - findLatest(feed_)?.let { it.sortOrder = feed.sortOrder } + findLatest(feed_)?.let { + it.sortOrder = sortOrder + } } - } else upsert(feed) {} - EventFlow.postEvent(FlowEvent.EpisodesFilterOrSortEvent(FlowEvent.EpisodesFilterOrSortEvent.Action.SORT_ORDER_CHANGED, feed)) + } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt index 1bee810d..bfb888ea 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt @@ -3,8 +3,8 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.EditTextDialogBinding import ac.mdiq.podcini.databinding.FeedinfoBinding -import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce +import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher import ac.mdiq.podcini.net.utils.HtmlToPlainText import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.database.Feeds.updateFeedDownloadURL @@ -32,7 +32,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.ImageView -import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources @@ -66,15 +65,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { private lateinit var feed: Feed private lateinit var imgvCover: ImageView - private lateinit var txtvTitle: TextView - private lateinit var txtvDescription: TextView - private lateinit var txtvFundingUrl: TextView - private lateinit var lblSupport: TextView - private lateinit var txtvUrl: TextView - private lateinit var txtvAuthorHeader: TextView private lateinit var imgvBackground: ImageView - private lateinit var infoContainer: View - private lateinit var header: View private lateinit var toolbar: MaterialToolbar private val addLocalFolderLauncher = registerForActivityResult(AddLocalFolder()) { @@ -115,18 +106,10 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { appBar.addOnOffsetChangedListener(iconTintManager) imgvCover = binding.header.imgvCover - txtvTitle = binding.header.txtvTitle - txtvAuthorHeader = binding.header.txtvAuthor imgvBackground = binding.imgvBackground - infoContainer = binding.infoContainer - header = binding.header.root // https://github.com/bumptech/glide/issues/529 // imgvBackground.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000) - txtvDescription = binding.txtvDescription - txtvUrl = binding.txtvUrl - lblSupport = binding.lblSupport - txtvFundingUrl = binding.txtvFundingUrl binding.header.episodes.text = feed.episodes.size.toString() + " episodes" binding.header.episodes.setOnClickListener { val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id) @@ -134,13 +117,12 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } binding.btnvRelatedFeeds.setOnClickListener { - val fragment = OnlineSearchFragment.newInstance(CombinedSearcher::class.java, "${txtvAuthorHeader.text} podcasts") + val fragment = OnlineSearchFragment.newInstance(CombinedSearcher::class.java, "${binding.header.txtvAuthor.text} podcasts") (activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE) } - txtvUrl.setOnClickListener(copyUrlToClipboard) + binding.txtvUrl.setOnClickListener(copyUrlToClipboard) -// val feedId = requireArguments().getLong(EXTRA_FEED_ID) val feedId = feed.id parentFragmentManager.beginTransaction().replace(R.id.statisticsFragmentContainer, FeedStatisticsFragment.newInstance(feedId, false), "feed_statistics_fragment") @@ -158,8 +140,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val horizontalSpacing = resources.getDimension(R.dimen.additional_horizontal_spacing).toInt() - header.setPadding(horizontalSpacing, header.paddingTop, horizontalSpacing, header.paddingBottom) - infoContainer.setPadding(horizontalSpacing, infoContainer.paddingTop, horizontalSpacing, infoContainer.paddingBottom) + binding.header.root.setPadding(horizontalSpacing, binding.header.root.paddingTop, horizontalSpacing, binding.header.root.paddingBottom) + binding.infoContainer.setPadding(horizontalSpacing, binding.infoContainer.paddingTop, horizontalSpacing, binding.infoContainer.paddingBottom) } private fun showFeed() { @@ -173,22 +155,22 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { error(R.mipmap.ic_launcher) } - txtvTitle.text = feed.title - txtvTitle.setMaxLines(6) + binding.header.txtvTitle.text = feed.title + binding.header.txtvTitle.setMaxLines(6) val description: String = HtmlToPlainText.getPlainText(feed.description?:"") - txtvDescription.text = description + binding.txtvDescription.text = description - if (!feed.author.isNullOrEmpty()) txtvAuthorHeader.text = feed.author + if (!feed.author.isNullOrEmpty()) binding.header.txtvAuthor.text = feed.author - txtvUrl.text = feed.downloadUrl - txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0) + binding.txtvUrl.text = feed.downloadUrl + binding.txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0) if (feed.paymentLinks.isEmpty()) { - lblSupport.visibility = View.GONE - txtvFundingUrl.visibility = View.GONE + binding.lblSupport.visibility = View.GONE + binding.txtvFundingUrl.visibility = View.GONE } else { - lblSupport.visibility = View.VISIBLE + binding.lblSupport.visibility = View.VISIBLE val fundingList: ArrayList = feed.paymentLinks // Filter for duplicates, but keep items in the order that they have in the feed. @@ -212,7 +194,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { str.append("\n") } str = StringBuilder(StringUtils.trim(str.toString())) - txtvFundingUrl.text = str.toString() + binding.txtvFundingUrl.text = str.toString() } refreshToolbarState() } @@ -260,8 +242,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { object : EditUrlSettingsDialog(activity as Activity, feed) { override fun setUrl(url: String?) { feed.downloadUrl = url - txtvUrl.text = feed.downloadUrl - txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0) + binding.txtvUrl.text = feed.downloadUrl + binding.txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0) } }.show() } 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 927fa056..1e7d08c1 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 @@ -275,8 +275,8 @@ class FeedSettingsFragment : Fragment() { // EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feedPrefs!!.feedID)) } updateVolumeAdaptationValue() - if (feed != null && feedPrefs!!.volumeAdaptionSetting != null) - EventFlow.postEvent(FlowEvent.VolumeAdaptionChangedEvent(feedPrefs!!.volumeAdaptionSetting!!, feed!!.id)) +// if (feed != null && feedPrefs!!.volumeAdaptionSetting != null) +// EventFlow.postEvent(FlowEvent.VolumeAdaptionChangedEvent(feedPrefs!!.volumeAdaptionSetting!!, feed!!.id)) false } } @@ -297,32 +297,6 @@ class FeedSettingsFragment : Fragment() { } } -// @OptIn(UnstableApi::class) private fun setupNewEpisodesAction() { -// if (feedPreferences == null) return -// -// findPreference(PREF_NEW_EPISODES_ACTION)!!.onPreferenceChangeListener = -// Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any -> -// val code = (newValue as String).toInt() -// feedPreferences!!.newEpisodesAction = NewEpisodesAction.fromCode(code) -// DBWriter.setFeedPreferences(feedPreferences!!) -// updateNewEpisodesAction() -// false -// } -// } - -// private fun updateNewEpisodesAction() { -// if (feedPreferences == null || feedPreferences!!.newEpisodesAction == null) return -// val newEpisodesAction = findPreference(PREF_NEW_EPISODES_ACTION) -// newEpisodesAction!!.value = "" + feedPreferences!!.newEpisodesAction!!.code -// -// when (feedPreferences!!.newEpisodesAction) { -// NewEpisodesAction.GLOBAL -> newEpisodesAction.setSummary(R.string.global_default) -//// NewEpisodesAction.ADD_TO_INBOX -> newEpisodesAction.setSummary(R.string.feed_new_episodes_action_add_to_inbox) -// NewEpisodesAction.NOTHING -> newEpisodesAction.setSummary(R.string.feed_new_episodes_action_nothing) -// else -> {} -// } -// } - @OptIn(UnstableApi::class) private fun setupKeepUpdatedPreference() { if (feedPrefs == null) return val pref = findPreference("keepUpdated") 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 96797522..9f259dbc 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 @@ -37,9 +37,7 @@ import kotlin.math.min private var startDate : Long = 0L private var endDate : Long = Date().time - override fun getFragmentTag(): String { - return TAG - } + var allHistory: List = listOf() override fun getPrefName(): String { return TAG @@ -89,10 +87,6 @@ import kotlin.math.min cancelFlowEvents() } - override fun getFilter(): EpisodeFilter { - return EpisodeFilter.unfiltered() - } - @OptIn(UnstableApi::class) override fun onMenuItemClick(item: MenuItem): Boolean { if (super.onOptionsItemSelected(item)) return true when (item.itemId) { @@ -157,15 +151,15 @@ import kotlin.math.min } override fun loadData(): List { - val hList = getHistory(0, page * EPISODES_PER_PAGE, startDate, endDate, sortOrder).toMutableList() -// FeedItemPermutors.getPermutor(sortOrder).reorder(hList) - return hList + allHistory = getHistory(0, Int.MAX_VALUE, startDate, endDate, sortOrder).toMutableList() + return allHistory.subList(0, min(allHistory.size-1, page * EPISODES_PER_PAGE)) } override fun loadMoreData(page: Int): List { - val hList = getHistory((page - 1) * EPISODES_PER_PAGE, EPISODES_PER_PAGE, startDate, endDate, sortOrder).toMutableList() -// FeedItemPermutors.getPermutor(sortOrder).reorder(hList) - return hList + val offset = (page - 1) * EPISODES_PER_PAGE + if (offset >= allHistory.size) return listOf() + val toIndex = offset + EPISODES_PER_PAGE + return allHistory.subList(offset, min(allHistory.size, toIndex)) } override fun loadTotalItemCount(): Int { @@ -215,7 +209,7 @@ import kotlin.math.min fun getHistory(offset: Int, limit: Int, start: Long = 0L, end: Long = Date().time, sortOrder: SortOrder = SortOrder.PLAYED_DATE_NEW_OLD): List { Logd(TAG, "getHistory() called") - val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0 AND lastPlayedTime <= $1", start, end).find() + val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > $0 AND lastPlayedTime <= $1", start, end).find() var episodes: MutableList = mutableListOf() for (m in medias) { if (m.episode != null) episodes.add(m.episode!!) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index 608129d1..19648e2e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -9,7 +9,7 @@ import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory +import ac.mdiq.podcini.storage.algorithms.AutoCleanups import ac.mdiq.podcini.storage.database.Episodes.getEpisodesCount import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.model.DatasetStats @@ -413,7 +413,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { // queueSize = queue?.episodeIds?.size ?: 0 // } Logd(TAG, "getDatasetStats: queueSize: $queueSize") - return DatasetStats(queueSize, numDownloadedItems, EpisodeCleanupAlgorithmFactory.build().getReclaimableItems(), numItems, numFeeds) + return DatasetStats(queueSize, numDownloadedItems, AutoCleanups.build().getReclaimableItems(), numItems, numFeeds) } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt index e649e508..7bbee623 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt @@ -96,10 +96,6 @@ import kotlin.concurrent.Volatile private var dialog: Dialog? = null -// private var download: Disposable? = null -// private var parser: Disposable? = null -// private var updater: Disposable? = null - @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater) binding.closeButton.visibility = View.INVISIBLE @@ -170,9 +166,6 @@ import kotlin.concurrent.Volatile override fun onDestroy() { super.onDestroy() _binding = null -// updater?.dispose() -// download?.dispose() -// parser?.dispose() } @OptIn(UnstableApi::class) override fun onSaveInstanceState(outState: Bundle) { @@ -332,7 +325,7 @@ import kotlin.concurrent.Volatile EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") when (event) { - is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event) + is FlowEvent.FeedListEvent -> onFeedListChanged(event) else -> {} } } @@ -348,7 +341,7 @@ import kotlin.concurrent.Volatile } } - private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent) { + private fun onFeedListChanged(event: FlowEvent.FeedListEvent) { lifecycleScope.launch { try { val feeds = withContext(Dispatchers.IO) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt index a37b0bed..c056abc3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt @@ -56,7 +56,7 @@ class OnlineSearchFragment : Fragment() { break } } - if (searchProvider == null) Log.i(TAG,"Podcast searcher not found") + if (searchProvider == null) Logd(TAG,"Podcast searcher not found") } @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt index 57f7616b..5c959bca 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt @@ -7,7 +7,7 @@ import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.PlaybackController.Companion.position +import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.storage.database.Episodes.persistEpisode import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope @@ -59,7 +59,7 @@ import org.apache.commons.lang3.StringUtils * Displays the description of a Playable object in a Webview. */ @UnstableApi -class PlayerDetailsFragment : Fragment() { +class PlayerDetailsFragment : Fragment() { private lateinit var shownoteView: ShownotesWebView private var shownotesCleaner: ShownotesCleaner? = null @@ -67,8 +67,8 @@ class PlayerDetailsFragment : Fragment() { private val binding get() = _binding!! private var prevItem: Episode? = null - private var media: Playable? = null - private var item: Episode? = null + private var playable: Playable? = null + private var currentItem: Episode? = null private var displayedChapterIndex = -1 private var cleanedNotes: String? = null @@ -80,8 +80,8 @@ class PlayerDetailsFragment : Fragment() { private val currentChapter: Chapter? get() { - if (media == null || media!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null - return media!!.getChapters()[displayedChapterIndex] + if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null + return playable!!.getChapters()[displayedChapterIndex] } @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -115,11 +115,13 @@ class PlayerDetailsFragment : Fragment() { } override fun onStart() { + Logd(TAG, "onStart()") super.onStart() procFlowEvents() } override fun onStop() { + Logd(TAG, "onStop()") super.onStop() cancelFlowEvents() } @@ -136,34 +138,34 @@ class PlayerDetailsFragment : Fragment() { return shownoteView.onContextItemSelected(item) } - internal fun load() { + internal fun updateInfo() { // if (isLoading) return lifecycleScope.launch { - Logd(TAG, "in load()") + Logd(TAG, "in updateInfo") isLoading = true withContext(Dispatchers.IO) { - if (item == null) { - media = curMedia - if (media != null && media is EpisodeMedia) { - val episodeMedia = media as EpisodeMedia - item = episodeMedia.episode + if (currentItem == null) { + playable = curMedia + if (playable != null && playable is EpisodeMedia) { + val episodeMedia = playable as EpisodeMedia + currentItem = episodeMedia.episode showHomeText = false homeText = null } } - if (item != null) { - media = item!!.media - if (prevItem?.identifier != item!!.identifier) cleanedNotes = null + if (currentItem != null) { + playable = currentItem!!.media + if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null if (cleanedNotes == null) { - Logd(TAG, "calling load description ${item!!.description==null} ${item!!.title}") - cleanedNotes = shownotesCleaner?.processShownotes(item?.description ?: "", media?.getDuration()?:0) + Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}") + cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration()?:0) } - prevItem = item + prevItem = currentItem } } withContext(Dispatchers.Main) { - Logd(TAG, "subscribe: ${media?.getEpisodeTitle()}") - displayMediaInfo(media!!) + Logd(TAG, "subscribe: ${playable?.getEpisodeTitle()}") + displayMediaInfo(playable!!) shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") Logd(TAG, "Webview loaded") } @@ -177,17 +179,17 @@ class PlayerDetailsFragment : Fragment() { showHomeText = !showHomeText runOnIOScope { if (showHomeText) { - homeText = item!!.transcript - if (homeText == null && item?.link != null) { - val url = item!!.link!! + homeText = currentItem!!.transcript + if (homeText == null && currentItem?.link != null) { + val url = currentItem!!.link!! val htmlSource = fetchHtmlSource(url) - val readability4J = Readability4J(item!!.link!!, htmlSource) + val readability4J = Readability4J(currentItem!!.link!!, htmlSource) val article = readability4J.parse() readerhtml = article.contentWithDocumentsCharsetOrUtf8 if (!readerhtml.isNullOrEmpty()) { - item!!.setTranscriptIfLonger(readerhtml) - homeText = item!!.transcript - persistEpisode(item) + currentItem!!.setTranscriptIfLonger(readerhtml) + homeText = currentItem!!.transcript + persistEpisode(currentItem) } } if (!homeText.isNullOrEmpty()) { @@ -203,7 +205,7 @@ class PlayerDetailsFragment : Fragment() { } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } } else { // val shownotesCleaner = ShownotesCleaner(requireContext()) - cleanedNotes = shownotesCleaner?.processShownotes(item?.description ?: "", media?.getDuration() ?: 0) + cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration() ?: 0) if (!cleanedNotes.isNullOrEmpty()) { withContext(Dispatchers.Main) { shownoteView.loadDataWithBaseURL("https://127.0.0.1", @@ -218,12 +220,12 @@ class PlayerDetailsFragment : Fragment() { } @UnstableApi private fun displayMediaInfo(media: Playable) { - Logd(TAG, "displayMediaInfo ${item?.title} ${media.getEpisodeTitle()}") + Logd(TAG, "displayMediaInfo ${currentItem?.title} ${media.getEpisodeTitle()}") val pubDateStr = DateFormatter.formatAbbrev(context, media.getPubDate()) binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle()) if (media is EpisodeMedia) { - if (item?.feedId != null) { - val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), item!!.feedId!!) + if (currentItem?.feedId != null) { + val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!) binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } } } else { @@ -231,8 +233,8 @@ class PlayerDetailsFragment : Fragment() { } binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) } binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr) - binding.txtvEpisodeTitle.text = item?.title - binding.txtvEpisodeTitle.setOnLongClickListener { copyText(item?.title?:"") } + binding.txtvEpisodeTitle.text = currentItem?.title + binding.txtvEpisodeTitle.setOnLongClickListener { copyText(currentItem?.title?:"") } binding.txtvEpisodeTitle.setOnClickListener { val lines = binding.txtvEpisodeTitle.lineCount val animUnit = 1500 @@ -262,9 +264,9 @@ class PlayerDetailsFragment : Fragment() { private fun updateChapterControlVisibility() { var chapterControlVisible = false when { - media?.getChapters() != null -> chapterControlVisible = media!!.getChapters().isNotEmpty() - media is EpisodeMedia -> { - val fm: EpisodeMedia? = (media as EpisodeMedia?) + playable?.getChapters() != null -> chapterControlVisible = playable!!.getChapters().isNotEmpty() + playable is EpisodeMedia -> { + val fm: EpisodeMedia? = (playable as EpisodeMedia?) // If an item has chapters but they are not loaded yet, still display the button. chapterControlVisible = fm?.episode != null && fm.episode!!.chapters.isNotEmpty() } @@ -278,9 +280,9 @@ class PlayerDetailsFragment : Fragment() { } private fun refreshChapterData(chapterIndex: Int) { - if (media != null && chapterIndex > -1) { - if (media!!.getPosition() > media!!.getDuration() || chapterIndex >= media!!.getChapters().size - 1) { - displayedChapterIndex = media!!.getChapters().size - 1 + if (playable != null && chapterIndex > -1) { + if (playable!!.getPosition() > playable!!.getDuration() || chapterIndex >= playable!!.getChapters().size - 1) { + displayedChapterIndex = playable!!.getChapters().size - 1 binding.butNextChapter.visibility = View.INVISIBLE } else { displayedChapterIndex = chapterIndex @@ -291,17 +293,17 @@ class PlayerDetailsFragment : Fragment() { } private fun displayCoverImage() { - if (media == null) return - if (displayedChapterIndex == -1 || media!!.getChapters().isEmpty() || media!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { + if (playable == null) return + if (displayedChapterIndex == -1 || playable!!.getChapters().isEmpty() || playable!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { val imageLoader = binding.imgvCover.context.imageLoader val imageRequest = ImageRequest.Builder(requireContext()) - .data(media!!.getImageLocation()) + .data(playable!!.getImageLocation()) .setHeader("User-Agent", "Mozilla/5.0") .placeholder(R.color.light_gray) .listener(object : ImageRequest.Listener { override fun onError(request: ImageRequest, result: ErrorResult) { val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(ImageResourceUtils.getFallbackImageLocation(media!!)) + .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) .setHeader("User-Agent", "Mozilla/5.0") .error(R.mipmap.ic_launcher) .target(binding.imgvCover) @@ -314,7 +316,7 @@ class PlayerDetailsFragment : Fragment() { imageLoader.enqueue(imageRequest) } else { - val imgLoc = EmbeddedChapterImage.getModelFor(media!!, displayedChapterIndex) + val imgLoc = EmbeddedChapterImage.getModelFor(playable!!, displayedChapterIndex) val imageLoader = binding.imgvCover.context.imageLoader val imageRequest = ImageRequest.Builder(requireContext()) .data(imgLoc) @@ -323,7 +325,7 @@ class PlayerDetailsFragment : Fragment() { .listener(object : ImageRequest.Listener { override fun onError(request: ImageRequest, result: ErrorResult) { val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(ImageResourceUtils.getFallbackImageLocation(media!!)) + .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) .setHeader("User-Agent", "Mozilla/5.0") .error(R.mipmap.ic_launcher) .target(binding.imgvCover) @@ -343,19 +345,19 @@ class PlayerDetailsFragment : Fragment() { when { displayedChapterIndex < 1 -> seekTo(0) - (position - 10000 * curSpeedMultiplier) < curr.start -> { + (curPosition - 10000 * curSpeedMultiplier) < curr.start -> { refreshChapterData(displayedChapterIndex - 1) - if (media != null) seekTo(media!!.getChapters()[displayedChapterIndex].start.toInt()) + if (playable != null) seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) } else -> seekTo(curr.start.toInt()) } } @UnstableApi private fun seekToNextChapter() { - if (media == null || media!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= media!!.getChapters().size) return + if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= playable!!.getChapters().size) return refreshChapterData(displayedChapterIndex + 1) - seekTo(media!!.getChapters()[displayedChapterIndex].start.toInt()) + seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) } @@ -425,17 +427,18 @@ class PlayerDetailsFragment : Fragment() { } } - fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { - val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(media, event.position) - if (newChapterIndex > -1 && newChapterIndex != displayedChapterIndex) { + private fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { + if (playable?.getIdentifier() != event.media?.getIdentifier()) return + val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position) + if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) { refreshChapterData(newChapterIndex) } } fun setItem(item_: Episode) { Logd(TAG, "setItem ${item_.title}") - if (item?.identifier != item_.identifier) { - item = item_ + if (currentItem?.identifier != item_.identifier) { + currentItem = item_ showHomeText = false homeText = 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 0bf96c4d..1b98f491 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 @@ -44,8 +44,6 @@ import android.os.Bundle import android.util.Log import android.view.* import android.widget.CheckBox -import android.widget.ProgressBar -import android.widget.TextView import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -74,15 +72,13 @@ import java.util.* private var _binding: QueueFragmentBinding? = null private val binding get() = _binding!! - private lateinit var infoBar: TextView private lateinit var recyclerView: EpisodesRecyclerView private lateinit var emptyView: EmptyViewHandler private lateinit var toolbar: MaterialToolbar private lateinit var swipeRefreshLayout: SwipeRefreshLayout private lateinit var swipeActions: SwipeActions private lateinit var speedDialView: SpeedDialView - private lateinit var progressBar: ProgressBar - + private var displayUpArrow = false private var queueItems: MutableList = mutableListOf() @@ -112,10 +108,8 @@ import java.util.* (activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow) toolbar.inflateMenu(R.menu.queue) refreshToolbarState() - progressBar = binding.progressBar - progressBar.visibility = View.VISIBLE + binding.progressBar.visibility = View.VISIBLE - infoBar = binding.infoBar recyclerView = binding.recyclerView val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator if (animator != null && animator is SimpleItemAnimator) animator.supportsChangeAnimations = false @@ -335,12 +329,12 @@ import java.util.* private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { // Logd(TAG, "onEventMainThread() called with ${event.TAG}") if (adapter != null) { - if (currentPlaying != null && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) + if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event) else { - Logd(TAG, "onEventMainThread() ${event.TAG} search list") + 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 && holder.isCurMedia) { + if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { currentPlaying = holder holder.notifyPlaybackPositionUpdated(event) break @@ -370,7 +364,7 @@ import java.util.* private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) { Log.d(TAG,"speedPresetChanged called") for (item in queueItems) { - if (item.feed?.id == event.prefs.feedID) item.feed!!.preferences = event.prefs + if (item.feed?.id == event.feed.id) item.feed = null } } @@ -470,14 +464,14 @@ import java.util.* val selectedItem: Episode? = adapter!!.longPressedItem if (selectedItem == null) { - Log.i(TAG, "Selected item was null, ignoring selection") + Logd(TAG, "Selected item was null, ignoring selection") return super.onContextItemSelected(item) } if (adapter!!.onContextItemSelected(item)) return true val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems.toList(), selectedItem.id) if (pos < 0) { - Log.i(TAG, "Selected item no longer exist, ignoring selection") + Logd(TAG, "Selected item no longer exist, ignoring selection") return super.onContextItemSelected(item) } @@ -536,7 +530,7 @@ import java.util.* info += " • " info += Converter.getDurationStringLocalized(requireActivity(), timeLeft) } - infoBar.text = info + binding.infoBar.text = info toolbar.title = "${getString(R.string.queue_label)}: ${curQueue.name}" } @@ -551,7 +545,7 @@ import java.util.* queueItems.clear() queueItems.addAll(curQueue.episodes) - progressBar.visibility = View.GONE + binding.progressBar.visibility = View.GONE adapter?.setDummyViews(0) adapter?.updateItems(queueItems) if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) @@ -562,13 +556,13 @@ import java.util.* swipeActions.detach() speedDialView.visibility = View.VISIBLE refreshToolbarState() - infoBar.visibility = View.GONE + binding.infoBar.visibility = View.GONE } override fun onEndSelectMode() { speedDialView.close() speedDialView.visibility = View.GONE - infoBar.visibility = View.VISIBLE + binding.infoBar.visibility = View.VISIBLE swipeActions.attachTo(recyclerView) } @@ -603,7 +597,7 @@ import java.util.* private fun reorderQueue(sortOrder: SortOrder?, broadcastUpdate: Boolean) : Job { Logd(TAG, "reorderQueue called") if (sortOrder == null) { - Log.w(TAG, "reorderQueue() - sortOrder is null. Do nothing.") + Logd(TAG, "reorderQueue() - sortOrder is null. Do nothing.") return Job() } val permutor = getPermutor(sortOrder) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt index a8476ab5..38ab58dc 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt @@ -215,14 +215,6 @@ class QuickDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener { val podcast: PodcastSearchResult? = getItem(position) holder.imageView!!.contentDescription = podcast?.title -// if (!podcast?.imageUrl.isNullOrBlank()) Glide.with(mainActivityRef.get()!!) -// .load(podcast?.imageUrl) -// .apply(RequestOptions() -// .placeholder(R.color.light_gray) -// .transform(FitCenter(), RoundedCorners((8 * mainActivityRef.get()!!.resources.displayMetrics.density).toInt())) -// .dontAnimate()) -// .into(holder.imageView!!) - holder.imageView?.load(podcast?.imageUrl) { placeholder(R.color.light_gray) error(R.mipmap.ic_launcher) 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 cc48d97a..03cf1cfe 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 @@ -23,7 +23,6 @@ import kotlin.math.min * Shows all episodes (possibly filtered by user). */ @UnstableApi class RemoteEpisodesFragment : BaseEpisodesFragment() { -// val TAG = this::class.simpleName ?: "Anonymous" private val episodeList: MutableList = mutableListOf() @@ -31,17 +30,10 @@ import kotlin.math.min val root = super.onCreateView(inflater, container, savedInstanceState) Logd(TAG, "fragment onCreateView") -// val episodes_ = requireArguments().getSerializable(EXTRA_EPISODES) as? ArrayList -// if (episodes_ != null) episodeList.addAll(episodes_) - toolbar.inflateMenu(R.menu.episodes) toolbar.setTitle(R.string.episodes_label) updateToolbar() listAdapter.setOnSelectModeListener(null) -// updateFilterUi() -// txtvInformation.setOnClickListener { -// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) -// } return root } @@ -66,21 +58,16 @@ import kotlin.math.min } override fun loadMoreData(page: Int): List { - return episodeList.subList((page - 1) * EPISODES_PER_PAGE, min(episodeList.size, page * EPISODES_PER_PAGE)) + val offset = (page - 1) * EPISODES_PER_PAGE + if (offset >= episodeList.size) return listOf() + val toIndex = offset + EPISODES_PER_PAGE + return episodeList.subList(offset, min(episodeList.size, toIndex)) } override fun loadTotalItemCount(): Int { return episodeList.size } - override fun getFilter(): EpisodeFilter { - return EpisodeFilter.unfiltered() - } - - override fun getFragmentTag(): String { - return TAG - } - override fun getPrefName(): String { return PREF_NAME } @@ -97,14 +84,6 @@ import kotlin.math.min if (super.onOptionsItemSelected(item)) return true when (item.itemId) { -// R.id.filter_items -> { -// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) -// return true -// } -// R.id.episodes_sort -> { -// AllEpisodesSortDialog().show(childFragmentManager.beginTransaction(), "SortDialog") -// return true -// } else -> return false } } @@ -127,44 +106,8 @@ import kotlin.math.min } } - private fun updateFilterUi() { -// swipeActions.setFilter(getFilter()) -// when { -// getFilter().values.isNotEmpty() -> { -// txtvInformation.visibility = View.VISIBLE -// emptyView.setMessage(R.string.no_all_episodes_filtered_label) -// } -// else -> { -// txtvInformation.visibility = View.GONE -// 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) - } - -// class AllEpisodesSortDialog : ItemSortDialog() { -// override fun onCreate(savedInstanceState: Bundle?) { -// super.onCreate(savedInstanceState) -// sortOrder = allEpisodesSortOrder -// } -// -// override fun onAddItem(title: Int, ascending: SortOrder, descending: SortOrder, ascendingIsDefault: Boolean) { -// if (ascending == SortOrder.DATE_OLD_NEW || ascending == SortOrder.DURATION_SHORT_LONG) { -// super.onAddItem(title, ascending, descending, ascendingIsDefault) -// } -// } -// -// override fun onSelectionChanged() { -// super.onSelectionChanged() -// allEpisodesSortOrder = sortOrder -// EventBus.getDefault().post(FeedListUpdateEvent(0)) -// } -// } - companion object { const val PREF_NAME: String = "EpisodesListFragment" - const val EXTRA_EPISODES: String = "episodes_list" fun newInstance(episodes: MutableList): RemoteEpisodesFragment { val i = RemoteEpisodesFragment() 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 cf498dc4..feee19c7 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 @@ -253,7 +253,7 @@ import java.lang.ref.WeakReference EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") when (event) { - is FlowEvent.FeedListUpdateEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> search() + is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> search() is FlowEvent.EpisodeEvent -> onEventMainThread(event) is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event) else -> {} @@ -295,13 +295,13 @@ import java.lang.ref.WeakReference } fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { - if (currentPlaying != null && currentPlaying!!.isCurMedia) + 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 && holder.isCurMedia) { + if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) { currentPlaying = holder holder.notifyPlaybackPositionUpdated(event) break @@ -509,13 +509,6 @@ import java.lang.ref.WeakReference false } -// if (!podcast.imageUrl.isNullOrBlank()) Glide.with(mainActivityRef.get()!!) -// .load(podcast.imageUrl) -// .apply(RequestOptions() -// .placeholder(R.color.light_gray) -// .fitCenter() -// .dontAnimate()) -// .into(holder.imageView) holder.imageView.load(podcast.imageUrl) { placeholder(R.color.light_gray) error(R.mipmap.ic_launcher) 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 37d90ffd..8cbf687e 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 @@ -5,10 +5,10 @@ import ac.mdiq.podcini.databinding.* import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.feedOrder +import ac.mdiq.podcini.preferences.UserPreferences.useGridLayout import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getTags import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences -import ac.mdiq.podcini.storage.database.Feeds.updateFeedMap import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Feed @@ -38,14 +38,12 @@ import android.widget.* import androidx.annotation.OptIn import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar -import androidx.cardview.widget.CardView import androidx.core.util.Consumer import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.elevation.SurfaceColors @@ -72,14 +70,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec private val binding get() = _binding!! private lateinit var subscriptionRecycler: RecyclerView - private lateinit var listAdapter: ListAdapter + private lateinit var listAdapter: SubscriptionsAdapter<*> private lateinit var emptyView: EmptyViewHandler - private lateinit var feedsInfoMsg: LinearLayout - private lateinit var feedsFilteredMsg: TextView - private lateinit var feedCount: TextView private lateinit var toolbar: MaterialToolbar - private lateinit var swipeRefreshLayout: SwipeRefreshLayout - private lateinit var progressBar: ProgressBar private lateinit var speedDialView: SpeedDialView private var tagFilterIndex = 1 @@ -89,6 +82,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec private var feedList: MutableList = mutableListOf() private var feedListFiltered: List = mutableListOf() + private var useGrid: Boolean? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) retainInstance = true @@ -120,20 +115,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec subscriptionRecycler.addItemDecoration(GridDividerItemDecorator()) registerForContextMenu(subscriptionRecycler) subscriptionRecycler.addOnScrollListener(LiftOnScrollListener(binding.appbar)) -// subscriptionAdapter = object : SubscriptionsAdapter(activity as MainActivity) { -// override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { -// super.onCreateContextMenu(menu, v, menuInfo) -// MenuItemUtils.setOnClickListeners(menu) { item: MenuItem -> -// this@SubscriptionsFragment.onContextItemSelected(item) -// } -// } -// } - listAdapter = ListAdapter() - val gridLayoutManager = GridLayoutManager(context, 1, RecyclerView.VERTICAL, false) - subscriptionRecycler.layoutManager = gridLayoutManager - listAdapter.setOnSelectModeListener(this) - subscriptionRecycler.adapter = listAdapter + initAdapter() setupEmptyView() resetTags() @@ -163,31 +146,19 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } else false } - progressBar = binding.progressBar - progressBar.visibility = View.VISIBLE + binding.progressBar.visibility = View.VISIBLE val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd subscriptionAddButton.setOnClickListener { if (activity is MainActivity) (activity as MainActivity).loadChildFragment(AddFeedFragment()) } - feedsInfoMsg = binding.feedsInfoMessage -// feedsInfoMsg.setOnClickListener { -// SubscriptionsFilterDialog().show( -// childFragmentManager, "filter") -// } - feedsFilteredMsg = binding.feedsFilteredMessage - feedCount = binding.count - feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() - - swipeRefreshLayout = binding.swipeRefresh - swipeRefreshLayout.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance)) - swipeRefreshLayout.setOnRefreshListener { + binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() + binding.swipeRefresh.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance)) + binding.swipeRefresh.setOnRefreshListener { FeedUpdateManager.runOnceOrAsk(requireContext()) } - val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root) - speedDialView = speedDialBinding.fabSD speedDialView.overlayLayout = speedDialBinding.fabSDOverlay speedDialView.inflate(R.menu.nav_feed_action_speeddial) @@ -201,14 +172,28 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec FeedMultiSelectActionHandler(activity as MainActivity, listAdapter.selectedItems.filterIsInstance()).handleAction(actionItem.id) true } - loadSubscriptions() - return binding.root } + private fun initAdapter() { + if (useGrid != useGridLayout) { + useGrid = useGridLayout + var spanCount = 1 + if (useGrid!!) { + listAdapter = GridAdapter() + spanCount = 3 + } else listAdapter = ListAdapter() + subscriptionRecycler.layoutManager = GridLayoutManager(context, spanCount, RecyclerView.VERTICAL, false) + listAdapter.setOnSelectModeListener(this) + subscriptionRecycler.adapter = listAdapter + listAdapter.setItems(feedListFiltered) + } + } + override fun onStart() { super.onStart() + initAdapter() procFlowEvents() } @@ -242,7 +227,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } } } - feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() + binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() listAdapter.setItems(feedListFiltered) } @@ -259,7 +244,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") when (event) { - is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event) + is FlowEvent.FeedListEvent -> onFeedListChanged(event) is FlowEvent.EpisodePlayedEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions() is FlowEvent.FeedTagsChangedEvent -> resetTags() else -> {} @@ -270,7 +255,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec EventFlow.stickyEvents.collectLatest { event -> Logd(TAG, "Received sticky event: ${event.TAG}") when (event) { - is FlowEvent.FeedUpdateRunningEvent -> swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning + is FlowEvent.FeedUpdateRunningEvent -> binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning else -> {} } } @@ -311,9 +296,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec if ( feedListFiltered.size > result.size) listAdapter.endSelectMode() feedList = result.toMutableList() filterOnTag() - progressBar.visibility = View.GONE + binding.progressBar.visibility = View.GONE listAdapter.setItems(feedListFiltered) - feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() + binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() emptyView.updateVisibility() } } catch (e: Throwable) { @@ -416,7 +401,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } override fun onContextItemSelected(item: MenuItem): Boolean { - val feed: Feed = listAdapter.getSelectedItem() ?: return false + val feed: Feed = listAdapter.selectedItem ?: return false val itemId = item.itemId if (itemId == R.id.multi_select) { speedDialView.visibility = View.VISIBLE @@ -426,8 +411,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() } } - private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent?) { - updateFeedMap() + private fun onFeedListChanged(event: FlowEvent.FeedListEvent) { +// val feeds_ = realm.query(Feed::class,"id IN $0", event.feedIds).find() +// updateFeedMap(feeds_) loadSubscriptions() } @@ -535,12 +521,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } @OptIn(UnstableApi::class) - private inner class ListAdapter - : SelectableAdapter(activity as MainActivity), View.OnCreateContextMenuListener { + private abstract inner class SubscriptionsAdapter : SelectableAdapter(activity as MainActivity), View.OnCreateContextMenuListener { - private var feedList: List - private var selectedItem: Feed? = null - private var longPressedPosition: Int = 0 // used to init actionMode + protected var feedList: List + var selectedItem: Feed? = null + protected var longPressedPosition: Int = 0 // used to init actionMode val selectedItems: List get() { val items = ArrayList() @@ -560,14 +545,49 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec fun getItem(position: Int): Any { return feedList[position] } - fun getSelectedItem(): Feed? { - return selectedItem + override fun getItemCount(): Int { + return feedList.size } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun getItemId(position: Int): Long { + if (position >= feedList.size) return RecyclerView.NO_ID // Dummy views + return feedList[position].id + } + @OptIn(UnstableApi::class) + override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { + if (selectedItem == null) return + val mainActRef = (activity as MainActivity) + val inflater: MenuInflater = mainActRef.menuInflater + if (inActionMode()) { +// inflater.inflate(R.menu.multi_select_context_popup, menu) +// menu.findItem(R.id.multi_select).setVisible(true) + } else { + inflater.inflate(R.menu.nav_feed_context, menu) +// menu.findItem(R.id.multi_select).setVisible(true) + menu.setHeaderTitle(selectedItem?.title) + } + MenuItemUtils.setOnClickListeners(menu) { item: MenuItem -> + this@SubscriptionsFragment.onContextItemSelected(item) + } + } + fun onContextItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.multi_select) { + startSelectMode(longPressedPosition) + return true + } + return false + } + fun setItems(listItems: List) { + this.feedList = listItems + notifyDataSetChanged() + } + } + + private inner class ListAdapter : SubscriptionsAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderExpanded { val itemView: View = LayoutInflater.from(activity).inflate(R.layout.subscription_item, parent, false) - return ViewHolder(itemView) + return ViewHolderExpanded(itemView) } - @UnstableApi override fun onBindViewHolder(holder: ViewHolder, position: Int) { + @UnstableApi override fun onBindViewHolder(holder: ViewHolderExpanded, position: Int) { val feed: Feed = feedList[position] holder.bind(feed) if (inActionMode()) { @@ -622,89 +642,149 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } } } - override fun getItemCount(): Int { - return feedList.size + } + + private inner class GridAdapter : SubscriptionsAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderBrief { + val itemView: View = LayoutInflater.from(activity).inflate(R.layout.subscription_item_brief, parent, false) + return ViewHolderBrief(itemView) } - override fun getItemId(position: Int): Long { - if (position >= feedList.size) return RecyclerView.NO_ID // Dummy views - return feedList[position].id - } - @OptIn(UnstableApi::class) - override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { - if (selectedItem == null) return - val mainActRef = (activity as MainActivity) - val inflater: MenuInflater = mainActRef.menuInflater + @UnstableApi override fun onBindViewHolder(holder: ViewHolderBrief, position: Int) { + val feed: Feed = feedList[position] + holder.bind(feed) if (inActionMode()) { -// inflater.inflate(R.menu.multi_select_context_popup, menu) -// menu.findItem(R.id.multi_select).setVisible(true) + holder.selectCheckbox.visibility = View.VISIBLE + holder.selectView.visibility = View.VISIBLE + + holder.selectCheckbox.setChecked(isSelected(position)) + holder.selectCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + setSelected(holder.bindingAdapterPosition, isChecked) + } + holder.coverImage.alpha = 0.6f + holder.count.visibility = View.GONE } else { - inflater.inflate(R.menu.nav_feed_context, menu) -// menu.findItem(R.id.multi_select).setVisible(true) - menu.setHeaderTitle(selectedItem?.title) + holder.selectView.visibility = View.GONE + holder.coverImage.alpha = 1.0f } - MenuItemUtils.setOnClickListeners(menu) { item: MenuItem -> - this@SubscriptionsFragment.onContextItemSelected(item) + holder.coverImage.setOnClickListener { + if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition)) + else { + val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id) + (activity as MainActivity).loadChildFragment(fragment) + } } - } - fun onContextItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.multi_select) { + holder.coverImage.setOnLongClickListener { + longPressedPosition = holder.bindingAdapterPosition + selectedItem = feed startSelectMode(longPressedPosition) - return true + true } - return false - } - fun setItems(listItems: List) { - this.feedList = listItems - notifyDataSetChanged() - } - - private inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val binding = SubscriptionItemBinding.bind(itemView) - private val title = binding.titleLabel - private val producer = binding.producerLabel - val count: TextView = binding.countLabel - - val coverImage: ImageView = binding.coverImage - val infoCard: LinearLayout = binding.infoCard - val selectView: FrameLayout = binding.selectContainer - val selectCheckbox: CheckBox = binding.selectCheckBox - private val card: CardView = binding.outerContainer - - private val errorIcon: View = binding.errorIcon - - fun bind(drawerItem: Feed) { - val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context, R.drawable.ic_checkbox_background) - selectView.background = drawable // Setting this in XML crashes API <= 21 - title.text = drawerItem.title - producer.text = drawerItem.author - coverImage.contentDescription = drawerItem.title - coverImage.setImageDrawable(null) - - val counter = drawerItem.episodes.size - count.text = NumberFormat.getInstance().format(counter.toLong()) + " episodes" - count.visibility = View.VISIBLE - - val mainActRef = (activity as MainActivity) - val coverLoader = CoverLoader(mainActRef) - val feed: Feed = drawerItem - coverLoader.withUri(feed.imageUrl) - errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE - - coverLoader.withCoverView(coverImage) - coverLoader.load() - - val density: Float = mainActRef.resources.displayMetrics.density - card.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density)) - - val textHPadding = 20 - val textVPadding = 5 - title.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) - producer.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) - count.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) - - val textSize = 14 - title.textSize = textSize.toFloat() + holder.itemView.setOnTouchListener { _: View?, e: MotionEvent -> + if (e.isFromSource(InputDevice.SOURCE_MOUSE) && e.buttonState == MotionEvent.BUTTON_SECONDARY) { + if (!inActionMode()) { + longPressedPosition = holder.bindingAdapterPosition + selectedItem = feed + } + } + false } + holder.itemView.setOnClickListener { + if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition)) + else { +// val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id) +// mainActivityRef.get()?.loadChildFragment(fragment) + } + } + } + } + + private inner class ViewHolderExpanded(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = SubscriptionItemBinding.bind(itemView) + val count: TextView = binding.countLabel + + val coverImage: ImageView = binding.coverImage + val infoCard: LinearLayout = binding.infoCard + val selectView: FrameLayout = binding.selectContainer + val selectCheckbox: CheckBox = binding.selectCheckBox + + private val errorIcon: View = binding.errorIcon + + fun bind(drawerItem: Feed) { + val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context, R.drawable.ic_checkbox_background) + selectView.background = drawable // Setting this in XML crashes API <= 21 + binding.titleLabel.text = drawerItem.title + binding.producerLabel.text = drawerItem.author + coverImage.contentDescription = drawerItem.title + coverImage.setImageDrawable(null) + + val counter = drawerItem.episodes.size + count.text = NumberFormat.getInstance().format(counter.toLong()) + " episodes" + count.visibility = View.VISIBLE + + val mainActRef = (activity as MainActivity) + val coverLoader = CoverLoader(mainActRef) + val feed: Feed = drawerItem + coverLoader.withUri(feed.imageUrl) + errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE + + coverLoader.withCoverView(coverImage) + coverLoader.load() + + val density: Float = mainActRef.resources.displayMetrics.density + binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density)) + + val textHPadding = 20 + val textVPadding = 5 + binding.titleLabel.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) + binding.producerLabel.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) + count.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) + + val textSize = 14 + binding.titleLabel.textSize = textSize.toFloat() + } + } + + private inner class ViewHolderBrief(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = SubscriptionItemBriefBinding.bind(itemView) + private val title = binding.titleLabel + val count: TextView = binding.countLabel + + val coverImage: ImageView = binding.coverImage + val selectView: FrameLayout = binding.selectContainer + val selectCheckbox: CheckBox = binding.selectCheckBox + + private val errorIcon: View = binding.errorIcon + + fun bind(drawerItem: Feed) { + val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context, R.drawable.ic_checkbox_background) + selectView.background = drawable // Setting this in XML crashes API <= 21 + title.text = drawerItem.title + coverImage.contentDescription = drawerItem.title + coverImage.setImageDrawable(null) + + val counter = drawerItem.episodes.size + count.text = NumberFormat.getInstance().format(counter.toLong()) + count.visibility = View.VISIBLE + + val mainActRef = (activity as MainActivity) + val coverLoader = CoverLoader(mainActRef) + val feed: Feed = drawerItem + coverLoader.withUri(feed.imageUrl) + errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE + + coverLoader.withCoverView(coverImage) + coverLoader.load() + + val density: Float = mainActRef.resources.displayMetrics.density + binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density)) + + val textHPadding = 20 + val textVPadding = 5 + title.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) + count.setPadding(textHPadding, textVPadding, textHPadding, textVPadding) + + val textSize = 14 + title.textSize = textSize.toFloat() } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt index b35efe54..3b90f37f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt @@ -8,7 +8,7 @@ import ac.mdiq.podcini.playback.PlaybackController.Companion.duration import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.PlaybackController.Companion.isPlayingVideoLocally import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService -import ac.mdiq.podcini.playback.PlaybackController.Companion.position +import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.PlayerStatus @@ -79,17 +79,40 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { var controller: PlaybackController? = null var isFavorite = false + private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent -> + if (event.action != MotionEvent.ACTION_DOWN) return@OnTouchListener false + if (PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) return@OnTouchListener true + videoControlsHider.removeCallbacks(hideVideoControls) + if (System.currentTimeMillis() - lastScreenTap < 300) { + if (event.x > v.measuredWidth / 2.0f) { + onFastForward() + showSkipAnimation(true) + } else { + onRewind() + showSkipAnimation(false) + } + if (videoControlsShowing) { + hideVideoControls(false) + if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() + videoControlsShowing = false + } + return@OnTouchListener true + } + toggleVideoControlsVisibility() + if (videoControlsShowing) setupVideoControlsToggler() + + lastScreenTap = System.currentTimeMillis() + true + } + @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = VideoEpisodeFragmentBinding.inflate(LayoutInflater.from(requireContext())) root = binding.root - controller = newPlaybackController() controller!!.init() // loadMediaInfo() - setupView() - return root } @@ -104,15 +127,14 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { setupVideoAspectRatio() if (videoSurfaceCreated && controller != null) { Logd(TAG, "Videosurface already created, setting videosurface now") - setVideoSurface(binding.videoView.holder) +// setVideoSurface(binding.videoView.holder) + playbackService?.mPlayer?.setVideoSurface(binding.videoView.holder) } } } - override fun loadMediaInfo() { this@VideoEpisodeFragment.loadMediaInfo() } - override fun onPlaybackEnd() { activity?.finish() } @@ -131,7 +153,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { super.onStop() cancelFlowEvents() if (!PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) videoControlsHider.removeCallbacks(hideVideoControls) - // Controller released; we will not receive buffering updates binding.progressBar.visibility = View.GONE } @@ -151,7 +172,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { _binding = null controller?.release() controller = null // prevent leak -// scope.cancel() } private var eventSink: Job? = null @@ -204,7 +224,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { @OptIn(UnstableApi::class) private fun loadMediaInfo() { Logd(TAG, "loadMediaInfo called") if (curMedia == null) return - if (MediaPlayerBase.status == PlayerStatus.PLAYING && !isPlayingVideoLocally) { Logd(TAG, "Closing, no longer video") destroyingDueToReload = true @@ -245,7 +264,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { Log.e(TAG, Log.getStackTraceString(e)) } } - } private fun loadInBackground(): Episode? { @@ -266,11 +284,9 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { private fun setupView() { showTimeLeft = shouldShowRemainingTime() Logd(TAG, "setupView showTimeLeft: $showTimeLeft") - binding.durationLabel.setOnClickListener { showTimeLeft = !showTimeLeft val media = curMedia ?: return@setOnClickListener - val converter = TimeSpeedConverter(curSpeedMultiplier) val length: String if (showTimeLeft) { @@ -281,7 +297,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { length = getDurationStringLong(duration) } binding.durationLabel.text = length - setShowRemainTimeSetting(showTimeLeft) Logd("timeleft on click", if (showTimeLeft) "true" else "false") } @@ -304,15 +319,12 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { binding.videoView.holder.addCallback(surfaceHolderCallback) binding.bottomControlsContainer.fitsSystemWindows = true // binding.videoView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - setupVideoControlsToggler() // (activity as AppCompatActivity).window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - binding.videoPlayerContainer.setOnTouchListener(onVideoviewTouched) binding.videoPlayerContainer.viewTreeObserver.addOnGlobalLayoutListener { binding.videoView.setAvailableSize(binding.videoPlayerContainer.width.toFloat(), binding.videoPlayerContainer.height.toFloat()) } - webvDescription = binding.webvDescription // webvDescription.setTimecodeSelectedListener { time: Int? -> // val cMedia = getMedia @@ -325,45 +337,11 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { // } // registerForContextMenu(webvDescription) // webvDescription.visibility = View.GONE - - binding.toggleViews.setOnClickListener { - (activity as? VideoplayerActivity)?.toggleViews() - } + binding.toggleViews.setOnClickListener { (activity as? VideoplayerActivity)?.toggleViews() } binding.audioOnly.setOnClickListener { (activity as? VideoplayerActivity)?.switchToAudioOnly = true (activity as? VideoplayerActivity)?.finish() } - - } - - private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent -> - if (event.action != MotionEvent.ACTION_DOWN) return@OnTouchListener false - - if (PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) return@OnTouchListener true - - videoControlsHider.removeCallbacks(hideVideoControls) - - if (System.currentTimeMillis() - lastScreenTap < 300) { - if (event.x > v.measuredWidth / 2.0f) { - onFastForward() - showSkipAnimation(true) - } else { - onRewind() - showSkipAnimation(false) - } - if (videoControlsShowing) { - hideVideoControls(false) - if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() - videoControlsShowing = false - } - return@OnTouchListener true - } - - toggleVideoControlsVisibility() - if (videoControlsShowing) setupVideoControlsToggler() - - lastScreenTap = System.currentTimeMillis() - true } fun toggleVideoControlsVisibility() { @@ -393,17 +371,14 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { binding.skipAnimationImage.setImageResource(R.drawable.ic_fast_rewind_video_white) params.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL } - binding.skipAnimationImage.visibility = View.VISIBLE binding.skipAnimationImage.layoutParams = params binding.skipAnimationImage.startAnimation(skipAnimation) skipAnimation.setAnimationListener(object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) {} - override fun onAnimationEnd(animation: Animation) { binding.skipAnimationImage.visibility = View.GONE } - override fun onAnimationRepeat(animation: Animation) {} }) } @@ -417,7 +392,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { override fun surfaceCreated(holder: SurfaceHolder) { Logd(TAG, "Videoview holder created") videoSurfaceCreated = true - if (MediaPlayerBase.status == PlayerStatus.PLAYING) setVideoSurface(holder) +// if (MediaPlayerBase.status == PlayerStatus.PLAYING) setVideoSurface(holder) + if (MediaPlayerBase.status == PlayerStatus.PLAYING) playbackService?.mPlayer?.setVideoSurface(holder) setupVideoAspectRatio() } @@ -431,27 +407,24 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { fun notifyVideoSurfaceAbandoned() { // playbackService?.notifyVideoSurfaceAbandoned() - playbackService?.mediaPlayer?.pause(abandonFocus = true, reinit = false) - playbackService?.mediaPlayer?.resetVideoSurface() + playbackService?.mPlayer?.pause(abandonFocus = true, reinit = false) + playbackService?.mPlayer?.resetVideoSurface() } - fun setVideoSurface(holder: SurfaceHolder?) { - playbackService?.mediaPlayer?.setVideoSurface(holder) - } +// fun setVideoSurface(holder: SurfaceHolder?) { +// playbackService?.mPlayer?.setVideoSurface(holder) +// } @UnstableApi fun onRewind() { if (controller == null) return - - val curr = position - seekTo(curr - rewindSecs * 1000) + playbackService?.mPlayer?.seekDelta(-rewindSecs * 1000) setupVideoControlsToggler() } @UnstableApi fun onPlayPause() { if (controller == null) return - controller!!.playPause() setupVideoControlsToggler() } @@ -459,9 +432,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { @UnstableApi fun onFastForward() { if (controller == null) return - - val curr = position - seekTo(curr + fastForwardSecs * 1000) + playbackService?.mPlayer?.seekDelta(fastForwardSecs * 1000) setupVideoControlsToggler() } @@ -512,11 +483,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { private fun onPositionObserverUpdate() { if (controller == null) return - val converter = TimeSpeedConverter(curSpeedMultiplier) - val currentPosition = converter.convert(position) + val currentPosition = converter.convert(curPosition) val duration_ = converter.convert(duration) - val remainingTime = converter.convert(duration - position) + val remainingTime = converter.convert(duration - curPosition) // Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition)); if (currentPosition == Playable.INVALID_TIME || duration_ == Playable.INVALID_TIME) { Log.w(TAG, "Could not react to position observer update because of invalid time") @@ -537,7 +507,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { if (controller == null) return - if (fromUser) { prog = progress / (seekBar.max.toFloat()) val converter = TimeSpeedConverter(curSpeedMultiplier) @@ -559,7 +528,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { override fun onStopTrackingTouch(seekBar: SeekBar) { seekTo((prog * duration).toInt()) - binding.seekCardView.scaleX = 1f binding.seekCardView.scaleY = 1f binding.seekCardView.animate() @@ -574,6 +542,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { val TAG: String = VideoEpisodeFragment::class.simpleName ?: "Anonymous" val videoSize: Pair? - get() = playbackService?.mediaPlayer?.getVideoSize() + get() = playbackService?.mPlayer?.getVideoSize() } } \ No newline at end of file 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 index 9cd7d5b3..39a20814 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsListAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsListAdapter.kt @@ -50,14 +50,6 @@ abstract class StatisticsListAdapter protected constructor(@JvmField protected v } else { val holder = h as StatisticsHolder val statsItem = statisticsData!![position - 1] -// if (!statsItem.feed.imageUrl.isNullOrBlank()) Glide.with(context) -// .load(statsItem.feed.imageUrl) -// .apply(RequestOptions() -// .placeholder(R.color.light_gray) -// .error(R.color.light_gray) -// .fitCenter() -// .dontAnimate()) -// .into(holder.image) holder.image.load(statsItem.feed.imageUrl) { placeholder(R.color.light_gray) error(R.mipmap.ic_launcher) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/utils/CoverLoader.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/utils/CoverLoader.kt index 9825aed3..f921aea8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/utils/CoverLoader.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/utils/CoverLoader.kt @@ -75,7 +75,7 @@ class CoverLoader(private val activity: MainActivity) { .data(uri) .setHeader("User-Agent", "Mozilla/5.0") .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, throwable: ErrorResult) { + override fun onError(request: ImageRequest, result: ErrorResult) { Logd("CoverLoader", "Trying to get fallback image") val fallbackImageRequest = ImageRequest.Builder(activity) .data(fallbackUri) @@ -99,13 +99,13 @@ class CoverLoader(private val activity: MainActivity) { override fun onStart(placeholder: Drawable?) { } - override fun onError(errorDrawable: Drawable?) { + override fun onError(error: Drawable?) { setTitleVisibility(fallbackTitle.get(), true) } - override fun onSuccess(resource: Drawable) { + override fun onSuccess(result: Drawable) { val ivCover = cover.get() - ivCover!!.setImageDrawable(resource) + ivCover!!.setImageDrawable(result) setTitleVisibility(fallbackTitle.get(), textAndImageCombined) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt index 4c205314..5470a141 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt @@ -14,6 +14,7 @@ import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.PlaybackSpeedActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.util.Converter.getDurationStringLong +import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.TimeSpeedConverter import android.appwidget.AppWidgetManager import android.content.ComponentName @@ -45,11 +46,12 @@ object WidgetUpdater { * Update the widgets with the given parameters. Must be called in a background thread. */ fun updateWidget(context: Context, widgetState: WidgetState?) { - if (!isEnabled(context) || widgetState == null) return + if (!isEnabled() || widgetState == null) return + Logd(TAG, "in updateWidget") - val startMediaPlayer = if (widgetState.media != null && widgetState.media.getMediaType() === MediaType.VIDEO) - VideoPlayerActivityStarter(context).pendingIntent - else MainActivityStarter(context).withOpenPlayer().pendingIntent + val startMediaPlayer = + if (widgetState.media != null && widgetState.media.getMediaType() === MediaType.VIDEO) VideoPlayerActivityStarter(context).pendingIntent + else MainActivityStarter(context).withOpenPlayer().pendingIntent val startPlaybackSpeedDialog = PlaybackSpeedActivityStarter(context).pendingIntent val views = RemoteViews(context.packageName, R.layout.player_widget) @@ -61,26 +63,9 @@ object WidgetUpdater { views.setOnClickPendingIntent(R.id.imgvCover, startMediaPlayer) views.setOnClickPendingIntent(R.id.butPlaybackSpeed, startPlaybackSpeedDialog) - val radius = context.resources.getDimensionPixelSize(R.dimen.widget_inner_radius) -// val options = RequestOptions() -// .dontAnimate() -// .transform(FitCenter(), RoundedCorners(radius)) - try { val imgLoc = widgetState.media.getImageLocation() val imgLoc1 = getFallbackImageLocation(widgetState.media) -// icon = Glide.with(context) -// .asBitmap() -// .load(imgLoc) -// .error(Glide.with(context) -// .asBitmap() -// .load(imgLoc1) -// .apply(options) -// .submit(iconSize, iconSize)[500, TimeUnit.MILLISECONDS]) -// .apply(options) -// .submit(iconSize, iconSize) -// .get(500, TimeUnit.MILLISECONDS) - CoroutineScope(Dispatchers.IO).launch { val request = ImageRequest.Builder(context) .data(imgLoc) @@ -162,6 +147,7 @@ object WidgetUpdater { val widgetIds = manager.getAppWidgetIds(playerWidget) for (id in widgetIds) { + Logd(TAG, "updating widget $id") val options = manager.getAppWidgetOptions(id) // val prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE) val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/PowerUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/PowerUtils.kt deleted file mode 100644 index 95ce3efd..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/PowerUtils.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ac.mdiq.podcini.util - -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.BatteryManager - -/** - * Created by Tom on 1/5/15. - */ -object PowerUtils { - /** - * @return true if the device is charging - */ - @JvmStatic - fun deviceCharging(context: Context): Boolean { - // from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html - val iFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) - val batteryStatus = context.registerReceiver(null, iFilter) - - val status = batteryStatus!!.getIntExtra(BatteryManager.EXTRA_STATUS, -1) - return (status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/config/ApplicationCallbacksImpl.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/config/ApplicationCallbacksImpl.kt deleted file mode 100644 index 79e8b8fb..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/config/ApplicationCallbacksImpl.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ac.mdiq.podcini.util.config - -import android.app.Application -import ac.mdiq.podcini.PodciniApp - - -class ApplicationCallbacksImpl : ApplicationCallbacks { - override fun getApplicationInstance(): Application { - return PodciniApp.getInstance() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/error/InvalidFeedException.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/error/InvalidFeedException.kt deleted file mode 100644 index 1a307c4f..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/error/InvalidFeedException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ac.mdiq.podcini.util.error - -/** - * Thrown if a feed has invalid attribute values. - */ -class InvalidFeedException(message: String?) : Exception(message) { - companion object { - private const val serialVersionUID = 1L - } -} 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 3eb4c2cd..a8934ed4 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 @@ -5,12 +5,14 @@ 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 @@ -26,7 +28,7 @@ import kotlin.math.max sealed class FlowEvent { val TAG = this::class.simpleName ?: "FlowEvent" - data class PlaybackPositionEvent(val position: Int, val duration: Int) : FlowEvent() + data class PlaybackPositionEvent(val media: Playable?, val position: Int, val duration: Int) : FlowEvent() data class PlaybackServiceEvent(val action: Action) : FlowEvent() { enum class Action { SERVICE_STARTED, SERVICE_SHUT_DOWN, } @@ -124,34 +126,25 @@ sealed class FlowEvent { } } - data class FeedListUpdateEvent(val feedIds: List = emptyList()) : FlowEvent() { - constructor(feed: Feed) : this(listOf(feed.id)) - constructor(feedId: Long) : this(listOf(feedId)) - constructor(feeds: List, junk: String = "") : this(feeds.map { it.id }) + data class FeedListEvent(val action: Action, val feedIds: List = emptyList()) : FlowEvent() { + enum class Action { ADDED, REMOVED, ERROR, UNKNOWN } + + constructor(action: Action, feedId: Long) : this(action, listOf(feedId)) fun contains(feed: Feed): Boolean { return feedIds.contains(feed.id) } } + data class FeedsSortedEvent(val dummy: Unit = Unit) : FlowEvent() + // data class SkipIntroEndingChangedEvent(val skipIntro: Int, val skipEnding: Int, val feedId: Long) : FlowEvent() - data class VolumeAdaptionChangedEvent(val volumeAdaptionSetting: VolumeAdaptionSetting, val feedId: Long) : FlowEvent() +// handled together in FeedPrefsChangeEvent + // data class VolumeAdaptionChangedEvent(val volumeAdaptionSetting: VolumeAdaptionSetting, val feedId: Long) : FlowEvent() + data class FeedPrefsChangeEvent(val feed: Feed) : FlowEvent() - // TODO: consider merging the two data class SpeedChangedEvent(val newSpeed: Float) : FlowEvent() - data class FeedPrefsChangeEvent(val prefs: FeedPreferences) : FlowEvent() - - data class EpisodesFilterOrSortEvent(val action: Action, val feed: Feed) : FlowEvent() { - enum class Action { FILTER_CHANGED, SORT_ORDER_CHANGED } - - override fun toString(): String { - return ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) - .append("action", action) - .append("feedId", feed.id) - .toString() - } - } data class DownloadLogEvent(val dummy: Unit = Unit) : FlowEvent() @@ -160,8 +153,6 @@ sealed class FlowEvent { get() = map.keys } -// data class NewEpisodeDownloadEvent(val url: String) : FlowEvent() {} - // TODO: need better handling at receving end data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent() @@ -180,8 +171,6 @@ sealed class FlowEvent { } } - data class FeedsSortedEvent(val dummy: Unit = Unit) : FlowEvent() - data class FeedTagsChangedEvent(val dummy: Unit = Unit) : FlowEvent() data class FeedUpdateRunningEvent(val isFeedUpdateRunning: Boolean) : FlowEvent() @@ -195,12 +184,9 @@ sealed class FlowEvent { data class SyncServiceEvent(val messageResId: Int, val message: String = "") : FlowEvent() data class DiscoveryDefaultUpdateEvent(val dummy: Unit = Unit) : FlowEvent() - - data class DiscoveryCompletedEvent(val dummy: Unit = Unit) : FlowEvent() } object EventFlow { - val collectorCount = MutableStateFlow(0) val events: MutableSharedFlow = MutableSharedFlow(replay = 0) val stickyEvents: MutableSharedFlow = MutableSharedFlow(replay = 1) val keyEvents: MutableSharedFlow = MutableSharedFlow(replay = 0) @@ -211,7 +197,7 @@ object EventFlow { val caller = if (stackTrace.size > 3) stackTrace[3] else null Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted: $event") } - GlobalScope.launch(Dispatchers.Default) { + CoroutineScope(Dispatchers.Default).launch { events.emit(event) } } @@ -222,7 +208,7 @@ object EventFlow { val caller = if (stackTrace.size > 3) stackTrace[3] else null Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted sticky: $event") } - GlobalScope.launch(Dispatchers.Default) { + CoroutineScope(Dispatchers.Default).launch { stickyEvents.emit(event) } } @@ -233,7 +219,7 @@ object EventFlow { val caller = if (stackTrace.size > 3) stackTrace[3] else null Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted key: $event") } - GlobalScope.launch(Dispatchers.Default) { + CoroutineScope(Dispatchers.Default).launch { keyEvents.emit(event) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodePubdateComparator.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodePubdateComparator.kt deleted file mode 100644 index 574f80b3..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodePubdateComparator.kt +++ /dev/null @@ -1,20 +0,0 @@ -package ac.mdiq.podcini.util.sorting - -import ac.mdiq.podcini.storage.model.Episode - -/** - * Compares the pubDate of two FeedItems for sorting. - */ -class EpisodePubdateComparator : Comparator { - /** - * Returns a new instance of this comparator in reverse order. - */ - override fun compare(lhs: Episode, rhs: Episode): Int { - return when { - rhs.pubDate == null && lhs.pubDate == null -> 0 - rhs.pubDate == null -> 1 - lhs.pubDate == null -> -1 - else -> rhs.pubDate.compareTo(lhs.pubDate) ?: -1 - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodesPermutors.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodesPermutors.kt index 15e717a5..413fdeb7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodesPermutors.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/EpisodesPermutors.kt @@ -19,64 +19,38 @@ object EpisodesPermutors { var permutor: Permutor? = null when (sortOrder) { - SortOrder.EPISODE_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> - itemTitle(f1).compareTo(itemTitle(f2)) - } - SortOrder.EPISODE_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> - itemTitle(f2).compareTo(itemTitle(f1)) - } - SortOrder.DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> - pubDate(f1).compareTo(pubDate(f2)) - } - SortOrder.DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> - pubDate(f2).compareTo(pubDate(f1)) - } - SortOrder.DURATION_SHORT_LONG -> comparator = Comparator { f1: Episode?, f2: Episode? -> - duration(f1).compareTo(duration(f2)) - } - SortOrder.DURATION_LONG_SHORT -> comparator = Comparator { f1: Episode?, f2: Episode? -> - duration(f2).compareTo(duration(f1)) - } - SortOrder.EPISODE_FILENAME_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> - itemLink(f1).compareTo(itemLink(f2)) - } - SortOrder.EPISODE_FILENAME_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> - itemLink(f2).compareTo(itemLink(f1)) - } - SortOrder.PLAYED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> - playDate(f1).compareTo(playDate(f2)) - } - SortOrder.PLAYED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> - playDate(f2).compareTo(playDate(f1)) - } - SortOrder.COMPLETED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> - completeDate(f1).compareTo(completeDate(f2)) - } - SortOrder.COMPLETED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> - completeDate(f2).compareTo(completeDate(f1)) - } + SortOrder.EPISODE_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemTitle(f1).compareTo(itemTitle(f2)) } + SortOrder.EPISODE_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemTitle(f2).compareTo(itemTitle(f1)) } + SortOrder.DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> pubDate(f1).compareTo(pubDate(f2)) } + SortOrder.DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> pubDate(f2).compareTo(pubDate(f1)) } + SortOrder.DURATION_SHORT_LONG -> comparator = Comparator { f1: Episode?, f2: Episode? -> duration(f1).compareTo(duration(f2)) } + SortOrder.DURATION_LONG_SHORT -> comparator = Comparator { f1: Episode?, f2: Episode? -> duration(f2).compareTo(duration(f1)) } + SortOrder.EPISODE_FILENAME_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemLink(f1).compareTo(itemLink(f2)) } + SortOrder.EPISODE_FILENAME_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemLink(f2).compareTo(itemLink(f1)) } + SortOrder.PLAYED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> playDate(f1).compareTo(playDate(f2)) } + SortOrder.PLAYED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> playDate(f2).compareTo(playDate(f1)) } + SortOrder.COMPLETED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> completeDate(f1).compareTo(completeDate(f2)) } + SortOrder.COMPLETED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> completeDate(f2).compareTo(completeDate(f1)) } - SortOrder.FEED_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> - feedTitle(f1).compareTo(feedTitle(f2)) - } - SortOrder.FEED_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> - feedTitle(f2).compareTo(feedTitle(f1)) - } + SortOrder.FEED_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> feedTitle(f1).compareTo(feedTitle(f2)) } + SortOrder.FEED_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> feedTitle(f2).compareTo(feedTitle(f1)) } SortOrder.RANDOM -> permutor = object : Permutor { - override fun reorder(queue: MutableList?) {if (!queue.isNullOrEmpty()) queue.shuffle()} + override fun reorder(queue: MutableList?) { + if (!queue.isNullOrEmpty()) queue.shuffle() + } } SortOrder.SMART_SHUFFLE_OLD_NEW -> permutor = object : Permutor { - override fun reorder(queue: MutableList?) {if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList, true) } + override fun reorder(queue: MutableList?) { + if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList, true) + } } SortOrder.SMART_SHUFFLE_NEW_OLD -> permutor = object : Permutor { - override fun reorder(queue: MutableList?) {if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList, false) } - } - SortOrder.SIZE_SMALL_LARGE -> comparator = Comparator { f1: Episode?, f2: Episode? -> - size(f1).compareTo(size(f2)) - } - SortOrder.SIZE_LARGE_SMALL -> comparator = Comparator { f1: Episode?, f2: Episode? -> - size(f2).compareTo(size(f1)) + override fun reorder(queue: MutableList?) { + if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList, false) + } } + SortOrder.SIZE_SMALL_LARGE -> comparator = Comparator { f1: Episode?, f2: Episode? -> size(f1).compareTo(size(f2)) } + SortOrder.SIZE_LARGE_SMALL -> comparator = Comparator { f1: Episode?, f2: Episode? -> size(f2).compareTo(size(f1)) } } if (comparator != null) { val comparator2: Comparator = comparator @@ -87,7 +61,6 @@ object EpisodesPermutors { return permutor!! } - // Null-safe accessors private fun pubDate(item: Episode?): Date { return if (item == null) Date() else Date(item.pubDate) } @@ -154,9 +127,9 @@ object EpisodesPermutors { } // Sort each individual list by PubDate (ascending/descending) - val itemComparator: Comparator = if (ascending) - Comparator { f1: Episode, f2: Episode -> f1.pubDate?.compareTo(f2.pubDate)?:-1 } - else Comparator { f1: Episode, f2: Episode -> f2.pubDate?.compareTo(f1.pubDate)?:-1 } + val itemComparator: Comparator = + if (ascending) Comparator { f1: Episode, f2: Episode -> f1.pubDate?.compareTo(f2.pubDate)?:-1 } + else Comparator { f1: Episode, f2: Episode -> f2.pubDate?.compareTo(f1.pubDate)?:-1 } val feeds: MutableList> = ArrayList() for ((_, value) in map) { @@ -190,4 +163,18 @@ object EpisodesPermutors { } } } + + /** + * Interface for passing around list permutor method. This is used for cases where a simple comparator + * won't work (e.g. Random, Smart Shuffle, etc). + * + * @param the type of elements in the list + */ + interface Permutor { + /** + * Reorders the specified list. + * @param queue A (modifiable) list of elements to be reordered + */ + fun reorder(queue: MutableList?) + } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/Permutor.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/Permutor.kt deleted file mode 100644 index e61fdc2e..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/Permutor.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ac.mdiq.podcini.util.sorting - -/** - * Interface for passing around list permutor method. This is used for cases where a simple comparator - * won't work (e.g. Random, Smart Shuffle, etc). - * - * @param the type of elements in the list - */ -interface Permutor { - /** - * Reorders the specified list. - * @param queue A (modifiable) list of elements to be reordered - */ - fun reorder(queue: MutableList?) -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackCompletionDateComparator.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackCompletionDateComparator.kt deleted file mode 100644 index b0e78946..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackCompletionDateComparator.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ac.mdiq.podcini.util.sorting - -import ac.mdiq.podcini.storage.model.Episode - -class PlaybackCompletionDateComparator : Comparator { - override fun compare(lhs: Episode, rhs: Episode): Int { - if (lhs.media?.playbackCompletionDate != null && rhs.media?.playbackCompletionDate != null) - return rhs.media!!.playbackCompletionDate!!.compareTo(lhs.media!!.playbackCompletionDate) - - return 0 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackLastPlayedDateComparator.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackLastPlayedDateComparator.kt deleted file mode 100644 index 916d4915..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/sorting/PlaybackLastPlayedDateComparator.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ac.mdiq.podcini.util.sorting - -import ac.mdiq.podcini.storage.model.Episode - -class PlaybackLastPlayedDateComparator : Comparator { - override fun compare(lhs: Episode, rhs: Episode): Int { - if (lhs.media?.getLastPlayedTime() != null && rhs.media?.getLastPlayedTime() != null) - return rhs.media!!.getLastPlayedTime().compareTo(lhs.media!!.getLastPlayedTime()) - - return 0 - } -} diff --git a/app/src/main/play/listings/en-US/full-description.txt b/app/src/main/play/listings/en-US/full-description.txt index 7f28ef22..31214f48 100644 --- a/app/src/main/play/listings/en-US/full-description.txt +++ b/app/src/main/play/listings/en-US/full-description.txt @@ -1,31 +1,44 @@ -Podcini is an open-source podcast manager and player that allows you to subscribe to any RSS feed, gives you instant access to millions of free and paid podcasts, from independent podcasters to large publishing houses such as the BBC, NPR and CNN. Add, import and export their feeds hassle-free using the Apple Podcasts database, OPML files or simple RSS URLs. -Download, stream or queue episodes and enjoy them the way you like with adjustable playback speeds, chapter support and a sleep timer. +Podcini, an open source podcast instrument, attuned to Puccini, adorned with pasticcini and aromatized with porcini, invites your harmonious heartbeats. + +It allows you to subscribe to any RSS feed (with or without media), gives you instant access to millions of free and paid podcasts or plain RSS feeds, from independent podcasters to large publishing houses such as the BBC, NPR and CNN. Add, import and export their feeds hassle-free using the Apple Podcasts database, OPML files or simple RSS URLs. + +Download, stream or queue episodes and enjoy them the way you like with adjustable playback speeds, chapter support and a sleep timer. In case the feed contains no media (plain RSS feed), Text-to-Speech engine (if available) is used to play the text. Save effort, battery power and mobile data usage with powerful automation controls for downloading episodes (specify times, intervals and WiFi networks) and deleting episodes (based on your favourites and delay settings). +Transcripts or episode descriptions can be easily shared as text to other apps for record-keeping. + +Listening progress can be instantly synced across devices in the same wifi network. + Made by podcast-enthusiasts, Podcini is free in all senses of the word: open source, no costs, no ads. +Podcini emphasizes on efficient battery usage, streamlined podcast management and convenient player control while listening. + Import, organize and play -• Manage playback from anywhere: homescreen widget, system notification and earplug and bluetooth controls -• Add and import feeds via the Apple Podcasts, gPodder.net, fyyd or Podcast Index directories, OPML files and RSS or Atom links -• Enjoy listening your way with adjustable playback speed, chapter support, remembered playback position and an advanced sleep timer (shake to reset, lower volume) -• Access password-protected feeds and episodes + +* Manage playback from anywhere: homescreen widget, system notification and earplug and bluetooth controls +* Add and import feeds via the Apple Podcasts, gPodder.net, fyyd or Podcast Index directories, OPML files and RSS or Atom links +* Enjoy listening your way with adjustable playback speed, chapter support, remembered playback position and an advanced sleep timer (shake to reset, lower volume) +* Access password-protected feeds and episodes Keep track, share & appreciate -• Keep track of the best of the best by marking episodes as favourites -• Find that one episode through the playback history or by searching titles and shownotes -• Share episodes and feeds through advanced social media and email options, the gPodder.net services and via OPML export + +* Keep track of the best of the best by marking episodes as favourites +* Find that one episode through the playback history or by searching titles and shownotes +* Share episodes and feeds through advanced social media and email options, the gPodder.net services and via OPML export Control the system -• Take control over automated downloading: choose feeds, exclude mobile networks, select specific WiFi networks, require the phone to be charging and set times or intervals -• Manage storage by setting the amount of cached episodes, smart deletion and selecting your preferred location -• Adapt to your environment using the light and dark theme -• Back-up your subscriptions with the gPodder.net integration and OPML export + +* Take control over automated downloading: choose feeds, exclude mobile networks, select specific WiFi networks, require the phone to be charging and set times or intervals +* Manage storage by setting the amount of cached episodes, smart deletion and selecting your preferred location +* Adapt to your environment using the light and dark theme +* Back-up your subscriptions with the gPodder.net integration and OPML export Join the Podcini community! + Podcini is under active development by volunteers. You can contribute too, with code or with comment! https://github.com/XilinJia/Podcini Transifex is the place to help with translations: -(to be announced) +https://app.transifex.com/xilinjia/podcini/dashboard/ diff --git a/app/src/main/play/release-notes/en-US/default.txt b/app/src/main/play/release-notes/en-US/default.txt index a6d7e4fa..e69de29b 100644 --- a/app/src/main/play/release-notes/en-US/default.txt +++ b/app/src/main/play/release-notes/en-US/default.txt @@ -1,8 +0,0 @@ -🥂 A small treat for our users - check the Home screen on 10 December! (@ByteHamster, @keunes) -∙ Accessibility & synchronisation improvements (@ByteHamster) -∙ Add 'boost' options in the 'Volume adaptation' podcast setting (@matejdro) -∙ Support double/triple-pressing headset buttons (@blairun) -∙ Improve support for password-protected feeds (@ByteHamster) -∙ Add multi-select actions on search results (@vinodpatildev) -∙ Allow deleting local feed episodes (@matejdro) -∙ Android Auto bugfix (@harshad1) \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_home_24.xml b/app/src/main/res/drawable/outline_home_24.xml new file mode 100644 index 00000000..94c8f78a --- /dev/null +++ b/app/src/main/res/drawable/outline_home_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_subscriptions.xml b/app/src/main/res/layout/fragment_subscriptions.xml index 087d2164..253c1a23 100644 --- a/app/src/main/res/layout/fragment_subscriptions.xml +++ b/app/src/main/res/layout/fragment_subscriptions.xml @@ -22,7 +22,7 @@ app:navigationIcon="?homeAsUpIndicator" /> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/mediaplayer.xml b/app/src/main/res/menu/mediaplayer.xml index 82ce95c4..75ec8af2 100644 --- a/app/src/main/res/menu/mediaplayer.xml +++ b/app/src/main/res/menu/mediaplayer.xml @@ -6,6 +6,7 @@ android:id="@+id/show_home_reader_view" android:icon="@drawable/baseline_home_24" android:title="@string/home_label" + android:checkable="true" custom:showAsAction="always"> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e9d6c8ea..63e7f9a1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -46,7 +46,7 @@ Přehráno mezi 1%1$s a 2%2$s Celkem přehnáno -\ Zamítli jste oprávnění. + Zamítli jste oprávnění. Pokud oznámení zakážete a něco se pokazí, nemusí se vám podařit zjistit proč. Zakázat Otevřít nastavení diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d570174..d2e2669f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -509,6 +509,8 @@ Full screen Small window Audio only + Subscriptions use grid layout + When set, Subscriptions view use a grid layout, otherwise list layout High notification priority This usually expands the notification to show playback buttons. Persistent playback controls diff --git a/app/src/main/res/xml/preferences_user_interface.xml b/app/src/main/res/xml/preferences_user_interface.xml index 3314bb83..d0fc8297 100644 --- a/app/src/main/res/xml/preferences_user_interface.xml +++ b/app/src/main/res/xml/preferences_user_interface.xml @@ -38,17 +38,13 @@ android:title="@string/pref_nav_drawer_feed_order_title" android:key="prefDrawerFeedOrder" android:summary="@string/pref_nav_drawer_feed_order_sum"/> - - - - - - - - - - - + + diff --git a/app/src/play/kotlin/ac/mdiq/podcini/dialog/RatingDialog.kt b/app/src/play/kotlin/ac/mdiq/podcini/dialog/RatingDialog.kt index 50c9f67d..37891387 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/dialog/RatingDialog.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/dialog/RatingDialog.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.content.Context import android.content.SharedPreferences import android.util.Log +import ac.mdiq.podcini.util.Logd import androidx.annotation.VisibleForTesting import com.google.android.play.core.review.ReviewInfo import com.google.android.play.core.review.ReviewManager @@ -66,15 +67,15 @@ object RatingDialog { .putInt(KEY_NUMBER_OF_REVIEWS, previousAttempts + 1) .apply() } - Log.i("ReviewDialog", "Successfully finished in-app review") + Logd("ReviewDialog", "Successfully finished in-app review") } .addOnFailureListener { error: Exception? -> - Log.i("ReviewDialog", "failed in reviewing process") + Logd("ReviewDialog", "failed in reviewing process") } } } .addOnFailureListener { error: Exception? -> - Log.i("ReviewDialog", "failed to get in-app review request") + Logd("ReviewDialog", "failed to get in-app review request") } } diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt index 922dd14d..831532d9 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt @@ -2,6 +2,7 @@ package ac.mdiq.podcini.playback.cast import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.MediaPlayerBase +import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Playable diff --git a/app/src/test/kotlin/ac/mdiq/podcini/feed/FeedMother.kt b/app/src/test/kotlin/ac/mdiq/podcini/feed/FeedMother.kt index 36cace25..8a0e4ea5 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/feed/FeedMother.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/feed/FeedMother.kt @@ -9,6 +9,6 @@ object FeedMother { fun anyFeed(): Feed { return Feed(0, null, "title", "http://example.com", "This is the description", "http://example.com/payment", "Daniel", "en", null, "http://example.com/feed", IMAGE_URL, - null, "http://example.com/feed", true) + null, "http://example.com/feed") } } diff --git a/app/src/test/kotlin/ac/mdiq/podcini/parser/feed/element/namespace/FeedParserTestHelper.kt b/app/src/test/kotlin/ac/mdiq/podcini/parser/feed/element/namespace/FeedParserTestHelper.kt index 65f2ede5..7ca3c659 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/parser/feed/element/namespace/FeedParserTestHelper.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/parser/feed/element/namespace/FeedParserTestHelper.kt @@ -13,7 +13,7 @@ object FeedParserTestHelper { */ @JvmStatic fun getFeedFile(fileName: String): File { - return File(FeedParserTestHelper::class.java.classLoader.getResource(fileName).file) + return File(FeedParserTestHelper::class.java.classLoader?.getResource(fileName)?.file?:"") } /** @@ -25,7 +25,6 @@ object FeedParserTestHelper { val handler = FeedHandler() val parsedFeed = Feed("http://example.com/feed", null) parsedFeed.fileUrl = (feedFile.absolutePath) - parsedFeed.downloaded = (true) handler.parseFeed(parsedFeed) return parsedFeed } diff --git a/app/src/test/kotlin/ac/mdiq/podcini/service/playback/VolumeUpdaterTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/service/playback/VolumeUpdaterTest.kt index 73e397fd..40d05eb4 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/service/playback/VolumeUpdaterTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/service/playback/VolumeUpdaterTest.kt @@ -21,14 +21,14 @@ class VolumeUpdaterTest { @Test fun noChangeIfNoFeedMediaPlaying() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.PAUSED) val noFeedMedia = Mockito.mock(Playable::class.java) Mockito.`when`(curMedia).thenReturn(noFeedMedia) - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) Mockito.verify(mediaPlayer, Mockito.never())?.pause(ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean()) Mockito.verify(mediaPlayer, Mockito.never())?.resume() @@ -36,14 +36,14 @@ class VolumeUpdaterTest { @Test fun noChangeIfPlayerStatusIsError() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.ERROR) val feedMedia = mockFeedMedia() Mockito.`when`(curMedia).thenReturn(feedMedia) - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) Mockito.verify(mediaPlayer, Mockito.never())?.pause(ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean()) Mockito.verify(mediaPlayer, Mockito.never())?.resume() @@ -51,14 +51,14 @@ class VolumeUpdaterTest { @Test fun noChangeIfPlayerStatusIsIndeterminate() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.INDETERMINATE) val feedMedia = mockFeedMedia() Mockito.`when`(curMedia).thenReturn(feedMedia) - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) Mockito.verify(mediaPlayer, Mockito.never())?.pause(ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean()) Mockito.verify(mediaPlayer, Mockito.never())?.resume() @@ -66,14 +66,14 @@ class VolumeUpdaterTest { @Test fun noChangeIfPlayerStatusIsStopped() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.STOPPED) val feedMedia = mockFeedMedia() Mockito.`when`(curMedia).thenReturn(feedMedia) - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) Mockito.verify(mediaPlayer, Mockito.never())?.pause(ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean()) Mockito.verify(mediaPlayer, Mockito.never())?.resume() @@ -87,8 +87,8 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) Mockito.`when`(feedMedia.episode?.feed?.id).thenReturn(FEED_ID + 1) - val volumeUpdater = PlaybackService.VolumeUpdater() - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) +// val volumeUpdater = PlaybackService.VolumeUpdater() + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF) Mockito.verify(mediaPlayer, Mockito.never())?.pause(ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean()) Mockito.verify(mediaPlayer, Mockito.never())?.resume() @@ -96,7 +96,7 @@ class VolumeUpdaterTest { @Test fun updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsPaused() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.PAUSED) @@ -104,7 +104,7 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!! - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) Mockito.verify(feedPreferences, Mockito.times(1)) .volumeAdaptionSetting = (VolumeAdaptionSetting.LIGHT_REDUCTION) @@ -115,7 +115,7 @@ class VolumeUpdaterTest { @Test fun updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsPrepared() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.PREPARED) @@ -123,7 +123,7 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!! - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) Mockito.verify(feedPreferences, Mockito.times(1)) .volumeAdaptionSetting = (VolumeAdaptionSetting.LIGHT_REDUCTION) @@ -134,7 +134,7 @@ class VolumeUpdaterTest { @Test fun updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsInitializing() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.INITIALIZING) @@ -142,7 +142,7 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!! - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) Mockito.verify(feedPreferences, Mockito.times(1)) .volumeAdaptionSetting = (VolumeAdaptionSetting.LIGHT_REDUCTION) @@ -153,7 +153,7 @@ class VolumeUpdaterTest { @Test fun updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsPreparing() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.PREPARING) @@ -161,7 +161,7 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!! - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) Mockito.verify(feedPreferences, Mockito.times(1)) .volumeAdaptionSetting = (VolumeAdaptionSetting.LIGHT_REDUCTION) @@ -172,7 +172,7 @@ class VolumeUpdaterTest { @Test fun updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsSeeking() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.SEEKING) @@ -180,7 +180,7 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!! - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION) Mockito.verify(feedPreferences, Mockito.times(1)) .volumeAdaptionSetting = (VolumeAdaptionSetting.LIGHT_REDUCTION) @@ -191,7 +191,7 @@ class VolumeUpdaterTest { @Test fun updatesPreferencesAndForcesVolumeChangeForLoadedFeedMediaIfPlayerStatusIsPlaying() { - val volumeUpdater = PlaybackService.VolumeUpdater() +// val volumeUpdater = PlaybackService.VolumeUpdater() Mockito.`when`(MediaPlayerBase.status).thenReturn(PlayerStatus.PLAYING) @@ -199,7 +199,7 @@ class VolumeUpdaterTest { Mockito.`when`(curMedia).thenReturn(feedMedia) val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!! - volumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.HEAVY_REDUCTION) + PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.HEAVY_REDUCTION) Mockito.verify(feedPreferences, Mockito.times(1)) .volumeAdaptionSetting = (VolumeAdaptionSetting.HEAVY_REDUCTION) diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/APCleanupAlgorithmTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/APCleanupAlgorithmTest.kt index 228464f3..c79eb22b 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/APCleanupAlgorithmTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/APCleanupAlgorithmTest.kt @@ -1,6 +1,6 @@ package ac.mdiq.podcini.storage -import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APCleanupAlgorithm +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APCleanupAlgorithm import org.junit.Assert import org.junit.Test import java.text.SimpleDateFormat diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbCleanupTests.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbCleanupTests.kt index ca89f3de..7e3614e4 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbCleanupTests.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbCleanupTests.kt @@ -1,7 +1,7 @@ package ac.mdiq.podcini.storage import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.Episodes.performAutoCleanup +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.performAutoCleanup import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbNullCleanupAlgorithmTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbNullCleanupAlgorithmTest.kt index d89c8e30..61a28475 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbNullCleanupAlgorithmTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbNullCleanupAlgorithmTest.kt @@ -1,7 +1,7 @@ package ac.mdiq.podcini.storage import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.Episodes.performAutoCleanup +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.performAutoCleanup import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbPlayQueueCleanupAlgorithmTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbPlayQueueCleanupAlgorithmTest.kt index d7236e62..ccd683d5 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbPlayQueueCleanupAlgorithmTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbPlayQueueCleanupAlgorithmTest.kt @@ -3,7 +3,7 @@ package ac.mdiq.podcini.storage import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.Episodes.performAutoCleanup +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.performAutoCleanup import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbReaderTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbReaderTest.kt index ca14db4f..85cdad0b 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbReaderTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbReaderTest.kt @@ -69,10 +69,10 @@ class DbReaderTest { // val adapter = getInstance() // adapter.open() // -// val feed1 = Feed(0, null, "A", "link", "d", null, null, null, "rss", "A", null, "", "", true) -// val feed2 = Feed(0, null, "b", "link", "d", null, null, null, "rss", "b", null, "", "", true) -// val feed3 = Feed(0, null, "C", "link", "d", null, null, null, "rss", "C", null, "", "", true) -// val feed4 = Feed(0, null, "d", "link", "d", null, null, null, "rss", "d", null, "", "", true) +// val feed1 = Feed(0, null, "A", "link", "d", null, null, null, "rss", "A", null, "", "") +// val feed2 = Feed(0, null, "b", "link", "d", null, null, null, "rss", "b", null, "", "") +// val feed3 = Feed(0, null, "C", "link", "d", null, null, null, "rss", "C", null, "", "") +// val feed4 = Feed(0, null, "d", "link", "d", null, null, null, "rss", "d", null, "", "") // adapter.setCompleteFeed(feed1) // adapter.setCompleteFeed(feed2) // adapter.setCompleteFeed(feed3) diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbTestUtils.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbTestUtils.kt index b0f16536..766214fb 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/DbTestUtils.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/DbTestUtils.kt @@ -24,7 +24,7 @@ internal object DbTestUtils { // adapter.open() // for (i in 0 until numFeeds) { // val f = Feed(0, null, "feed $i", "link$i", "descr", null, null, -// null, null, "id$i", null, null, "url$i", false) +// null, null, "id$i", null, null, "url$i") // f.items.clear() // for (j in 0 until numItems) { // val item = FeedItem(0, "item $j", "id$j", "link$j", Date(), diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/EpisodeDuplicateGuesserTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/EpisodeDuplicateGuesserTest.kt index 65e1fb93..2b7d8920 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/EpisodeDuplicateGuesserTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/EpisodeDuplicateGuesserTest.kt @@ -1,6 +1,6 @@ package ac.mdiq.podcini.storage -import ac.mdiq.podcini.storage.database.Episodes.EpisodeDuplicateGuesser.seemDuplicates +import ac.mdiq.podcini.storage.database.Feeds.EpisodeDuplicateGuesser.seemDuplicates import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia import org.junit.Assert diff --git a/app/src/test/kotlin/ac/mdiq/podcini/storage/ExceptFavoriteCleanupAlgorithmTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/storage/ExceptFavoriteCleanupAlgorithmTest.kt index 526abcee..b3351434 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/storage/ExceptFavoriteCleanupAlgorithmTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/storage/ExceptFavoriteCleanupAlgorithmTest.kt @@ -3,7 +3,7 @@ package ac.mdiq.podcini.storage import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.Episodes.performAutoCleanup +import ac.mdiq.podcini.storage.algorithms.AutoCleanups.performAutoCleanup import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith diff --git a/changelog.md b/changelog.md index 9ae61185..3c051cf8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,18 @@ +## 6.0.3 + +* minor class restructuring +* PlayerDetailed view updates properly when new episode starts playing +* on PlayerDetailed and EpisodeHome views the home button on action bar has a toggle +* progressive loading in some episodes list views are more efficient +* live monitoring feed changes in DB +* re-worked some events related to feed changes +* fixed issue of player skipping to next or fast-forwarding past the end +* fixed issue of not properly handling widgets (existing since some release of version 5) +* grid view is enabled for Subscriptions and can be switched on in Settings->User interface +* on importing preferences, PlayerWidgetPrefs is ignored +* position updates to widget is also set for every 5 seconds +* further class restructuring and code cleaning + ## 6.0.2 * filtered query for episodes is more efficient diff --git a/fastlane/metadata/android/en-US/changelogs/3020203.txt b/fastlane/metadata/android/en-US/changelogs/3020203.txt new file mode 100644 index 00000000..42035399 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020203.txt @@ -0,0 +1,15 @@ + +Version 6.0.3 brings several changes: + +* minor class restructuring +* PlayerDetailed view updates properly when new episode starts playing +* on PlayerDetailed and EpisodeHome views the home button on action bar has a toggle +* progressive loading in some episodes list views are more efficient +* live monitoring feed changes in DB +* re-worked some events related to feed changes +* fixed issue of player skipping to next or fast-forwarding past the end +* fixed issue of not properly handling widgets (existing since some release of version 5) +* grid view is enabled for Subscriptions and can be switched on in Settings->User interface +* on importing preferences, PlayerWidgetPrefs is ignored +* position updates to widget is also set for every 5 seconds +* further class restructuring and code cleaning diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index b267ff5f..31214f48 100755 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -40,3 +40,5 @@ Podcini emphasizes on efficient battery usage, streamlined podcast management an Podcini is under active development by volunteers. You can contribute too, with code or with comment! https://github.com/XilinJia/Podcini +Transifex is the place to help with translations: +https://app.transifex.com/xilinjia/podcini/dashboard/