6.0.3 commit

This commit is contained in:
Xilin Jia 2024-06-28 23:08:14 +01:00
parent 85407855d7
commit 1afc48290d
129 changed files with 2719 additions and 3273 deletions

View File

@ -2,46 +2,46 @@
host = https://www.transifex.com host = https://www.transifex.com
[o:podcini:p:podcini:r:core-values] [o:podcini:p:podcini:r:core-values]
file_filter = ui/i18n/src/main/res/values-<lang>/strings.xml file_filter = app/src/main/res/values-<lang>/strings.xml
source_file = ui/i18n/src/main/res/values/strings.xml source_file = app/src/main/res/values/strings.xml
source_lang = en source_lang = en
trans.ar = ui/i18n/src/main/res/values-ar/strings.xml trans.ar = app/src/main/res/values-ar/strings.xml
trans.ast_ES = ui/i18n/src/main/res/values-ast/strings.xml trans.ast_ES = app/src/main/res/values-ast/strings.xml
trans.br = ui/i18n/src/main/res/values-br/strings.xml trans.br = app/src/main/res/values-br/strings.xml
trans.ca = ui/i18n/src/main/res/values-ca/strings.xml trans.ca = app/src/main/res/values-ca/strings.xml
trans.cs_CZ = ui/i18n/src/main/res/values-cs/strings.xml trans.cs_CZ = app/src/main/res/values-cs/strings.xml
trans.da = ui/i18n/src/main/res/values-da/strings.xml trans.da = app/src/main/res/values-da/strings.xml
trans.de = ui/i18n/src/main/res/values-de/strings.xml trans.de = app/src/main/res/values-de/strings.xml
trans.es = ui/i18n/src/main/res/values-es/strings.xml trans.es = app/src/main/res/values-es/strings.xml
trans.et = ui/i18n/src/main/res/values-et/strings.xml trans.et = app/src/main/res/values-et/strings.xml
trans.eu = ui/i18n/src/main/res/values-eu/strings.xml trans.eu = app/src/main/res/values-eu/strings.xml
trans.fa = ui/i18n/src/main/res/values-fa/strings.xml trans.fa = app/src/main/res/values-fa/strings.xml
trans.fi = ui/i18n/src/main/res/values-fi/strings.xml trans.fi = app/src/main/res/values-fi/strings.xml
trans.fr = ui/i18n/src/main/res/values-fr/strings.xml trans.fr = app/src/main/res/values-fr/strings.xml
trans.gl = ui/i18n/src/main/res/values-gl/strings.xml trans.gl = app/src/main/res/values-gl/strings.xml
trans.he_IL = ui/i18n/src/main/res/values-iw/strings.xml trans.he_IL = app/src/main/res/values-iw/strings.xml
trans.hi_IN = ui/i18n/src/main/res/values-hi/strings.xml trans.hi_IN = app/src/main/res/values-hi/strings.xml
trans.hu = ui/i18n/src/main/res/values-hu/strings.xml trans.hu = app/src/main/res/values-hu/strings.xml
trans.id = ui/i18n/src/main/res/values-in/strings.xml trans.id = app/src/main/res/values-in/strings.xml
trans.it_IT = ui/i18n/src/main/res/values-it/strings.xml trans.it_IT = app/src/main/res/values-it/strings.xml
trans.ja = ui/i18n/src/main/res/values-ja/strings.xml trans.ja = app/src/main/res/values-ja/strings.xml
trans.ko = ui/i18n/src/main/res/values-ko/strings.xml trans.ko = app/src/main/res/values-ko/strings.xml
trans.lt = ui/i18n/src/main/res/values-lt/strings.xml trans.lt = app/src/main/res/values-lt/strings.xml
trans.nb_NO = ui/i18n/src/main/res/values-nb/strings.xml trans.nb_NO = app/src/main/res/values-nb/strings.xml
trans.nl = ui/i18n/src/main/res/values-nl/strings.xml trans.nl = app/src/main/res/values-nl/strings.xml
trans.pl_PL = ui/i18n/src/main/res/values-pl/strings.xml trans.pl_PL = app/src/main/res/values-pl/strings.xml
trans.pt = ui/i18n/src/main/res/values-pt/strings.xml trans.pt = app/src/main/res/values-pt/strings.xml
trans.pt_BR = ui/i18n/src/main/res/values-pt-rBR/strings.xml trans.pt_BR = app/src/main/res/values-pt-rBR/strings.xml
trans.ro_RO = ui/i18n/src/main/res/values-ro/strings.xml trans.ro_RO = app/src/main/res/values-ro/strings.xml
trans.ru_RU = ui/i18n/src/main/res/values-ru/strings.xml trans.ru_RU = app/src/main/res/values-ru/strings.xml
trans.sk = ui/i18n/src/main/res/values-sk/strings.xml trans.sk = app/src/main/res/values-sk/strings.xml
trans.sl_SI = ui/i18n/src/main/res/values-sl/strings.xml trans.sl_SI = app/src/main/res/values-sl/strings.xml
trans.sv_SE = ui/i18n/src/main/res/values-sv/strings.xml trans.sv_SE = app/src/main/res/values-sv/strings.xml
trans.tr = ui/i18n/src/main/res/values-tr/strings.xml trans.tr = app/src/main/res/values-tr/strings.xml
trans.uk_UA = ui/i18n/src/main/res/values-uk/strings.xml trans.uk_UA = app/src/main/res/values-uk/strings.xml
trans.zh_CN = ui/i18n/src/main/res/values-zh-rCN/strings.xml trans.zh_CN = app/src/main/res/values-zh-rCN/strings.xml
trans.zh_HK = ui/i18n/src/main/res/values-zh-rHK/strings.xml trans.zh_HK = app/src/main/res/values-zh-rHK/strings.xml
trans.zh_TW = ui/i18n/src/main/res/values-zh-rTW/strings.xml trans.zh_TW = app/src/main/res/values-zh-rTW/strings.xml
[o:podcini:p:podcini:r:description] [o:podcini:p:podcini:r:description]
file_filter = app/src/main/play/listings/<lang>/full-description.txt file_filter = app/src/main/play/listings/<lang>/full-description.txt

View File

@ -24,7 +24,7 @@ How to submit a feature request
Translating Podcini 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 Submit a pull request
--------------------- ---------------------

View File

@ -125,8 +125,8 @@ android {
buildConfig true buildConfig true
} }
defaultConfig { defaultConfig {
versionCode 3020202 versionCode 3020203
versionName "6.0.2" versionName "6.0.3"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""

View File

@ -2,8 +2,8 @@ package de.test.podcini.service.playback
import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.storage.model.Playable 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.MediaPlayerBase.MediaPlayerInfo
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
class CancelableMediaPlayerCallback(private val originalCallback: MediaPlayerCallback) : MediaPlayerCallback { class CancelableMediaPlayerCallback(private val originalCallback: MediaPlayerCallback) : MediaPlayerCallback {
private var isCancelled = false private var isCancelled = false

View File

@ -2,8 +2,8 @@ package de.test.podcini.service.playback
import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.storage.model.Playable 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.MediaPlayerBase.MediaPlayerInfo
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
open class DefaultMediaPlayerCallback : MediaPlayerCallback { open class DefaultMediaPlayerCallback : MediaPlayerCallback {
override fun statusChanged(newInfo: MediaPlayerInfo?) { override fun statusChanged(newInfo: MediaPlayerInfo?) {

View File

@ -100,7 +100,7 @@ class MediaPlayerBaseTest {
} }
private fun writeTestPlayable(downloadUrl: String?, fileUrl: String?): Playable { 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, val prefs = FeedPreferences(f.id, false, FeedPreferences.AutoDeleteAction.NEVER,
VolumeAdaptionSetting.OFF, null, null) VolumeAdaptionSetting.OFF, null, null)
f.preferences = prefs f.preferences = prefs

View File

@ -62,7 +62,7 @@ class TaskManagerTest {
private fun writeTestQueue(pref: String): List<Episode>? { private fun writeTestQueue(pref: String): List<Episode>? {
val NUM_ITEMS = 10 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() f.episodes.clear()
for (i in 0 until NUM_ITEMS) { for (i in 0 until NUM_ITEMS) {
f.episodes.add(Episode(0, pref + i, pref + i, "link", Date(), Episode.PLAYED, f)) f.episodes.add(Episode(0, pref + i, pref + i, "link", Date(), Episode.PLAYED, f))

View File

@ -4,8 +4,8 @@ import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming
import ac.mdiq.podcini.preferences.UserPreferences.isFollowQueue import ac.mdiq.podcini.preferences.UserPreferences.isFollowQueue
import ac.mdiq.podcini.storage.database.Episodes import ac.mdiq.podcini.storage.algorithms.AutoDownloads
import ac.mdiq.podcini.storage.database.Episodes.downloadAlgorithm import ac.mdiq.podcini.storage.algorithms.AutoDownloads.downloadAlgorithm
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@ -47,7 +47,7 @@ class AutoDownloadTest {
@Throws(Exception::class) @Throws(Exception::class)
fun tearDown() { fun tearDown() {
// setDownloadAlgorithm(Episodes.AutomaticDownloadAlgorithm()) // setDownloadAlgorithm(Episodes.AutomaticDownloadAlgorithm())
downloadAlgorithm = Episodes.AutomaticDownloadAlgorithm() downloadAlgorithm = AutoDownloads.AutoDownloadAlgorithm()
EspressoTestUtils.tryKillPlaybackService() EspressoTestUtils.tryKillPlaybackService()
stubFeedsServer!!.tearDown() stubFeedsServer!!.tearDown()
} }
@ -103,11 +103,11 @@ class AutoDownloadTest {
// .until { item.media!!.id == currentlyPlayingFeedMediaId } // .until { item.media!!.id == currentlyPlayingFeedMediaId }
} }
private class StubDownloadAlgorithm : Episodes.AutomaticDownloadAlgorithm() { private class StubDownloadAlgorithm : AutoDownloads.AutoDownloadAlgorithm() {
var currentlyPlayingAtDownload: Long = -1 var currentlyPlayingAtDownload: Long = -1
private set private set
override fun autoDownloadEpisodeMedia(context: Context): Runnable? { override fun autoDownloadEpisodeMedia(context: Context): Runnable {
return Runnable { return Runnable {
if (currentlyPlayingAtDownload == -1L) { if (currentlyPlayingAtDownload == -1L) {
// currentlyPlayingAtDownload = currentlyPlayingFeedMediaId // currentlyPlayingAtDownload = currentlyPlayingFeedMediaId

View File

@ -12,11 +12,11 @@ import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule import androidx.test.rule.ActivityTestRule
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.ui.activity.PreferenceActivity import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APCleanupAlgorithm import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APCleanupAlgorithm
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APNullCleanupAlgorithm import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APNullCleanupAlgorithm
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.APQueueCleanupAlgorithm import ac.mdiq.podcini.storage.algorithms.AutoCleanups.APQueueCleanupAlgorithm
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.build import ac.mdiq.podcini.storage.algorithms.AutoCleanups.build
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory.ExceptFavoriteCleanupAlgorithm import ac.mdiq.podcini.storage.algorithms.AutoCleanups.ExceptFavoriteCleanupAlgorithm
import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.EnqueueLocation import ac.mdiq.podcini.preferences.UserPreferences.EnqueueLocation
import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation

View File

@ -107,7 +107,7 @@ class UITestUtils(private val context: Context) {
for (i in 0 until NUM_FEEDS) { for (i in 0 until NUM_FEEDS) {
val feed = Feed(0, null, "Title $i", "http://example.com/$i", "Description of feed $i", 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/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 // create items
val items: MutableList<Episode> = ArrayList() val items: MutableList<Episode> = 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 * Adds feeds, images and episodes to the local database. This method will also call addHostedFeedData if it has not
* been called yet. * been called yet.
*
* Adds one item of each feed to the queue and to the playback history. * 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. * 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. * @param downloadEpisodes true if episodes should also be marked as downloaded.
*/ */
@Throws(Exception::class) @Throws(Exception::class)
@ -161,13 +158,10 @@ class UITestUtils(private val context: Context) {
// might be a flaky test, this is actually not that severe // might be a flaky test, this is actually not that severe
return return
} }
if (!feedDataHosted) { if (!feedDataHosted) addHostedFeedData()
addHostedFeedData()
}
val queue: MutableList<Episode> = ArrayList() val queue: MutableList<Episode> = ArrayList()
for (feed in hostedFeeds) { for (feed in hostedFeeds) {
feed.downloaded = (true)
if (downloadEpisodes) { if (downloadEpisodes) {
for (item in feed.episodes) { for (item in feed.episodes) {
if (item.media != null) { if (item.media != null) {
@ -191,7 +185,7 @@ class UITestUtils(private val context: Context) {
// adapter.setCompleteFeed(*hostedFeeds.toTypedArray<Feed>()) // adapter.setCompleteFeed(*hostedFeeds.toTypedArray<Feed>())
// adapter.setQueue(queue) // adapter.setQueue(queue)
// adapter.close() // adapter.close()
EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(hostedFeeds)) // EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.UNKNOWN, hostedFeeds))
EventFlow.postEvent(FlowEvent.QueueEvent.setQueue(queue)) EventFlow.postEvent(FlowEvent.QueueEvent.setQueue(queue))
} }

View File

@ -2,7 +2,7 @@ package ac.mdiq.podcini.playback.cast
import android.content.Context import android.content.Context
import ac.mdiq.podcini.playback.base.MediaPlayerBase 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 * Stub implementation of CastPsmp for Free build flavour

View File

@ -39,7 +39,7 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:backupAgent=".storage.backup.OpmlBackupAgent" android:backupAgent=".preferences.OpmlBackupAgent"
android:restoreAnyVersion="true" android:restoreAnyVersion="true"
android:theme="@style/Theme.Podcini.Splash" android:theme="@style/Theme.Podcini.Splash"
android:supportsRtl="true" android:supportsRtl="true"

View File

@ -4,7 +4,7 @@ import ac.mdiq.podcini.preferences.PreferenceUpgrader
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.ui.activity.SplashActivity import ac.mdiq.podcini.ui.activity.SplashActivity
import ac.mdiq.podcini.util.SPAUtil 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.ClientConfig
import ac.mdiq.podcini.util.config.ClientConfigurator import ac.mdiq.podcini.util.config.ClientConfigurator
import ac.mdiq.podcini.util.error.CrashReportWriter import ac.mdiq.podcini.util.error.CrashReportWriter
@ -49,6 +49,12 @@ class PodciniApp : Application() {
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
} }
class ApplicationCallbacksImpl : ApplicationCallbacks {
override fun getApplicationInstance(): Application {
return PodciniApp.getInstance()
}
}
companion object { companion object {
private lateinit var singleton: PodciniApp private lateinit var singleton: PodciniApp

View File

@ -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.isAutoDownloadAllowed
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface 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 import ac.mdiq.podcini.util.Logd
@UnstableApi @UnstableApi

View File

@ -152,7 +152,7 @@ class DownloadRequest private constructor(@JvmField val destination: String?,
feed.downloadUrl != null -> prepareUrl(feed.downloadUrl!!) feed.downloadUrl != null -> prepareUrl(feed.downloadUrl!!)
else -> null else -> null
} }
this.title = feed.getHumanReadableIdentifier() this.title = feed.getTextIdentifier()
this.feedfileId = feed.id this.feedfileId = feed.id
this.feedfileType = feed.getTypeAsInt() this.feedfileType = feed.getTypeAsInt()
arguments.putInt(REQUEST_ARG_PAGE_NR, feed.pageNr) arguments.putInt(REQUEST_ARG_PAGE_NR, feed.pageNr)

View File

@ -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.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi
import ac.mdiq.podcini.net.utils.NetworkUtils.networkAvailable 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.Episodes
import ac.mdiq.podcini.storage.database.Feeds import ac.mdiq.podcini.storage.database.Feeds
import ac.mdiq.podcini.storage.database.LogsAndStats 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.storage.utils.VolumeAdaptionSetting
import ac.mdiq.podcini.ui.utils.NotificationUtils import ac.mdiq.podcini.ui.utils.NotificationUtils
import ac.mdiq.podcini.util.config.ClientConfigurator 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.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.event.FlowEvent
import android.Manifest import android.Manifest
@ -129,14 +129,11 @@ object FeedUpdateManager {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(context, params) { class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
// private val newEpisodesNotification = NewEpisodesNotification()
private val notificationManager = NotificationManagerCompat.from(context) private val notificationManager = NotificationManagerCompat.from(context)
@UnstableApi @UnstableApi
override fun doWork(): Result { override fun doWork(): Result {
ClientConfigurator.initialize(applicationContext) ClientConfigurator.initialize(applicationContext)
// newEpisodesNotification.loadCountersBeforeRefresh()
val toUpdate: MutableList<Feed> val toUpdate: MutableList<Feed>
val feedId = inputData.getLong(EXTRA_FEED_ID, -1L) val feedId = inputData.getLong(EXTRA_FEED_ID, -1L)
var allAreLocal = true var allAreLocal = true
@ -158,7 +155,6 @@ object FeedUpdateManager {
toUpdate.add(feed) // Needs to be updatable, so no singletonList toUpdate.add(feed) // Needs to be updatable, so no singletonList
force = true force = true
} }
if (!inputData.getBoolean(EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) { if (!inputData.getBoolean(EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) {
if (!networkAvailable() || !isFeedRefreshAllowed) { if (!networkAvailable() || !isFeedRefreshAllowed) {
Logd(TAG, "Blocking automatic update") Logd(TAG, "Blocking automatic update")
@ -166,12 +162,10 @@ object FeedUpdateManager {
} }
} }
refreshFeeds(toUpdate, force) refreshFeeds(toUpdate, force)
notificationManager.cancel(R.id.notification_updating_feeds) notificationManager.cancel(R.id.notification_updating_feeds)
Episodes.autodownloadEpisodeMedia(applicationContext) autodownloadEpisodeMedia(applicationContext)
return Result.success() return Result.success()
} }
private fun createNotification(toUpdate: List<Feed?>?): Notification { private fun createNotification(toUpdate: List<Feed?>?): Notification {
val context = applicationContext val context = applicationContext
var contentText = "" var contentText = ""
@ -190,11 +184,9 @@ object FeedUpdateManager {
.addAction(R.drawable.ic_cancel, context.getString(R.string.cancel_label), WorkManager.getInstance(context).createCancelPendingIntent(id)) .addAction(R.drawable.ic_cancel, context.getString(R.string.cancel_label), WorkManager.getInstance(context).createCancelPendingIntent(id))
.build() .build()
} }
override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> { override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null))) return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null)))
} }
@UnstableApi @UnstableApi
private fun refreshFeeds(toUpdate: MutableList<Feed>, force: Boolean) { private fun refreshFeeds(toUpdate: MutableList<Feed>, force: Boolean) {
if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext, 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() // Toast.makeText(applicationContext, R.string.notification_permission_text, Toast.LENGTH_LONG).show()
return return
} }
while (toUpdate.isNotEmpty()) { while (toUpdate.isNotEmpty()) {
if (isStopped) return if (isStopped) return
notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate)) notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate))
val feed = unmanagedCopy(toUpdate[0]) val feed = unmanagedCopy(toUpdate[0])
try { try {
@ -230,18 +220,15 @@ object FeedUpdateManager {
toUpdate.removeAt(0) toUpdate.removeAt(0)
} }
} }
@UnstableApi @UnstableApi
@Throws(Exception::class) @Throws(Exception::class)
fun refreshFeed(feed: Feed, force: Boolean) { fun refreshFeed(feed: Feed, force: Boolean) {
val nextPage = (inputData.getBoolean(EXTRA_NEXT_PAGE, false) && feed.nextPageLink != null) val nextPage = (inputData.getBoolean(EXTRA_NEXT_PAGE, false) && feed.nextPageLink != null)
if (nextPage) feed.pageNr += 1 if (nextPage) feed.pageNr += 1
val builder = create(feed) val builder = create(feed)
builder.setForce(force || feed.lastUpdateFailed) builder.setForce(force || feed.lastUpdateFailed)
if (nextPage) builder.source = feed.nextPageLink if (nextPage) builder.source = feed.nextPageLink
val request = builder.build() val request = builder.build()
val downloader = DefaultDownloaderFactory().create(request) ?: throw Exception("Unable to create downloader") val downloader = DefaultDownloaderFactory().create(request) ?: throw Exception("Unable to create downloader")
downloader.call() downloader.call()
if (!downloader.result.isSuccessful) { if (!downloader.result.isSuccessful) {
@ -251,24 +238,18 @@ object FeedUpdateManager {
LogsAndStats.addDownloadStatus(downloader.result) LogsAndStats.addDownloadStatus(downloader.result)
return return
} }
val feedSyncTask = FeedSyncTask(applicationContext, request) val feedSyncTask = FeedSyncTask(applicationContext, request)
val success = feedSyncTask.run() val success = feedSyncTask.run()
if (!success) { if (!success) {
Logd(TAG, "update failed: unsuccessful") Logd(TAG, "update failed: unsuccessful")
Feeds.persistFeedLastUpdateFailed(feed, true) Feeds.persistFeedLastUpdateFailed(feed, true)
LogsAndStats.addDownloadStatus(feedSyncTask.downloadStatus) LogsAndStats.addDownloadStatus(feedSyncTask.downloadStatus)
return return
} }
if (request.feedfileId == null) return // No download logs for new subscriptions if (request.feedfileId == null) return // No download logs for new subscriptions
// we create a 'successful' download log if the feed's last refresh failed // we create a 'successful' download log if the feed's last refresh failed
val log = LogsAndStats.getFeedDownloadLog(request.feedfileId) val log = LogsAndStats.getFeedDownloadLog(request.feedfileId)
if (log.isNotEmpty() && !log[0].isSuccessful) LogsAndStats.addDownloadStatus(feedSyncTask.downloadStatus) if (log.isNotEmpty() && !log[0].isSuccessful) LogsAndStats.addDownloadStatus(feedSyncTask.downloadStatus)
// newEpisodesNotification.showIfNeeded(applicationContext, feedSyncTask.savedFeed!!)
if (!request.source.isNullOrEmpty()) { if (!request.source.isNullOrEmpty()) {
when { when {
!downloader.permanentRedirectUrl.isNullOrEmpty() -> Feeds.updateFeedDownloadURL(request.source, downloader.permanentRedirectUrl!!) !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") DownloadError.ERROR_REQUEST_ERROR, Date(), "Unknown error: Status not set")
} }
override fun call(): FeedHandlerResult? { override fun call(): FeedHandlerResult? {
Logd(TAG, "in call()") Logd(TAG, "in FeedParserTask call()")
val feed = Feed(request.source, request.lastModified) val feed = Feed(request.source, request.lastModified)
feed.fileUrl = request.destination feed.fileUrl = request.destination
feed.id = request.feedfileId feed.id = request.feedfileId
feed.downloaded = true
if (feed.preferences == null) feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL, if (feed.preferences == null) feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL,
VolumeAdaptionSetting.OFF, request.username, request.password) VolumeAdaptionSetting.OFF, request.username, request.password)
if (request.arguments != null) feed.pageNr = request.arguments.getInt(DownloadRequest.REQUEST_ARG_PAGE_NR, 0) if (request.arguments != null) feed.pageNr = request.arguments.getInt(DownloadRequest.REQUEST_ARG_PAGE_NR, 0)
var reason: DownloadError? = null var reason: DownloadError? = null
var reasonDetailed: String? = null var reasonDetailed: String? = null
val feedHandler = FeedHandler() val feedHandler = FeedHandler()
var result: FeedHandlerResult? = null var result: FeedHandlerResult? = null
try { try {
result = feedHandler.parseFeed(feed) result = feedHandler.parseFeed(feed)
@ -344,31 +321,33 @@ object FeedUpdateManager {
} }
} }
if (isSuccessful) { if (isSuccessful) {
downloadStatus = DownloadResult(feed.id, feed.getHumanReadableIdentifier()?:"", DownloadError.SUCCESS, isSuccessful, reasonDetailed?:"") downloadStatus = DownloadResult(feed.id, feed.getTextIdentifier()?:"", DownloadError.SUCCESS, isSuccessful, reasonDetailed?:"")
return result return result
} else { } else {
downloadStatus = DownloadResult(feed.id, feed.getHumanReadableIdentifier()?:"", reason?: DownloadError.ERROR_NOT_FOUND, downloadStatus = DownloadResult(feed.id, feed.getTextIdentifier()?:"", reason?: DownloadError.ERROR_NOT_FOUND, isSuccessful, reasonDetailed?:"")
isSuccessful, reasonDetailed?:"")
return null return null
} }
} }
/** /**
* Checks if the feed was parsed correctly. * Checks if the feed was parsed correctly.
*/ */
@Throws(InvalidFeedException::class) @Throws(InvalidFeedException::class)
private fun checkFeedData(feed: Feed) { private fun checkFeedData(feed: Feed) {
if (feed.title == null) throw InvalidFeedException("Feed has no title") 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) { for (item in feed.episodes) {
if (item.title == null) throw InvalidFeedException("Item has no title: $item") 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 { companion object {
private val TAG: String = FeedParserTask::class.simpleName ?: "Anonymous" private val TAG: String = FeedParserTask::class.simpleName ?: "Anonymous"
} }
@ -379,6 +358,10 @@ object FeedUpdateManager {
private set private set
private val task = FeedParserTask(request) private val task = FeedParserTask(request)
private var feedHandlerResult: FeedHandlerResult? = null private var feedHandlerResult: FeedHandlerResult? = null
val downloadStatus: DownloadResult
get() = task.downloadStatus
val redirectUrl: String
get() = feedHandlerResult?.redirectUrl?:""
fun run(): Boolean { fun run(): Boolean {
feedHandlerResult = task.call() feedHandlerResult = task.call()
@ -386,12 +369,6 @@ object FeedUpdateManager {
savedFeed = Feeds.updateFeed(context, feedHandlerResult!!.feed, false) savedFeed = Feeds.updateFeed(context, feedHandlerResult!!.feed, false)
return true return true
} }
val downloadStatus: DownloadResult
get() = task.downloadStatus
val redirectUrl: String
get() = feedHandlerResult?.redirectUrl?:""
} }
companion object { companion object {

View File

@ -252,11 +252,11 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont
val guid = if (isValidGuid(action.guid)) action.guid else null val guid = if (isValidGuid(action.guid)) action.guid else null
val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"")
if (feedItem == null) { if (feedItem == null) {
Log.i(TAG, "Unknown feed item: $action") Logd(TAG, "Unknown feed item: $action")
return null return null
} }
if (feedItem.media == null) { if (feedItem.media == null) {
Log.i(TAG, "Feed item has no media: $action") Logd(TAG, "Feed item has no media: $action")
return null return null
} }
var idRemove: Long? = null var idRemove: Long? = null

View File

@ -157,7 +157,7 @@ import kotlin.math.min
} }
} }
} else { } else {
Log.w(TAG, "port $hostPort in use, ignored") Logd(TAG, "port $hostPort in use, ignored")
loginFail = true loginFail = true
} }
EventFlow.postEvent(FlowEvent.SyncServiceEvent(R.string.sync_status_in_progress, "5")) 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 guid = if (isValidGuid(action.guid)) action.guid else null
val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"")
if (feedItem == null) { if (feedItem == null) {
Log.i(TAG, "Unknown feed item: $action") Logd(TAG, "Unknown feed item: $action")
return null return null
} }
if (feedItem.media == null) { if (feedItem.media == null) {
Log.i(TAG, "Feed item has no media: $action") Logd(TAG, "Feed item has no media: $action")
return null return null
} }
// feedItem.media = getFeedMedia(feedItem.media!!.id) // feedItem.media = getFeedMedia(feedItem.media!!.id)

View File

@ -8,10 +8,14 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.playback.service.PlaybackService import ac.mdiq.podcini.playback.service.PlaybackService
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType 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.playback.service.PlaybackService.LocalBinder
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.utils.MediaType 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.Logd
import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.event.FlowEvent
@ -39,6 +43,71 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
private var initialized = false private var initialized = false
private var eventsRegistered = 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 @Synchronized
fun init() { fun init() {
Logd(TAG, "controller init") Logd(TAG, "controller init")
@ -46,7 +115,7 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
procFlowEvents() procFlowEvents()
eventsRegistered = true eventsRegistered = true
} }
if (PlaybackService.isRunning) initServiceRunning() if (isRunning) initServiceRunning()
else updatePlayButtonShowsPlay(true) else updatePlayButtonShowsPlay(true)
} }
@ -133,76 +202,11 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
*/ */
private fun bindToService() { private fun bindToService() {
Logd(TAG, "Trying to connect to service") 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) val bound = activity.bindService(Intent(activity, PlaybackService::class.java), mConnection, 0)
Logd(TAG, "Result for service binding: $bound") 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() {} open fun onPlaybackEnd() {}
/** /**
@ -258,7 +262,7 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
if (curMedia == null) return if (curMedia == null) return
if (playbackService == null) { if (playbackService == null) {
PlaybackServiceStarter(activity, curMedia!!).start() 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 (curMedia == null) return
if (playbackService == null) { if (playbackService == null) {
PlaybackServiceStarter(activity, curMedia!!).start() PlaybackServiceStarter(activity, curMedia!!).start()
Log.w(TAG, "playbackservice was null, restarted!") Logd(TAG, "playbackservice was null, restarted!")
return return
} }
when (MediaPlayerBase.status) { when (MediaPlayerBase.status) {
PlayerStatus.FALLBACK -> fallbackSpeed(1.0f) PlayerStatus.FALLBACK -> fallbackSpeed(1.0f)
PlayerStatus.PLAYING -> { PlayerStatus.PLAYING -> {
playbackService?.mediaPlayer?.pause(true, reinit = false) playbackService?.mPlayer?.pause(true, reinit = false)
playbackService?.isSpeedForward = false playbackService?.isSpeedForward = false
playbackService?.isFallbackSpeed = false playbackService?.isFallbackSpeed = false
// if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END)) // if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END))
} }
PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
playbackService?.mediaPlayer?.resume() playbackService?.mPlayer?.resume()
playbackService?.taskManager?.restartSleepTimer() playbackService?.taskManager?.restartSleepTimer()
// if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!)) // if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!))
} }
PlayerStatus.PREPARING -> isStartWhenPrepared = !isStartWhenPrepared PlayerStatus.PREPARING -> isStartWhenPrepared = !isStartWhenPrepared
PlayerStatus.INITIALIZED -> { PlayerStatus.INITIALIZED -> {
if (playbackService != null) isStartWhenPrepared = true if (playbackService != null) isStartWhenPrepared = true
playbackService?.mediaPlayer?.prepare() playbackService?.mPlayer?.prepare()
playbackService?.taskManager?.restartSleepTimer() playbackService?.taskManager?.restartSleepTimer()
} }
else -> { else -> {
@ -300,35 +304,35 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
var playbackService: PlaybackService? = null var playbackService: PlaybackService? = null
val position: Int val curPosition: Int
get() = playbackService?.currentPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME get() = playbackService?.curPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME
val duration: Int val duration: Int
get() = playbackService?.duration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME get() = playbackService?.curDuration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME
val curSpeedMultiplier: Float val curSpeedMultiplier: Float
get() = playbackService?.currentPlaybackSpeed ?: getCurrentPlaybackSpeed(curMedia) get() = playbackService?.curSpeed ?: getCurrentPlaybackSpeed(curMedia)
val isPlayingVideoLocally: Boolean val isPlayingVideoLocally: Boolean
get() = when { get() = when {
PlaybackService.isCasting -> false isCasting -> false
playbackService != null -> currentMediaType == MediaType.VIDEO playbackService != null -> currentMediaType == MediaType.VIDEO
else -> curMedia?.getMediaType() == MediaType.VIDEO else -> curMedia?.getMediaType() == MediaType.VIDEO
} }
private var isStartWhenPrepared: Boolean private var isStartWhenPrepared: Boolean
get() = playbackService?.mediaPlayer?.startWhenPrepared?.get() ?: false get() = playbackService?.mPlayer?.startWhenPrepared?.get() ?: false
set(s) { set(s) {
playbackService?.mediaPlayer?.startWhenPrepared?.set(s) playbackService?.mPlayer?.startWhenPrepared?.set(s)
} }
private val mPlayerInfo: MediaPlayerInfo? private val mPlayerInfo: MediaPlayerInfo?
get() = playbackService?.mediaPlayer?.playerInfo get() = playbackService?.mPlayer?.playerInfo
fun seekTo(time: Int) { fun seekTo(time: Int) {
if (playbackService != null) { if (playbackService != null) {
playbackService!!.mediaPlayer?.seekTo(time) playbackService!!.mPlayer?.seekTo(time)
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(time, duration)) // if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, time, duration))
} }
} }
@ -337,24 +341,24 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
when (MediaPlayerBase.status) { when (MediaPlayerBase.status) {
PlayerStatus.PLAYING -> { PlayerStatus.PLAYING -> {
MediaPlayerBase.status = PlayerStatus.FALLBACK MediaPlayerBase.status = PlayerStatus.FALLBACK
fallbackSpeed_(speed) setToFallback(speed)
} }
PlayerStatus.FALLBACK -> { PlayerStatus.FALLBACK -> {
MediaPlayerBase.status = PlayerStatus.PLAYING MediaPlayerBase.status = PlayerStatus.PLAYING
fallbackSpeed_(speed) setToFallback(speed)
} }
else -> {} else -> {}
} }
} }
} }
private fun fallbackSpeed_(speed: Float) { private fun setToFallback(speed: Float) {
if (playbackService?.mediaPlayer == null || playbackService!!.isSpeedForward) return if (playbackService?.mPlayer == null || playbackService!!.isSpeedForward) return
if (!playbackService!!.isFallbackSpeed) { if (!playbackService!!.isFallbackSpeed) {
playbackService!!.normalSpeed = playbackService!!.mediaPlayer!!.getPlaybackSpeed() playbackService!!.normalSpeed = playbackService!!.mPlayer!!.getPlaybackSpeed()
playbackService!!.mediaPlayer!!.setPlaybackParams(speed, isSkipSilence) playbackService!!.mPlayer!!.setPlaybackParams(speed, isSkipSilence)
} else playbackService!!.mediaPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) } else playbackService!!.mPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence)
playbackService!!.isFallbackSpeed = !playbackService!!.isFallbackSpeed playbackService!!.isFallbackSpeed = !playbackService!!.isFallbackSpeed
} }
@ -362,5 +366,28 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
fun sleepTimerActive(): Boolean { fun sleepTimerActive(): Boolean {
return playbackService?.taskManager?.isSleepTimerActive ?: false 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()
}
} }
} }

View File

@ -27,10 +27,6 @@ object InTheatre {
} }
var curMedia: Playable? = null var curMedia: Playable? = null
// get() {
// if (field == null) field = loadPlayableFromPreferences()
// return field
// }
set(value) { set(value) {
field = value field = value
if (field is EpisodeMedia) { if (field is EpisodeMedia) {

View File

@ -49,18 +49,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
status = PlayerStatus.STOPPED 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?) { protected open fun setPlayable(playable: Playable?) {
if (playable != null && playable !== curMedia) { if (playable != null && playable !== curMedia) {
curMedia = playable curMedia = playable
@ -154,12 +142,15 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
/** /**
* Seek a specific position from the current position * 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) { fun seekDelta(delta: Int) {
val currentPosition = getPosition() val curPosition = getPosition()
if (currentPosition != Playable.INVALID_TIME) seekTo(currentPosition + d) if (curPosition != Playable.INVALID_TIME) {
else Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta") 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 @Synchronized
protected fun setPlayerStatus(newStatus: PlayerStatus, newMedia: Playable?, position: Int = Playable.INVALID_TIME) { protected fun setPlayerStatus(newStatus: PlayerStatus, newMedia: Playable?, position: Int = Playable.INVALID_TIME) {
Logd(TAG, this.javaClass.simpleName + ": Setting player status to " + newStatus) Logd(TAG, this.javaClass.simpleName + ": Setting player status to " + newStatus)
this.oldStatus = status this.oldStatus = status
status = newStatus status = newStatus
if (newMedia != null) setPlayable(newMedia) if (newMedia != null) setPlayable(newMedia)
if (newMedia != null && newStatus != PlayerStatus.INDETERMINATE) { if (newMedia != null && newStatus != PlayerStatus.INDETERMINATE) {
when { when {
oldStatus == PlayerStatus.PLAYING && newStatus != PlayerStatus.PLAYING -> callback.onPlaybackPause(newMedia, position) oldStatus == PlayerStatus.PLAYING && newStatus != PlayerStatus.PLAYING -> callback.onPlaybackPause(newMedia, position)
oldStatus != PlayerStatus.PLAYING && newStatus == PlayerStatus.PLAYING -> callback.onPlaybackStart(newMedia, position) oldStatus != PlayerStatus.PLAYING && newStatus == PlayerStatus.PLAYING -> callback.onPlaybackStart(newMedia, position)
} }
} }
callback.statusChanged(MediaPlayerInfo(oldStatus, status, curMedia)) 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) 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?) class MediaPlayerInfo(@JvmField val oldPlayerStatus: PlayerStatus?, @JvmField var playerStatus: PlayerStatus, @JvmField var playable: Playable?)
companion object { companion object {
@ -354,7 +319,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
val newPosition = currentPosition - rewindTime.toInt() val newPosition = currentPosition - rewindTime.toInt()
return max(newPosition.toDouble(), 0.0).toInt() return max(newPosition.toDouble(), 0.0).toInt()
} else return currentPosition } else return currentPosition
} }
/** /**
@ -363,19 +327,11 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@JvmStatic @JvmStatic
fun getCurrentPlaybackSpeed(media: Playable?): Float { fun getCurrentPlaybackSpeed(media: Playable?): Float {
var playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL var playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL
var mediaType: MediaType? = null val mediaType: MediaType? = media?.getMediaType()
if (media != null) { if (media != null) {
mediaType = media.getMediaType()
playbackSpeed = curState.curTempSpeed playbackSpeed = curState.curTempSpeed
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) { if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) {
val item = media.episode if (media.episode?.feed?.preferences != null) playbackSpeed = media.episode!!.feed!!.preferences!!.playSpeed
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 (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = UserPreferences.getPlaybackSpeed(mediaType) if (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = UserPreferences.getPlaybackSpeed(mediaType)

View File

@ -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)
}

View File

@ -1,17 +1,17 @@
package ac.mdiq.podcini.playback.base package ac.mdiq.podcini.playback.base
enum class PlayerStatus(private val statusValue: Int) { 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), ERROR(-1),
PREPARING(19), INDETERMINATE(0), // player is currently changing its state, listeners should wait until the state is left
PAUSED(30),
FALLBACK(35),
PLAYING(40),
STOPPED(5), 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), PREPARED(20),
SEEKING(29), SEEKING(29),
INITIALIZING(9), // playback service is loading the Playable's metadata PAUSED(30),
INITIALIZED(10); // playback service was started, data source of media player was set FALLBACK(35),
PLAYING(40);
fun isAtLeast(other: PlayerStatus?): Boolean { fun isAtLeast(other: PlayerStatus?): Boolean {
return other == null || this.statusValue >= other.statusValue return other == null || this.statusValue >= other.statusValue

View File

@ -1,6 +1,5 @@
package ac.mdiq.podcini.playback.service package ac.mdiq.podcini.playback.service
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
import ac.mdiq.podcini.net.download.service.PodciniHttpClient 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.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.MediaPlayerBase 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.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
@ -58,7 +58,6 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
/** /**
* Manages the MediaPlayer object of the PlaybackService. * Manages the MediaPlayer object of the PlaybackService.
*/ */
@ -82,13 +81,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
private val formats: List<Format> private val formats: List<Format>
get() { get() {
val formats: MutableList<Format> = arrayListOf() val formats_: MutableList<Format> = arrayListOf()
val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList() val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList()
val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) val trackGroups = trackInfo.getTrackGroups(audioRendererIndex)
for (i in 0 until trackGroups.length) { 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 private val audioRendererIndex: Int
@ -139,7 +138,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
private fun prepareWR() { private fun prepareWR() {
Logd(TAG, "prepareWR() called") Logd(TAG, "prepareWR() called")
if (mediaSource == null && mediaItem == null) return if (mediaSource == null && mediaItem == null) return
if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false) if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false)
else exoPlayer?.setMediaItem(mediaItem!!) else exoPlayer?.setMediaItem(mediaItem!!)
exoPlayer?.prepare() exoPlayer?.prepare()
@ -164,7 +162,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
exoPlayer?.setAudioAttributes(b.build(), true) exoPlayer?.setAudioAttributes(b.build(), true)
} }
private fun metadata(p: Playable): MediaMetadata { private fun buildMetadata(p: Playable): MediaMetadata {
val builder = MediaMetadata.Builder() val builder = MediaMetadata.Builder()
.setArtist(p.getFeedTitle()) .setArtist(p.getFeedTitle())
.setTitle(p.getEpisodeTitle()) .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 * 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. * not do anything.
* Whether playback starts immediately depends on the given parameters. See below for more details. * Whether playback starts immediately depends on the given parameters. See below for more details.
*
* States: * States:
* During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. * 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 * 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. * '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 * 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. * will enter the ERROR state.
*
* This method is executed on an internal executor service. * 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 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 * @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 * 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. * @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) { 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 (curMedia != null) {
if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { 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.") Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
return 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 curMedia = playable
this.isStreaming = stream this.isStreaming = stream
mediaType = curMedia!!.getMediaType() mediaType = curMedia!!.getMediaType()
@ -263,7 +252,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
createMediaPlayer() createMediaPlayer()
this.startWhenPrepared.set(startWhenPrepared) this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
val metadata = metadata(curMedia!!) val metadata = buildMetadata(curMedia!!)
try { try {
callback.ensureMediaInfoLoaded(curMedia!!) callback.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(false) callback.onMediaChanged(false)
@ -313,7 +302,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime()) val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
seekTo(newPosition) seekTo(newPosition)
} }
// play()
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
exoPlayer?.play() exoPlayer?.play()
// Can't set params when paused - so always set it on start in case they changed // 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()) setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition())
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END)) if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END))
if (isStreaming && reinit) reinit() if (isStreaming && reinit) reinit()
} else { } else Logd(TAG, "Ignoring call to pause: Player is in $status state")
Logd(TAG, "Ignoring call to pause: Player is in $status state")
}
} }
override fun prepare() { override fun prepare() {
@ -346,7 +332,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
if (curMedia != null) { if (curMedia != null) {
val pos = curMedia!!.getPosition() val pos = curMedia!!.getPosition()
if (pos > 0) seekTo(pos) if (pos > 0) seekTo(pos)
if (curMedia!!.getDuration() <= 0) { if (curMedia != null && curMedia!!.getDuration() <= 0) {
Logd(TAG, "Setting duration of media") Logd(TAG, "Setting duration of media")
curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt()) 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) { override fun seekTo(t: Int) {
var t = t0 var t = t
if (t < 0) t = 0 if (t < 0) t = 0
Logd(TAG, "seekTo() called") Logd(TAG, "seekTo() called $t")
if (t >= getDuration()) { if (t >= getDuration()) {
Logd(TAG, "Seek reached end of file, skipping to next episode") 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() audioSeekCompleteListener?.run()
endPlayback(true, wasSkipped = true, true, toStoppedState = true) endPlayback(true, wasSkipped = true, true, toStoppedState = true)
t = getPosition()
// return // return
} }
when (status) { when (status) {
PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
Logd(TAG, "seekTo() called $t")
if (seekLatch != null && seekLatch!!.count > 0) { if (seekLatch != null && seekLatch!!.count > 0) {
try { try {
seekLatch!!.await(3, TimeUnit.SECONDS) seekLatch!!.await(3, TimeUnit.SECONDS)
@ -391,8 +380,9 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
} }
seekLatch = CountDownLatch(1) seekLatch = CountDownLatch(1)
statusBeforeSeeking = status statusBeforeSeeking = status
setPlayerStatus(PlayerStatus.SEEKING, curMedia, getPosition()) setPlayerStatus(PlayerStatus.SEEKING, curMedia, t)
exoPlayer?.seekTo(t.toLong()) exoPlayer?.seekTo(t.toLong())
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
audioSeekCompleteListener?.run() audioSeekCompleteListener?.run()
if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t) if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t)
try { try {
@ -411,25 +401,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
} }
override fun getDuration(): Int { override fun getDuration(): Int {
var retVal = Playable.INVALID_TIME return curMedia?.getDuration() ?: 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
} }
override fun getPosition(): Int { override fun getPosition(): Int {
var retVal = Playable.INVALID_TIME var retVal = Playable.INVALID_TIME
// Log.d(TAG, "getPosition() ${playable?.getIdentifier()} $status")
if (status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt() if (status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt()
if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition()
if (retVal <= 0) {
val playablePos = curMedia?.getPosition() ?: -1
if (playablePos >= 0) retVal = playablePos
}
return retVal return retVal
} }
@ -461,16 +439,14 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
volumeRight *= adaptionFactor volumeRight *= adaptionFactor
} }
} }
if (volumeLeft > 1) { if (volumeLeft > 1) {
exoPlayer!!.volume = 1f exoPlayer?.volume = 1f
loudnessEnhancer?.setEnabled(true) loudnessEnhancer?.setEnabled(true)
loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt()) loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt())
} else { } else {
exoPlayer!!.volume = volumeLeft exoPlayer?.volume = volumeLeft
loudnessEnhancer?.setEnabled(false) loudnessEnhancer?.setEnabled(false)
} }
Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight") Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight")
} }
@ -543,6 +519,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
} }
override fun createMediaPlayer() { override fun createMediaPlayer() {
Logd(TAG, "createMediaPlayer()")
release() release()
if (curMedia == null) { if (curMedia == null) {
status = PlayerStatus.STOPPED 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 // we're relying on the position stored in the Playable object for post-playback processing
val position = getPosition() val position = getPosition()
if (position >= 0) curMedia?.setPosition(position) 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 val currentMedia = curMedia
var nextMedia: Playable? = null var nextMedia: Playable? = null
if (shouldContinue) { if (shouldContinue) {
// Load next episode if previous episode was in the queue and if there // Load next episode if previous episode was in the queue and if there is an episode in the queue left.
// is an episode in the queue left.
// Start playback immediately if continuous playback is enabled // Start playback immediately if continuous playback is enabled
nextMedia = callback.getNextInQueue(currentMedia) nextMedia = callback.getNextInQueue(currentMedia)
if (nextMedia != null) { if (nextMedia != null) {
Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false") Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false")
// curMedia = null if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null)
callback.onPlaybackEnded(nextMedia.getMediaType(), false) callback.onPlaybackEnded(nextMedia.getMediaType(), false)
// setting media to null signals to playMediaObject() that // setting media to null signals to playMediaObject that we're taking care of post-playback processing
// we're taking care of post-playback processing
curMedia = null curMedia = null
playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying) playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying)
} }
@ -585,7 +560,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
callback.onPlaybackEnded(null, true) callback.onPlaybackEnded(null, true)
curMedia = null curMedia = null
exoPlayer?.stop() exoPlayer?.stop()
// stop()
releaseWifiLockIfNecessary() releaseWifiLockIfNecessary()
if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null) if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null)
else Logd(TAG, "Ignored call to stop: Current player state is: $status") 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_STARTED: Int = -1
const val BUFFERING_ENDED: Int = -2 const val BUFFERING_ENDED: Int = -2
const val ERROR_CODE_OFFSET: Int = 1000
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null private var httpDataSourceFactory: OkHttpDataSource.Factory? = null

View File

@ -42,11 +42,10 @@ class QuickSettingsTileService : TileService() {
return super.onBind(intent) return super.onBind(intent)
} }
fun updateTile() { private fun updateTile() {
val qsTile = qsTile val qsTile = qsTile
if (qsTile == null) Logd(TAG, "Ignored call to update QS tile: getQsTile() returned null.") if (qsTile == null) Logd(TAG, "Ignored call to update QS tile: getQsTile() returned null.")
else { else {
// val isPlaying = PlaybackService.isRunning && MediaPlayerBase.status == PlayerStatus.PLAYING
val isPlaying = (PlaybackService.isRunning && curState.curPlayerStatus == PLAYER_STATUS_PLAYING) val isPlaying = (PlaybackService.isRunning && curState.curPlayerStatus == PLAYER_STATUS_PLAYING)
qsTile.state = if (isPlaying) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE qsTile.state = if (isPlaying) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
qsTile.updateTile() qsTile.updateTile()

View File

@ -351,7 +351,7 @@ class TaskManager(private val context: Context, private val callback: PSTMCallba
/** /**
* Notification interval of widget updater in milliseconds. * 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 SCHED_EX_POOL_SIZE = 2
private const val UPDATE_INTERVAL = 1000L private const val UPDATE_INTERVAL = 1000L
const val NOTIFICATION_THRESHOLD: Long = 10000 const val NOTIFICATION_THRESHOLD: Long = 10000

View File

@ -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<Feed?>?, writer: Writer?, context: Context)
fun fileExtension(): String?
}

View File

@ -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.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.preferences.UserPreferences.isAutoBackupOPML import ac.mdiq.podcini.preferences.UserPreferences.isAutoBackupOPML
import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.transport.OpmlReader import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlReader
import ac.mdiq.podcini.storage.transport.OpmlWriter import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import android.app.backup.BackupAgentHelper import android.app.backup.BackupAgentHelper
import android.app.backup.BackupDataInputStream import android.app.backup.BackupDataInputStream
@ -45,7 +45,7 @@ class OpmlBackupAgent : BackupAgentHelper() {
*/ */
private var mChecksum: ByteArray = byteArrayOf() 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") Logd(TAG, "Performing backup")
val byteStream = ByteArrayOutputStream() val byteStream = ByteArrayOutputStream()
var digester: MessageDigest? = null var digester: MessageDigest? = null
@ -66,17 +66,14 @@ class OpmlBackupAgent : BackupAgentHelper() {
if (digester != null) { if (digester != null) {
val newChecksum = digester.digest() val newChecksum = digester.digest()
Logd(TAG, "New checksum: " + BigInteger(1, newChecksum).toString(16)) Logd(TAG, "New checksum: " + BigInteger(1, newChecksum).toString(16))
// Get the old checksum // Get the old checksum
if (oldState != null) { if (oldState != null) {
val inState = FileInputStream(oldState.fileDescriptor) val inState = FileInputStream(oldState.fileDescriptor)
val len = inState.read() val len = inState.read()
if (len != -1) { if (len != -1) {
val oldChecksum = ByteArray(len) val oldChecksum = ByteArray(len)
IOUtils.read(inState, oldChecksum, 0, len) IOUtils.read(inState, oldChecksum, 0, len)
Logd(TAG, "Old checksum: " + BigInteger(1, oldChecksum).toString(16)) Logd(TAG, "Old checksum: " + BigInteger(1, oldChecksum).toString(16))
if (oldChecksum.contentEquals(newChecksum)) { if (oldChecksum.contentEquals(newChecksum)) {
Logd(TAG, "Checksums are the same; won't backup") Logd(TAG, "Checksums are the same; won't backup")
return return
@ -99,22 +96,18 @@ class OpmlBackupAgent : BackupAgentHelper() {
@OptIn(UnstableApi::class) override fun restoreEntity(data: BackupDataInputStream) { @OptIn(UnstableApi::class) override fun restoreEntity(data: BackupDataInputStream) {
Logd(TAG, "Backup restore") Logd(TAG, "Backup restore")
if (OPML_ENTITY_KEY != data.key) { if (OPML_ENTITY_KEY != data.key) {
Logd(TAG, "Unknown entity key: " + data.key) Logd(TAG, "Unknown entity key: " + data.key)
return return
} }
var digester: MessageDigest? = null var digester: MessageDigest? = null
var reader: Reader var reader: Reader
try { try {
digester = MessageDigest.getInstance("MD5") digester = MessageDigest.getInstance("MD5")
reader = InputStreamReader(DigestInputStream(data, digester), Charset.forName("UTF-8")) reader = InputStreamReader(DigestInputStream(data, digester), Charset.forName("UTF-8"))
} catch (e: NoSuchAlgorithmException) { } catch (e: NoSuchAlgorithmException) {
reader = InputStreamReader(data, Charset.forName("UTF-8")) reader = InputStreamReader(data, Charset.forName("UTF-8"))
} }
try { try {
val opmlElements = OpmlReader().readDocument(reader) val opmlElements = OpmlReader().readDocument(reader)
mChecksum = digester?.digest()?: byteArrayOf() mChecksum = digester?.digest()?: byteArrayOf()
@ -139,7 +132,6 @@ class OpmlBackupAgent : BackupAgentHelper() {
/** /**
* Writes the new state description, which is the checksum of the OPML file. * Writes the new state description, which is the checksum of the OPML file.
*
* @param newState * @param newState
* @param checksum * @param checksum
*/ */

View File

@ -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<Feed?>?, 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<OpmlElement>? = 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<OpmlElement> {
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"
}
}
}

View File

@ -37,6 +37,7 @@ object UserPreferences {
const val PREF_TINTED_COLORS: String = "prefTintedColors" const val PREF_TINTED_COLORS: String = "prefTintedColors"
const val PREF_HIDDEN_DRAWER_ITEMS: String = "prefHiddenDrawerItems" const val PREF_HIDDEN_DRAWER_ITEMS: String = "prefHiddenDrawerItems"
const val PREF_DRAWER_FEED_ORDER: String = "prefDrawerFeedOrder" 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_DRAWER_FEED_COUNTER: String = "prefDrawerFeedIndicator"
const val PREF_EXPANDED_NOTIFICATION: String = "prefExpandNotify" const val PREF_EXPANDED_NOTIFICATION: String = "prefExpandNotify"
private const val PREF_USE_EPISODE_COVER: String = "prefEpisodeCover" private const val PREF_USE_EPISODE_COVER: String = "prefEpisodeCover"
@ -239,6 +240,9 @@ object UserPreferences {
.apply() .apply()
} }
val useGridLayout: Boolean
get() = appPrefs.getBoolean(PREF_FEED_GRID_LAYOUT, false)
/** /**
* @return `true` if episodes should use their own cover, `false` otherwise * @return `true` if episodes should use their own cover, `false` otherwise
*/ */

View File

@ -1,20 +1,28 @@
package ac.mdiq.podcini.preferences.fragments package ac.mdiq.podcini.preferences.fragments
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.PodciniApp.Companion.forceRestart import ac.mdiq.podcini.PodciniApp.Companion.forceRestart
import ac.mdiq.podcini.R 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.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.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.transport.DatabaseTransporter import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.transport.PreferencesTransporter import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.transport.ExportWriter import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.transport.EpisodeProgressReader import ac.mdiq.podcini.preferences.OpmlTransporter.*
import ac.mdiq.podcini.storage.transport.EpisodesProgressWriter import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.transport.FavoritesWriter import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded
import ac.mdiq.podcini.storage.transport.HtmlWriter import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.storage.transport.OpmlWriter
import ac.mdiq.podcini.ui.activity.OpmlImportActivity import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import ac.mdiq.podcini.ui.activity.PreferenceActivity import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.util.Logd
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
@ -23,16 +31,20 @@ import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.text.format.Formatter
import android.util.Log import android.util.Log
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.OptIn
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.app.ShareCompat.IntentBuilder import androidx.core.app.ShareCompat.IntentBuilder
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.io.*
import java.nio.channels.FileChannel
import java.nio.charset.Charset import java.nio.charset.Charset
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -87,10 +104,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.import_export_pref) (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.import_export_pref)
} }
// override fun onStop() {
// super.onStop()
// }
private fun dateStampFilename(fname: String): String { private fun dateStampFilename(fname: String): String {
return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()))
} }
@ -136,7 +149,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
exportPreferences() exportPreferences()
true true
} }
findPreference<Preference>(PREF_FAVORITE_EXPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { findPreference<Preference>(PREF_FAVORITE_EXPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
openExportPathPicker(Export.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter()) openExportPathPicker(Export.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter())
true true
@ -166,7 +178,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
try { try {
val output = worker.exportFile() val output = worker.exportFile()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
showExportSuccessSnackbar(output?.uri, exportType.contentType) showExportSuccessSnackbar(output.uri, exportType.contentType)
} }
} catch (e: Exception) { } catch (e: Exception) {
showExportErrorDialog(e) showExportErrorDialog(e)
@ -412,7 +424,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
} }
class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) { class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) {
suspend fun exportFile(): DocumentFile { suspend fun exportFile(): DocumentFile {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val output = DocumentFile.fromSingleUri(context, outputFileUri) val output = DocumentFile.fromSingleUri(context, outputFileUri)
@ -429,20 +440,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
} catch (e: IOException) { } catch (e: IOException) {
throw e throw e
} finally { } finally {
if (writer != null) { if (writer != null) try { writer.close() } catch (e: IOException) { throw e }
try { if (outputStream != null) try { outputStream.close() } catch (e: IOException) { throw e }
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. * 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) { 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), constructor(exportWriter: ExportWriter, context: Context) : this(exportWriter, File(getDataFolder(EXPORT_DIR),
DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context) DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context)
suspend fun exportFile(): File? { suspend fun exportFile(): File? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
if (output.exists()) { if (output.exists()) {
val success = output.delete() val success = output.delete()
Log.w(TAG, "Overwriting previously exported file: $success") Logd(TAG, "Overwriting previously exported file: $success")
} }
var writer: OutputStreamWriter? = null var writer: OutputStreamWriter? = null
@ -476,7 +473,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
} }
} }
} }
companion object { companion object {
private const val EXPORT_DIR = "export/" private const val EXPORT_DIR = "export/"
private val TAG: String = ExportWorker::class.simpleName ?: "Anonymous" 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<EpisodeAction>()
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<Episode> = 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<Long, Episode>? {
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<Feed?>?, writer: Writer?, context: Context) {
Logd(TAG, "Starting to write document")
val queuedEpisodeActions: MutableList<EpisodeAction> = 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<Episode>()
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<Feed?>?, 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<Episode> = favoritesByFeed[feedId]!!
writer.append("<li><div>\n")
writeFeed(writer, favorites[0].feed, feedTemplate)
writer.append("<ul>\n")
for (item in favorites) writeFavoriteItem(writer, item, favTemplate)
writer.append("</ul></div></li>\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<Episode>): Map<Long, MutableList<Episode>> {
val feedMap: MutableMap<Long, MutableList<Episode>> = 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<Feed?>?, 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("<li><div><img src=\"")
writer.append(feed!!.imageUrl)
writer.append("\" /><p>")
writer.append(feed.title)
writer.append(" <span><a href=\"")
writer.append(feed.link)
writer.append("\">Website</a> • <a href=\"")
writer.append(feed.downloadUrl)
writer.append("\">Feed</a></span></p></div></li>\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 { companion object {
private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous" private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous"
private const val PREF_OPML_EXPORT = "prefOpmlExport" private const val PREF_OPML_EXPORT = "prefOpmlExport"

View File

@ -1,30 +1,30 @@
package ac.mdiq.podcini.receiver 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.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.util.Log import android.content.SharedPreferences
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager 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 import java.util.concurrent.TimeUnit
class PlayerWidget : AppWidgetProvider() { class PlayerWidget : AppWidgetProvider() {
override fun onEnabled(context: Context) { override fun onEnabled(context: Context) {
super.onEnabled(context) super.onEnabled(context)
getSharedPrefs(context)
Logd(TAG, "Widget enabled") Logd(TAG, "Widget enabled")
setEnabled(context, true) setEnabled(true)
WidgetUpdaterWorker.enqueueWork(context) WidgetUpdaterWorker.enqueueWork(context)
scheduleWorkaround(context) scheduleWorkaround(context)
} }
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Logd(TAG, "onUpdate() called with: context = [$context], appWidgetManager = [$appWidgetManager], appWidgetIds = [${appWidgetIds.contentToString()}]") Logd(TAG, "onUpdate() called with: context = [$context], appWidgetManager = [$appWidgetManager], appWidgetIds = [${appWidgetIds.contentToString()}]")
getSharedPrefs(context)
WidgetUpdaterWorker.enqueueWork(context) WidgetUpdaterWorker.enqueueWork(context)
if (!prefs!!.getBoolean(KEY_WORKAROUND_ENABLED, false)) { if (!prefs!!.getBoolean(KEY_WORKAROUND_ENABLED, false)) {
@ -36,7 +36,7 @@ class PlayerWidget : AppWidgetProvider() {
override fun onDisabled(context: Context) { override fun onDisabled(context: Context) {
super.onDisabled(context) super.onDisabled(context)
Logd(TAG, "Widget disabled") Logd(TAG, "Widget disabled")
setEnabled(context, false) setEnabled(false)
} }
override fun onDeleted(context: Context, appWidgetIds: IntArray) { override fun onDeleted(context: Context, appWidgetIds: IntArray) {
@ -57,7 +57,7 @@ class PlayerWidget : AppWidgetProvider() {
super.onDeleted(context, appWidgetIds) super.onDeleted(context, appWidgetIds)
} }
private fun setEnabled(context: Context, enabled: Boolean) { private fun setEnabled(enabled: Boolean) {
prefs!!.edit().putBoolean(KEY_ENABLED, enabled).apply() prefs!!.edit().putBoolean(KEY_ENABLED, enabled).apply()
} }
@ -91,8 +91,7 @@ class PlayerWidget : AppWidgetProvider() {
} }
@JvmStatic @JvmStatic
fun isEnabled(context: Context): Boolean { fun isEnabled(): Boolean {
// val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs!!.getBoolean(KEY_ENABLED, false) return prefs!!.getBoolean(KEY_ENABLED, false)
} }
} }

View File

@ -7,7 +7,7 @@ import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.util.config.ClientConfigurator import ac.mdiq.podcini.util.config.ClientConfigurator
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery 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 import ac.mdiq.podcini.util.Logd
// modified from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html // modified from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html

View File

@ -20,7 +20,19 @@ import kotlinx.coroutines.runBlocking
import java.util.* import java.util.*
import java.util.concurrent.ExecutionException 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 @JvmStatic
fun build(): EpisodeCleanupAlgorithm { fun build(): EpisodeCleanupAlgorithm {
if (!isEnableAutodownload) return APNullCleanupAlgorithm() 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. * A cleanup algorithm that removes any item that isn't a favorite but only if space is needed.
*/ */
class ExceptFavoriteCleanupAlgorithm : EpisodeCleanupAlgorithm() { class ExceptFavoriteCleanupAlgorithm : EpisodeCleanupAlgorithm() {
private val candidates: List<Episode>
get() {
val candidates: MutableList<Episode> = 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. * The maximum number of episodes that could be cleaned up.
*
* @return the number of episodes that *could* be cleaned up, if needed * @return the number of episodes that *could* be cleaned up, if needed
*/ */
override fun getReclaimableItems(): Int { override fun getReclaimableItems(): Int {
return candidates.size return candidates.size
} }
@OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numToRemove: Int): Int {
@OptIn(UnstableApi::class) public override fun performCleanup(context: Context, numberOfEpisodesToDelete: Int): Int {
var candidates = candidates var candidates = candidates
// in the absence of better data, we'll sort by item publication date // in the absence of better data, we'll sort by item publication date
candidates = candidates.sortedWith { lhs: Episode, rhs: Episode -> candidates = candidates.sortedWith { lhs: Episode, rhs: Episode ->
val l = lhs.getPubDate() val l = lhs.getPubDate()
@ -56,9 +74,7 @@ object EpisodeCleanupAlgorithmFactory {
if (l != null && r != null) return@sortedWith l.compareTo(r) 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 else return@sortedWith lhs.id.compareTo(rhs.id) // No date - compare by id which should be always incremented
} }
val delete = if (candidates.size > numToRemove) candidates.subList(0, numToRemove) else candidates
val delete = if (candidates.size > numberOfEpisodesToDelete) candidates.subList(0, numberOfEpisodesToDelete) else candidates
for (item in delete) { for (item in delete) {
if (item.media == null) continue if (item.media == null) continue
try { try {
@ -69,23 +85,10 @@ object EpisodeCleanupAlgorithmFactory {
e.printStackTrace() e.printStackTrace()
} }
} }
val counter = delete.size 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 return counter
} }
private val candidates: List<Episode>
get() {
val candidates: MutableList<Episode> = 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 { public override fun getDefaultCleanupParameter(): Int {
val cacheSize = episodeCacheSize val cacheSize = episodeCacheSize
if (cacheSize != UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) { if (cacheSize != UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) {
@ -94,7 +97,6 @@ object EpisodeCleanupAlgorithmFactory {
} }
return 0 return 0
} }
companion object { companion object {
private val TAG: String = ExceptFavoriteCleanupAlgorithm::class.simpleName ?: "Anonymous" private val TAG: String = ExceptFavoriteCleanupAlgorithm::class.simpleName ?: "Anonymous"
} }
@ -105,47 +107,6 @@ object EpisodeCleanupAlgorithmFactory {
* but only if space is needed. * but only if space is needed.
*/ */
class APQueueCleanupAlgorithm : EpisodeCleanupAlgorithm() { 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<Episode> private val candidates: List<Episode>
get() { get() {
val candidates: MutableList<Episode> = ArrayList() val candidates: MutableList<Episode> = ArrayList()
@ -157,11 +118,40 @@ object EpisodeCleanupAlgorithmFactory {
} }
return candidates 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 { public override fun getDefaultCleanupParameter(): Int {
return getNumEpisodesToCleanup(0) return getNumEpisodesToCleanup(0)
} }
companion object { companion object {
private val TAG: String = APQueueCleanupAlgorithm::class.simpleName ?: "Anonymous" private val TAG: String = APQueueCleanupAlgorithm::class.simpleName ?: "Anonymous"
} }
@ -171,20 +161,17 @@ object EpisodeCleanupAlgorithmFactory {
* A cleanup algorithm that never removes anything * A cleanup algorithm that never removes anything
*/ */
class APNullCleanupAlgorithm : EpisodeCleanupAlgorithm() { 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 // never clean anything up
Log.i(TAG, "performCleanup: Not removing anything") Log.i(TAG, "performCleanup: Not removing anything")
return 0 return 0
} }
public override fun getDefaultCleanupParameter(): Int { public override fun getDefaultCleanupParameter(): Int {
return 0 return 0
} }
override fun getReclaimableItems(): Int { override fun getReclaimableItems(): Int {
return 0 return 0
} }
companion object { companion object {
private val TAG: String = APNullCleanupAlgorithm::class.simpleName ?: "Anonymous" 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. */ * Fractional for number of hours, e.g., 0.5 = 12 hours, 0.0416 = 1 hour. */
class APCleanupAlgorithm(@JvmField @get:VisibleForTesting val numberOfHoursAfterPlayback: Int) : EpisodeCleanupAlgorithm() { 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<Episode> private val candidates: List<Episode>
get() { get() {
val candidates: MutableList<Episode> = ArrayList() val candidates: MutableList<Episode> = ArrayList()
@ -249,27 +193,54 @@ object EpisodeCleanupAlgorithmFactory {
for (item in downloadedItems) { for (item in downloadedItems) {
if (item.media != null && item.media!!.downloaded && !idsInQueues.contains(item.id) && item.isPlayed() && !item.isFavorite) { if (item.media != null && item.media!!.downloaded && !idsInQueues.contains(item.id) && item.isPlayed() && !item.isFavorite) {
val media = item.media val media = item.media
// make sure this candidate was played at least the proper amount of days prior // make sure this candidate was played at least the proper amount of days prior to now
// to now if (media?.playbackCompletionDate != null && media.playbackCompletionDate!!.before(mostRecentDateForDeletion)) candidates.add(item)
if (media?.playbackCompletionDate != null && media.playbackCompletionDate!!.before(mostRecentDateForDeletion))
candidates.add(item)
} }
} }
return candidates 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 { public override fun getDefaultCleanupParameter(): Int {
return getNumEpisodesToCleanup(0) return getNumEpisodesToCleanup(0)
} }
companion object { companion object {
private val TAG: String = APCleanupAlgorithm::class.simpleName ?: "Anonymous" private val TAG: String = APCleanupAlgorithm::class.simpleName ?: "Anonymous"
private fun minusHours(baseDate: Date, numberOfHours: Int): Date { private fun minusHours(baseDate: Date, numberOfHours: Int): Date {
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.time = baseDate cal.time = baseDate
cal.add(Calendar.HOUR_OF_DAY, -1 * numberOfHours) cal.add(Calendar.HOUR_OF_DAY, -1 * numberOfHours)
return cal.time return cal.time
} }
} }
@ -286,22 +257,17 @@ object EpisodeCleanupAlgorithmFactory {
* @return The number of episodes that were deleted. * @return The number of episodes that were deleted.
*/ */
protected abstract fun performCleanup(context: Context, numToRemove: Int): Int protected abstract fun performCleanup(context: Context, numToRemove: Int): Int
fun performCleanup(context: Context): Int { fun performCleanup(context: Context): Int {
return performCleanup(context, getDefaultCleanupParameter()) return performCleanup(context, getDefaultCleanupParameter())
} }
protected abstract fun getDefaultCleanupParameter(): Int
/** /**
* Returns a parameter for performCleanup. The implementation of this interface should decide how much * 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 * space to free to satisfy the episode cache conditions. If the conditions are already satisfied, this
* method should not have any effects. * method should not have any effects.
*/ */
protected abstract fun getDefaultCleanupParameter(): Int
/** /**
* Cleans up just enough episodes to make room for the requested number * Cleans up just enough episodes to make room for the requested number
*
* @param context Can be used for accessing the database * @param context Can be used for accessing the database
* @param amountOfRoomNeeded the number of episodes we need space for * @param amountOfRoomNeeded the number of episodes we need space for
* @return The number of epiosdes that were deleted * @return The number of epiosdes that were deleted
@ -309,12 +275,10 @@ object EpisodeCleanupAlgorithmFactory {
fun makeRoomForEpisodes(context: Context, amountOfRoomNeeded: Int): Int { fun makeRoomForEpisodes(context: Context, amountOfRoomNeeded: Int): Int {
return performCleanup(context, getNumEpisodesToCleanup(amountOfRoomNeeded)) return performCleanup(context, getNumEpisodesToCleanup(amountOfRoomNeeded))
} }
/** /**
* @return the number of episodes/items that *could* be cleaned up, if needed * @return the number of episodes/items that *could* be cleaned up, if needed
*/ */
abstract fun getReclaimableItems(): Int abstract fun getReclaimableItems(): Int
/** /**
* @param amountOfRoomNeeded the number of episodes we want to download * @param amountOfRoomNeeded the number of episodes we want to download
* @return the number of episodes to delete in order to make room * @return the number of episodes to delete in order to make room

View File

@ -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<Episode>
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<Episode> = 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"
}
}
}

View File

@ -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.feed.LocalFeedUpdater.updateFeed
import ac.mdiq.podcini.net.sync.model.EpisodeAction import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink 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.curQueue
import ac.mdiq.podcini.playback.base.InTheatre.curState 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.base.InTheatre.writeNoMediaPlaying
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.ACTION_SHUTDOWN_PLAYBACK_SERVICE 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.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.removeFromAllQueues
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.database.RealmDB.realm 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.storage.utils.SortOrder
import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd 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.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.event.FlowEvent
import ac.mdiq.podcini.util.sorting.EpisodesPermutors.getPermutor import ac.mdiq.podcini.util.sorting.EpisodesPermutors.getPermutor
@ -43,28 +35,12 @@ import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.io.File import java.io.File
import java.text.DateFormat
import java.util.* 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 import kotlin.math.min
object Episodes { object Episodes {
private val TAG: String = Episodes::class.simpleName ?: "Anonymous" 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 offset The first episode that should be loaded.
* @param limit The maximum number of episodes 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 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<Episode>
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<Episode> = 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 // @JvmStatic is needed because some Runnable blocks call this
@OptIn(UnstableApi::class) @JvmStatic @OptIn(UnstableApi::class) @JvmStatic
fun deleteMediaOfEpisode(context: Context, episode: Episode) : Job { fun deleteMediaOfEpisode(context: Context, episode: Episode) : Job {
@ -228,7 +109,7 @@ object Episodes {
private fun deleteMediaSync(context: Context, episode: Episode): Boolean { private fun deleteMediaSync(context: Context, episode: Episode): Boolean {
Logd(TAG, "deleteMediaSync called") Logd(TAG, "deleteMediaSync called")
val media = episode.media ?: return false 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 var localDelete = false
val url = media.fileUrl val url = media.fileUrl
when { when {
@ -312,17 +193,7 @@ object Episodes {
} }
} }
} }
if (removedFromQueue.isNotEmpty()) { if (removedFromQueue.isNotEmpty()) removeFromAllQueues(*removedFromQueue.toTypedArray())
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()
for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode)) 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 * 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 * 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. * current date regardless of the current value.
*
* @param episode Episode that should be added to the playback history. * @param episode Episode that should be added to the playback history.
* @param date PlaybackCompletionDate for `media` * @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('—', '-')
}
}
} }

View File

@ -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.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.preferences.UserPreferences 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.Episodes.deleteEpisodes
import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet 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.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.DownloadResult import ac.mdiq.podcini.storage.model.*
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.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction
import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.TAG_ROOT import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.TAG_ROOT
import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.event.FlowEvent
import ac.mdiq.podcini.util.sorting.EpisodePubdateComparator
import android.app.backup.BackupManager import android.app.backup.BackupManager
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Job import io.realm.kotlin.ext.asFlow
import kotlinx.coroutines.runBlocking import io.realm.kotlin.notifications.*
import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.text.DateFormat
import java.util.* import java.util.*
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import kotlin.math.abs
object Feeds { object Feeds {
private val TAG: String = Feeds::class.simpleName ?: "Anonymous" private val TAG: String = Feeds::class.simpleName ?: "Anonymous"
// internal val feeds: MutableList<Feed> = mutableListOf()
private val feedMap: MutableMap<Long, Feed> = mutableMapOf() private val feedMap: MutableMap<Long, Feed> = mutableMapOf()
private val tags: MutableList<String> = mutableListOf() private val tags: MutableList<String> = mutableListOf()
@ -47,11 +44,22 @@ object Feeds {
return tags return tags
} }
fun updateFeedMap() { fun updateFeedMap(feeds: List<Feed> = listOf(), wipe: Boolean = false) {
Logd(TAG, "updateFeedMap called") Logd(TAG, "updateFeedMap called feeds: ${feeds.size} wipe: $wipe")
val feeds_ = realm.query(Feed::class).find() when {
feedMap.clear() feeds.isEmpty() -> {
feedMap.putAll(feeds_.associateBy { it.id }) 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() buildTags()
} }
@ -59,21 +67,87 @@ object Feeds {
val tagsSet = mutableSetOf<String>() val tagsSet = mutableSetOf<String>()
val feedsCopy = feedMap.values val feedsCopy = feedMap.values
for (feed in feedsCopy) { for (feed in feedsCopy) {
if (feed.preferences != null) { if (feed.preferences != null) tagsSet.addAll(feed.preferences!!.tags.filter { it != TAG_ROOT })
for (tag in feed.preferences!!.tags) {
if (tag != TAG_ROOT) tagsSet.add(tag)
}
}
} }
tags.clear() tags.clear()
tags.addAll(tagsSet) tags.addAll(tagsSet)
tags.sort() 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<Feed> ->
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<Feed> ->
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<Feed> ->
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<String> { fun getFeedListDownloadUrls(): List<String> {
Logd(TAG, "getFeedListDownloadUrls called") Logd(TAG, "getFeedListDownloadUrls called")
val result: MutableList<String> = mutableListOf() val result: MutableList<String> = mutableListOf()
// val feeds = realm.query(Feed::class).find()
for (f in feedMap.values) { for (f in feedMap.values) {
val url = f.downloadUrl val url = f.downloadUrl
if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) result.add(url) if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) result.add(url)
@ -81,11 +155,7 @@ object Feeds {
return result return result
} }
// TODO: some callers don't need to copy
fun getFeed(feedId: Long, copy: Boolean = false): Feed? { 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] val f = feedMap[feedId]
return if (f != null) { return if (f != null) {
if (copy) realm.copyFromRealm(f) if (copy) realm.copyFromRealm(f)
@ -118,14 +188,13 @@ object Feeds {
@Synchronized @Synchronized
fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? { fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? {
Logd(TAG, "updateFeed called") Logd(TAG, "updateFeed called")
// TODO: check further on enclosing in realm write block
var resultFeed: Feed? var resultFeed: Feed?
val unlistedItems: MutableList<Episode> = ArrayList() val unlistedItems: MutableList<Episode> = ArrayList()
// Look up feed in the feedslist // Look up feed in the feedslist
val savedFeed = searchFeedByIdentifyingValueOrID(newFeed, true) val savedFeed = searchFeedByIdentifyingValueOrID(newFeed, true)
if (savedFeed == null) { 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}") Logd(TAG, "newFeed.episodes: ${newFeed.episodes.size}")
resultFeed = newFeed resultFeed = newFeed
} else { } else {
@ -217,7 +286,6 @@ object Feeds {
Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate") Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate")
episode.setNew() episode.setNew()
} }
// idLong += 1
} }
} }
@ -246,16 +314,16 @@ object Feeds {
// Update with default values that are set in database // Update with default values that are set in database
resultFeed = searchFeedByIdentifyingValueOrID(newFeed) resultFeed = searchFeedByIdentifyingValueOrID(newFeed)
} else persistFeedsSync(savedFeed) } else persistFeedsSync(savedFeed)
updateFeedMap() // updateFeedMap()
if (removeUnlistedItems) runBlocking { deleteEpisodes(context, unlistedItems).join() } if (removeUnlistedItems) runBlocking { deleteEpisodes(context, unlistedItems).join() }
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
e.printStackTrace() e.printStackTrace()
} catch (e: ExecutionException) { } catch (e: ExecutionException) {
e.printStackTrace() e.printStackTrace()
} }
// TODO: feedMonitor likely takes care of this
if (savedFeed != null) EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(savedFeed)) // if (savedFeed != null) EventFlow.postEvent(FlowEvent.FeedListEvent(savedFeed))
else EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(emptyList<Long>())) // else EventFlow.postEvent(FlowEvent.FeedListEvent(emptyList<Long>()))
return resultFeed return resultFeed
} }
@ -302,7 +370,7 @@ object Feeds {
return runOnIOScope { return runOnIOScope {
feed.lastUpdateFailed = lastUpdateFailed feed.lastUpdateFailed = lastUpdateFailed
upsert(feed) {} 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) copyToRealm(feed)
} }
// updateFeedMap(feeds.toList())
} }
for (feed in feeds) { for (feed in feeds) {
if (!feed.isLocalFeed && feed.downloadUrl != null) if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedAddedIfSyncActive(context, feed.downloadUrl!!)
SynchronizationQueueSink.enqueueFeedAddedIfSyncActive(context, feed.downloadUrl!!)
} }
val backupManager = BackupManager(context) val backupManager = BackupManager(context)
backupManager.dataChanged() backupManager.dataChanged()
} }
private fun persistFeedsSync(vararg feeds: Feed) { private fun persistFeedsSync(vararg feeds: Feed) {
Logd(TAG, "persistCompleteFeeds called") Logd(TAG, "persistFeedsSync called")
for (feed in feeds) { for (feed in feeds) {
upsertBlk(feed) {} upsertBlk(feed) {}
} }
} }
fun persistFeedPreferences(feed: Feed) : Job { fun persistFeedPreferences(feed: Feed) : Job {
Logd(TAG, "persistCompleteFeeds called") Logd(TAG, "persistFeedPreferences called")
return runOnIOScope { return runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) { if (feed_ != null) {
realm.write { realm.write {
findLatest(feed_)?.let { it.preferences = feed.preferences } findLatest(feed_)?.let {
it.preferences = feed.preferences
// updateFeedMap(listOf(it))
}
} }
} else upsert(feed) {} } else {
if (feed.preferences != null) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(feed.preferences!!)) 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() val episodes = feed_.episodes.toList()
if (episodes.isNotEmpty()) episodes.forEach { e -> delete(e) } if (episodes.isNotEmpty()) episodes.forEach { e -> delete(e) }
val feedToDelete = findLatest(feed_) 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 (!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 if (!UserPreferences.isAutoDelete) return false
return !feed.isLocalFeed || UserPreferences.isAutoDeleteLocal return !feed.isLocalFeed || UserPreferences.isAutoDeleteLocal
} }
/**
* Compares the pubDate of two FeedItems for sorting in reverse order
*/
class EpisodePubdateComparator : Comparator<Episode> {
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('—', '-')
}
}
} }

View File

@ -42,9 +42,7 @@ object LogsAndStats {
Logd(TAG, "getStatistics called") Logd(TAG, "getStatistics called")
val medias = realm.query(EpisodeMedia::class).find() val medias = realm.query(EpisodeMedia::class).find()
val groupdMedias = medias.groupBy { val groupdMedias = medias.groupBy { it.episode?.feedId ?: 0L }
it.episode?.feedId ?: 0L
}
val result = StatisticsResult() val result = StatisticsResult()
result.oldestDate = Long.MAX_VALUE result.oldestDate = Long.MAX_VALUE
for (fid in groupdMedias.keys) { for (fid in groupdMedias.keys) {

View File

@ -7,7 +7,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.EnqueueLocation
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.isQueueKeepSorted
import ac.mdiq.podcini.preferences.UserPreferences.queueKeepSortedOrder 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.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
@ -186,7 +186,7 @@ object Queues {
queue.episodes.addAll(qItems) queue.episodes.addAll(qItems)
} }
for (event in events) EventFlow.postEvent(event) 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? // TODO: what's this for?
if (queue.id == curQueue.id && context != null) autodownloadEpisodeMedia(context) if (queue.id == curQueue.id && context != null) autodownloadEpisodeMedia(context)

View File

@ -16,9 +16,12 @@ import kotlinx.coroutines.*
import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.ContinuationInterceptor
object RealmDB { 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 val realm: Realm
init { init {
@ -33,7 +36,7 @@ object RealmDB {
DownloadResult::class, DownloadResult::class,
Chapter::class)) Chapter::class))
.name("Podcini.realm") .name("Podcini.realm")
.schemaVersion(3) .schemaVersion(SCHEMA_VERSION_NUMBER)
.build() .build()
realm = Realm.open(config) realm = Realm.open(config)
} }

View File

@ -1,7 +1,6 @@
package ac.mdiq.podcini.storage.model package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.storage.database.Feeds.getFeed 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.realmListOf
import io.realm.kotlin.ext.realmSetOf import io.realm.kotlin.ext.realmSetOf
import io.realm.kotlin.types.RealmList 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 io.realm.kotlin.types.annotations.PrimaryKey
import org.apache.commons.lang3.builder.ToStringBuilder import org.apache.commons.lang3.builder.ToStringBuilder
import org.apache.commons.lang3.builder.ToStringStyle import org.apache.commons.lang3.builder.ToStringStyle
import java.util.* import java.util.*
/** /**
@ -54,7 +52,6 @@ class Episode : RealmObject {
@Ignore @Ignore
var feed: Feed? = null var feed: Feed? = null
get() { get() {
// Logd(TAG, "feed.get() ${field == null} ${title}")
if (field == null && feedId != null) field = getFeed(feedId!!) if (field == null && feedId != null) field = getFeed(feedId!!)
return field return field
} }
@ -138,26 +135,6 @@ class Episode : RealmObject {
// this.hasChapters = false // 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. * This constructor should be used for creating test objects.
*/ */

View File

@ -1,10 +1,10 @@
package ac.mdiq.podcini.storage.model package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.net.feed.parser.media.id3.ChapterReader
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat 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.Logd
import ac.mdiq.podcini.util.showStackTrace
import android.content.Context import android.content.Context
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
@ -116,14 +116,14 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
this.downloaded = downloaded this.downloaded = downloaded
} }
constructor(id: Long, item: Episode?, duration: Int, position: Int, // constructor(id: Long, item: Episode?, duration: Int, position: Int,
size: Long, mime_type: String?, file_url: String?, download_url: String?, // size: Long, mime_type: String?, file_url: String?, download_url: String?,
downloaded: Boolean, playbackCompletionDate: Date?, played_duration: Int, // downloaded: Boolean, playbackCompletionDate: Date?, played_duration: Int,
hasEmbeddedPicture: Boolean?, lastPlayedTime: Long) // hasEmbeddedPicture: Boolean?, lastPlayedTime: Long)
: this(id, item, duration, position, size, mime_type, file_url, download_url, downloaded, playbackCompletionDate, played_duration, lastPlayedTime) { // : this(id, item, duration, position, size, mime_type, file_url, download_url, downloaded, playbackCompletionDate, played_duration, lastPlayedTime) {
//
this.hasEmbeddedPicture = hasEmbeddedPicture // this.hasEmbeddedPicture = hasEmbeddedPicture
} // }
fun getHumanReadableIdentifier(): String? { fun getHumanReadableIdentifier(): String? {
return if (episode?.title != null) episode!!.title else downloadUrl return if (episode?.title != null) episode!!.title else downloadUrl
@ -165,16 +165,16 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
return duration return duration
} }
override fun setDuration(duration: Int) { override fun setDuration(newDuration: Int) {
this.duration = duration this.duration = newDuration
} }
override fun getPosition(): Int { override fun getPosition(): Int {
return position return position
} }
override fun setPosition(position: Int) { override fun setPosition(newPosition: Int) {
this.position = position this.position = newPosition
if (position > 0 && episode != null && episode!!.isNew) episode!!.setPlayed(false) if (newPosition > 0 && episode != null && episode!!.isNew) episode!!.setPlayed(false)
} }
override fun getLastPlayedTime(): Long { override fun getLastPlayedTime(): Long {
@ -247,8 +247,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
// } // }
override fun getEpisodeTitle(): String { override fun getEpisodeTitle(): String {
if (episode == null) return "No title" return episode?.title ?: episode?.identifyingValue ?: "No title"
return if (episode!!.title != null) episode!!.title!! else episode!!.identifyingValue?:"No title"
} }
override fun getChapters(): List<Chapter> { override fun getChapters(): List<Chapter> {
@ -264,8 +263,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
} }
override fun getFeedTitle(): String { override fun getFeedTitle(): String {
if (episode == null) return "" return episode?.feed?.title?:""
return episode!!.feed?.title?:""
} }
override fun getIdentifier(): Any { override fun getIdentifier(): Any {
@ -368,6 +366,8 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
} }
companion object { companion object {
private val TAG: String = EpisodeMedia::class.simpleName ?: "Anonymous"
const val FEEDFILETYPE_FEEDMEDIA: Int = 2 const val FEEDFILETYPE_FEEDMEDIA: Int = 2
const val PLAYABLE_TYPE_FEEDMEDIA: Int = 1 const val PLAYABLE_TYPE_FEEDMEDIA: Int = 1
const val FILENAME_PREFIX_EMBEDDED_COVER: String = "metadata-retriever:" const val FILENAME_PREFIX_EMBEDDED_COVER: String = "metadata-retriever:"

View File

@ -29,7 +29,7 @@ class Feed : RealmObject {
var fileUrl: String? = null var fileUrl: String? = null
var downloadUrl: String? = null var downloadUrl: String? = null
var downloaded: Boolean = false // var downloaded: Boolean = false
/** /**
* title as defined by the feed. * 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?, constructor(id: Long, lastUpdate: String?, title: String?, link: String?, description: String?, paymentLink: String?,
description: String?, paymentLinks: String?, author: String?, language: String?, author: String?, language: String?, type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?,
type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?, downloadUrl: String?) {
downloadUrl: String?, downloaded: Boolean, paged: Boolean, nextPageLink: String?,
filter: String?, sortOrder: SortOrder?, lastUpdateFailed: Boolean) {
this.id = id this.id = id
this.fileUrl = fileUrl this.fileUrl = fileUrl
this.downloadUrl = downloadUrl this.downloadUrl = downloadUrl
this.downloaded = downloaded
this.eigenTitle = title this.eigenTitle = title
this.customTitle = customTitle this.customTitle = customTitle
this.lastUpdate = lastUpdate this.lastUpdate = lastUpdate
this.link = link this.link = link
this.description = description this.description = description
this.paymentLinks = extractPaymentLinks(paymentLinks) this.paymentLinks = extractPaymentLinks(paymentLink)
this.author = author this.author = author
this.language = language this.language = language
this.type = type this.type = type
this.identifier = feedIdentifier this.identifier = feedIdentifier
this.imageUrl = imageUrl this.imageUrl = imageUrl
this.isPaged = paged this.isPaged = false
this.nextPageLink = nextPageLink this.nextPageLink = nextPageLink
// if (filter != null) this.episodeFilter = EpisodeFilter(filter) this.preferences?.filterString = ""
// else this.episodeFilter = EpisodeFilter()
this.preferences?.filterString = filter ?: ""
this.sortOrder = sortOrder this.sortOrder = sortOrder
this.preferences?.sortOrderCode = sortOrder?.code ?: 0 this.preferences?.sortOrderCode = sortOrder?.code ?: 0
this.lastUpdateFailed = lastUpdateFailed 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. * 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 this.lastUpdate = lastUpdate
fileUrl = null fileUrl = null
this.downloadUrl = url this.downloadUrl = url
downloaded = false
} }
/** /**
@ -247,7 +214,7 @@ class Feed : RealmObject {
preferences = FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password) preferences = FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password)
} }
fun getHumanReadableIdentifier(): String? { fun getTextIdentifier(): String? {
return when { return when {
!customTitle.isNullOrEmpty() -> customTitle !customTitle.isNullOrEmpty() -> customTitle
!eigenTitle.isNullOrEmpty() -> eigenTitle !eigenTitle.isNullOrEmpty() -> eigenTitle

View File

@ -22,9 +22,6 @@ class FeedPreferences(@Index var feedID: Long,
@Ignore @JvmField var volumeAdaptionSetting: VolumeAdaptionSetting?, @Ignore @JvmField var volumeAdaptionSetting: VolumeAdaptionSetting?,
var username: String?, var username: String?,
var password: String?, var password: String?,
/**
* @return the filter for this feed
*/
@Ignore @JvmField var filter: FeedEpisodesFilter, @Ignore @JvmField var filter: FeedEpisodesFilter,
var playSpeed: Float, var playSpeed: Float,
var introSkip: Int, var introSkip: Int,

View File

@ -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"
}
}

View File

@ -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)
}
}
}

View File

@ -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<EpisodeAction>()
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<Episode> = 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<Long, Episode>? {
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)
}
}

View File

@ -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<Feed?>?, writer: Writer?, context: Context) {
Logd(TAG, "Starting to write document")
val queuedEpisodeActions: MutableList<EpisodeAction> = 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<Episode>()
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"
}
}

View File

@ -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<Feed?>?, writer: Writer?, context: Context)
fun fileExtension(): String?
}

View File

@ -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<Feed?>?, 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<Episode> = favoriteByFeed[feedId]!!
writer.append("<li><div>\n")
writeFeed(writer, favorites[0].feed, feedTemplate)
writer.append("<ul>\n")
for (item in favorites) {
writeFavoriteItem(writer, item, favTemplate)
}
writer.append("</ul></div></li>\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<Episode>): Map<Long, MutableList<Episode>> {
val feedMap: MutableMap<Long, MutableList<Episode>> = 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"
}
}

View File

@ -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<Feed?>?, 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("<li><div><img src=\"")
writer.append(feed!!.imageUrl)
writer.append("\" /><p>")
writer.append(feed.title)
writer.append(" <span><a href=\"")
writer.append(feed.link)
writer.append("\">Website</a> • <a href=\"")
writer.append(feed.downloadUrl)
writer.append("\">Feed</a></span></p></div></li>\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"
}
}

View File

@ -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
}

View File

@ -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<OpmlElement>? = 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<OpmlElement> {
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"
}
}

View File

@ -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"
}

View File

@ -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<Feed?>?, 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"
}
}

View File

@ -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 { }
}
}

View File

@ -67,10 +67,7 @@ object ChapterUtils {
try { try {
openStream(playable, context).use { inVal -> openStream(playable, context).use { inVal ->
val chapters = readId3ChaptersFrom(inVal) val chapters = readId3ChaptersFrom(inVal)
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) return chapters
Log.i(TAG, "Chapters loaded")
return chapters
}
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Unable to load ID3 chapters: " + e.message) Log.e(TAG, "Unable to load ID3 chapters: " + e.message)
@ -81,10 +78,7 @@ object ChapterUtils {
try { try {
openStream(playable, context).use { inVal -> openStream(playable, context).use { inVal ->
val chapters = readOggChaptersFromInputStream(inVal) val chapters = readOggChaptersFromInputStream(inVal)
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) return chapters
Log.i(TAG, "Chapters loaded")
return chapters
}
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Unable to load vorbis chapters: " + e.message) Log.e(TAG, "Unable to load vorbis chapters: " + e.message)
@ -151,7 +145,7 @@ object ChapterUtils {
chapters = chapters.sortedWith(ChapterStartTimeComparator()) chapters = chapters.sortedWith(ChapterStartTimeComparator())
enumerateEmptyChapterTitles(chapters) enumerateEmptyChapterTitles(chapters)
if (!chaptersValid(chapters)) { if (!chaptersValid(chapters)) {
Log.i(TAG, "Chapter data was invalid") Logd(TAG, "Chapter data was invalid")
return emptyList() return emptyList()
} }
return chapters return chapters

View File

@ -1,10 +1,10 @@
package ac.mdiq.podcini.ui.actions.actionbutton package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R 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.PlaybackController.Companion.playbackService
import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre 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.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeMedia
@ -40,7 +40,7 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) {
} }
if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) { if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) {
playbackService?.mediaPlayer?.resume() playbackService?.mPlayer?.resume()
playbackService?.taskManager?.restartSleepTimer() playbackService?.taskManager?.restartSleepTimer()
} else { } else {
PlaybackServiceStarter(context, media).callEvenIfRunning(true).start() PlaybackServiceStarter(context, media).callEvenIfRunning(true).start()

View File

@ -1,10 +1,10 @@
package ac.mdiq.podcini.ui.actions.actionbutton package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R 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.PlaybackController.Companion.playbackService
import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre 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.model.Episode
import ac.mdiq.podcini.storage.utils.MediaType import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
@ -30,7 +30,7 @@ class PlayLocalActionButton(item: Episode) : EpisodeActionButton(item) {
} }
if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) { if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) {
playbackService?.mediaPlayer?.resume() playbackService?.mPlayer?.resume()
playbackService?.taskManager?.restartSleepTimer() playbackService?.taskManager?.restartSleepTimer()
} else { } else {
PlaybackServiceStarter(context, media).callEvenIfRunning(true).start() PlaybackServiceStarter(context, media).callEvenIfRunning(true).start()

View File

@ -2,7 +2,6 @@ package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.PlaybackServiceStarter 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
import ac.mdiq.podcini.preferences.UsageStatistics.logAction import ac.mdiq.podcini.preferences.UsageStatistics.logAction
import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming 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.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia import ac.mdiq.podcini.storage.model.RemoteMedia
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed 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.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent import ac.mdiq.podcini.util.event.FlowEvent
import android.content.Context import android.content.Context

View File

@ -19,6 +19,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.defaultPage
import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.receiver.PlayerWidget 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.Feeds.updateFeedMap
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
@ -95,7 +96,7 @@ class MainActivity : CastEnabledActivity() {
private lateinit var mainView: View private lateinit var mainView: View
private lateinit var navDrawerFragment: NavDrawerFragment private lateinit var navDrawerFragment: NavDrawerFragment
private lateinit var audioPlayerFragment: AudioPlayerFragment private lateinit var audioPlayerFragment: AudioPlayerFragment
private lateinit var audioPlayerFragmentView: View private lateinit var audioPlayerView: View
private lateinit var controllerFuture: ListenableFuture<MediaController> private lateinit var controllerFuture: ListenableFuture<MediaController>
private lateinit var navDrawer: View private lateinit var navDrawer: View
private lateinit var dummyView : View private lateinit var dummyView : View
@ -130,6 +131,7 @@ class MainActivity : CastEnabledActivity() {
SwipeActions.getSharedPrefs(this@MainActivity) SwipeActions.getSharedPrefs(this@MainActivity)
QueueFragment.getSharedPrefs(this@MainActivity) QueueFragment.getSharedPrefs(this@MainActivity)
updateFeedMap() updateFeedMap()
monitorFeeds()
// InTheatre.apply { } // InTheatre.apply { }
PlayerDetailsFragment.getSharedPrefs(this@MainActivity) PlayerDetailsFragment.getSharedPrefs(this@MainActivity)
PlayerWidget.getSharedPrefs(this@MainActivity) PlayerWidget.getSharedPrefs(this@MainActivity)
@ -196,11 +198,11 @@ class MainActivity : CastEnabledActivity() {
transaction.replace(R.id.audioplayerFragment, audioPlayerFragment, AudioPlayerFragment.TAG) transaction.replace(R.id.audioplayerFragment, audioPlayerFragment, AudioPlayerFragment.TAG)
transaction.commit() transaction.commit()
navDrawer = findViewById(R.id.navDrawerFragment) navDrawer = findViewById(R.id.navDrawerFragment)
audioPlayerFragmentView = findViewById(R.id.audioplayerFragment) audioPlayerView = findViewById(R.id.audioplayerFragment)
runOnIOScope { checkFirstLaunch() } runOnIOScope { checkFirstLaunch() }
this.bottomSheet = BottomSheetBehavior.from(audioPlayerFragmentView) as LockableBottomSheetBehavior<*> this.bottomSheet = BottomSheetBehavior.from(audioPlayerView) as LockableBottomSheetBehavior<*>
this.bottomSheet.isHideable = false this.bottomSheet.isHideable = false
this.bottomSheet.isDraggable = false this.bottomSheet.isDraggable = false
this.bottomSheet.setBottomSheetCallback(bottomSheetCallback) this.bottomSheet.setBottomSheetCallback(bottomSheetCallback)
@ -382,7 +384,7 @@ class MainActivity : CastEnabledActivity() {
get() = drawerLayout?.isDrawerOpen(navDrawer)?:false get() = drawerLayout?.isDrawerOpen(navDrawer)?:false
private fun updateInsets() { private fun updateInsets() {
setPlayerVisible(audioPlayerFragmentView.visibility == View.VISIBLE) setPlayerVisible(audioPlayerView.visibility == View.VISIBLE)
val playerHeight = resources.getDimension(R.dimen.external_player_height).toInt() val playerHeight = resources.getDimension(R.dimen.external_player_height).toInt()
bottomSheet.peekHeight = playerHeight + navigationBarInsets.bottom bottomSheet.peekHeight = playerHeight + navigationBarInsets.bottom
} }
@ -403,7 +405,11 @@ class MainActivity : CastEnabledActivity() {
val playerParams = playerView?.layoutParams as? MarginLayoutParams val playerParams = playerView?.layoutParams as? MarginLayoutParams
playerParams?.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, 0) playerParams?.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, 0)
playerView.layoutParams = playerParams 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?) { fun loadFragment(tag: String?, args: Bundle?) {
@ -669,7 +675,7 @@ class MainActivity : CastEnabledActivity() {
val s: Snackbar val s: Snackbar
if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) { if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) {
s = Snackbar.make(mainView, text!!, duration) 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) } else s = Snackbar.make(binding.root, text!!, duration)
s.show() s.show()

View File

@ -5,8 +5,8 @@ import ac.mdiq.podcini.databinding.OpmlSelectionBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.transport.OpmlElement import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlElement
import ac.mdiq.podcini.storage.transport.OpmlReader import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlReader
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import android.Manifest import android.Manifest

View File

@ -5,12 +5,12 @@ import ac.mdiq.podcini.databinding.AudioControlsBinding
import ac.mdiq.podcini.databinding.VideoplayerActivityBinding import ac.mdiq.podcini.databinding.VideoplayerActivityBinding
import ac.mdiq.podcini.playback.PlaybackController import ac.mdiq.podcini.playback.PlaybackController
import ac.mdiq.podcini.playback.PlaybackController.Companion.duration 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.playbackService
import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo
import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive
import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.cast.CastEnabledActivity 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.playback.service.PlaybackService.Companion.isCasting
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
import ac.mdiq.podcini.storage.database.Episodes.setFavorite import ac.mdiq.podcini.storage.database.Episodes.setFavorite
@ -83,7 +83,7 @@ class VideoplayerActivity : CastEnabledActivity() {
finish() finish()
} }
if (videoMode != VideoMode.FULL_SCREEN_VIEW && videoMode != VideoMode.WINDOW_VIEW) { 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 videoMode = VideoMode.WINDOW_VIEW
} }
} }
@ -434,7 +434,7 @@ class VideoplayerActivity : CastEnabledActivity() {
butAudioTracks.text = audioTracks[selectedAudioTrack] butAudioTracks.text = audioTracks[selectedAudioTrack]
butAudioTracks.setOnClickListener { butAudioTracks.setOnClickListener {
// setAudioTrack((selectedAudioTrack + 1) % audioTracks.size) // 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) Handler(Looper.getMainLooper()).postDelayed({ this.setupAudioTracks() }, 500)
} }
} }
@ -455,13 +455,13 @@ class VideoplayerActivity : CastEnabledActivity() {
private val audioTracks: List<String> private val audioTracks: List<String>
get() { get() {
val tracks = playbackService?.mediaPlayer?.getAudioTracks() val tracks = playbackService?.mPlayer?.getAudioTracks()
if (tracks.isNullOrEmpty()) return emptyList() if (tracks.isNullOrEmpty()) return emptyList()
return tracks.filterNotNull().map { it } return tracks.filterNotNull().map { it }
} }
private val selectedAudioTrack: Int private val selectedAudioTrack: Int
get() = playbackService?.mediaPlayer?.getSelectedAudioTrack() ?: -1 get() = playbackService?.mPlayer?.getSelectedAudioTrack() ?: -1
private fun getWebsiteLinkWithFallback(media: Playable?): String? { private fun getWebsiteLinkWithFallback(media: Playable?): String? {
return when { return when {

View File

@ -18,6 +18,7 @@ import ac.mdiq.podcini.preferences.ThemeSwitcher.getTheme
import ac.mdiq.podcini.receiver.PlayerWidget import ac.mdiq.podcini.receiver.PlayerWidget
import ac.mdiq.podcini.receiver.PlayerWidget.Companion.prefs import ac.mdiq.podcini.receiver.PlayerWidget.Companion.prefs
import ac.mdiq.podcini.ui.widget.WidgetUpdaterWorker import ac.mdiq.podcini.ui.widget.WidgetUpdaterWorker
import ac.mdiq.podcini.util.Logd
class WidgetConfigActivity : AppCompatActivity() { class WidgetConfigActivity : AppCompatActivity() {
@ -63,9 +64,7 @@ class WidgetConfigActivity : AppCompatActivity() {
val color = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress) val color = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress)
widgetPreview.setBackgroundColor(color) widgetPreview.setBackgroundColor(color)
} }
override fun onStartTrackingTouch(seekBar: SeekBar) {} override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {} override fun onStopTrackingTouch(seekBar: SeekBar) {}
}) })
@ -96,6 +95,8 @@ class WidgetConfigActivity : AppCompatActivity() {
} }
private fun setInitialState() { private fun setInitialState() {
PlayerWidget.getSharedPrefs(this)
// val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE) // val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE)
ckPlaybackSpeed.isChecked = prefs!!.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, true) ckPlaybackSpeed.isChecked = prefs!!.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, true)
ckRewind.isChecked = prefs!!.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, true) ckRewind.isChecked = prefs!!.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, true)
@ -123,6 +124,7 @@ class WidgetConfigActivity : AppCompatActivity() {
private fun confirmCreateWidget() { private fun confirmCreateWidget() {
val backgroundColor = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress) val backgroundColor = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.progress)
Logd("WidgetConfigActivity", "confirmCreateWidget appWidgetId $appWidgetId")
// val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE) // val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE)
val editor = prefs!!.edit() val editor = prefs!!.edit()
editor.putInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, backgroundColor) editor.putInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, backgroundColor)

View File

@ -4,6 +4,7 @@ import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.model.Episode 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.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment
@ -25,12 +26,27 @@ open class EpisodesAdapter(mainActivity: MainActivity)
private val TAG: String = this::class.simpleName ?: "Anonymous" private val TAG: String = this::class.simpleName ?: "Anonymous"
val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity) val mainActivityRef: WeakReference<MainActivity> = WeakReference<MainActivity>(mainActivity)
protected val activity: Activity?
get() = mainActivityRef.get()
private var episodes: List<Episode> = ArrayList() private var episodes: List<Episode> = ArrayList()
private var feed: Feed? = null
var longPressedItem: Episode? = null var longPressedItem: Episode? = null
private var longPressedPosition: Int = 0 // used to init actionMode private var longPressedPosition: Int = 0 // used to init actionMode
private var dummyViews = 0 private var dummyViews = 0
val selectedItems: List<Any>
get() {
val items: MutableList<Episode> = 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 { init {
setHasStableIds(true) setHasStableIds(true)
} }
@ -40,8 +56,9 @@ open class EpisodesAdapter(mainActivity: MainActivity)
notifyDataSetChanged() notifyDataSetChanged()
} }
fun updateItems(items: List<Episode>) { fun updateItems(items: List<Episode>, feed_: Feed? = null) {
episodes = items episodes = items
feed = feed_
notifyDataSetChanged() notifyDataSetChanged()
updateTitle() updateTitle()
} }
@ -72,6 +89,7 @@ open class EpisodesAdapter(mainActivity: MainActivity)
beforeBindViewHolder(holder, pos) beforeBindViewHolder(holder, pos)
val item: Episode = unmanagedCopy(episodes[pos]) val item: Episode = unmanagedCopy(episodes[pos])
if (feed != null) item.feed = feed
holder.bind(item) holder.bind(item)
// holder.infoCard.setOnCreateContextMenuListener(this) // holder.infoCard.setOnCreateContextMenuListener(this)
@ -154,9 +172,6 @@ open class EpisodesAdapter(mainActivity: MainActivity)
return item return item
} }
protected val activity: Activity?
get() = mainActivityRef.get()
@UnstableApi override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { @UnstableApi override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
val inflater: MenuInflater = activity!!.menuInflater val inflater: MenuInflater = activity!!.menuInflater
if (inActionMode()) { if (inActionMode()) {
@ -188,16 +203,4 @@ open class EpisodesAdapter(mainActivity: MainActivity)
else -> return false else -> return false
} }
} }
val selectedItems: List<Any>
get() {
val items: MutableList<Episode> = 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
}
} }

View File

@ -58,17 +58,6 @@ class OnlineFeedsAdapter(private val context: Context, objects: List<PodcastSear
viewHolder.updateView.visibility = View.VISIBLE viewHolder.updateView.visibility = View.VISIBLE
} else viewHolder.updateView.visibility = View.INVISIBLE } else viewHolder.updateView.visibility = View.INVISIBLE
//Update the empty imageView with the image from the feed
// if (!podcast.imageUrl.isNullOrBlank()) Glide.with(context)
// .load(podcast.imageUrl)
// .apply(RequestOptions()
// .placeholder(R.color.light_gray)
// .diskCacheStrategy(DiskCacheStrategy.NONE)
// .transform(FitCenter(),
// RoundedCorners((4 * context.resources.displayMetrics.density).toInt()))
// .dontAnimate())
// .into(viewHolder.coverView)
viewHolder.coverView.load(podcast.imageUrl) { viewHolder.coverView.load(podcast.imageUrl) {
placeholder(R.color.light_gray) placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher) error(R.mipmap.ic_launcher)

View File

@ -22,13 +22,6 @@ class SimpleIconListAdapter<T : SimpleIconListAdapter.ListItem>(private val cont
val binding = SimpleIconListItemBinding.bind(view!!) val binding = SimpleIconListItemBinding.bind(view!!)
binding.title.text = item.title binding.title.text = item.title
binding.subtitle.text = item.subtitle 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) binding.icon.load(item.imageUrl)
return view return view
} }

View File

@ -48,7 +48,7 @@ object RemoveFeedDialog {
for (feed in feeds) { for (feed in feeds) {
deleteFeed(context, feed.id, false) deleteFeed(context, feed.id, false)
} }
EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feeds)) // EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds))
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Logd(TAG, "Feed(s) deleted") Logd(TAG, "Feed(s) deleted")

View File

@ -4,6 +4,8 @@ import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SpeedSelectDialogBinding import ac.mdiq.podcini.databinding.SpeedSelectDialogBinding
import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier
import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService 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.base.InTheatre.curState
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType
import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences
@ -138,7 +140,7 @@ import java.util.*
skipSilence.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> skipSilence.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
isSkipSilence = isChecked isSkipSilence = isChecked
// setSkipSilence(isChecked) // setSkipSilence(isChecked)
playbackService?.mediaPlayer?.setPlaybackParams(playbackService!!.currentPlaybackSpeed, isChecked) playbackService?.mPlayer?.setPlaybackParams(playbackService!!.curSpeed, isChecked)
} }
return binding.root return binding.root
@ -217,13 +219,13 @@ import java.util.*
if (currentMediaType == MediaType.VIDEO) { if (currentMediaType == MediaType.VIDEO) {
curState.curTempSpeed = speed curState.curTempSpeed = speed
videoPlaybackSpeed = speed videoPlaybackSpeed = speed
playbackService!!.mediaPlayer?.setPlaybackParams(speed, isSkipSilence) playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
} else { } else {
if (codeArray != null && codeArray.size == 3) { if (codeArray != null && codeArray.size == 3) {
Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}") Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}")
if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed) if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed)
if (codeArray[1]) { if (codeArray[1]) {
val episode = (playbackService!!.playable as? EpisodeMedia)?.episode ?: playbackService!!.currentitem val episode = (curMedia as? EpisodeMedia)?.episode ?: curEpisode
if (episode != null) { if (episode != null) {
var feed = episode.feed var feed = episode.feed
if (feed != null) { if (feed != null) {
@ -240,11 +242,11 @@ import java.util.*
} }
if (codeArray[0]) { if (codeArray[0]) {
curState.curTempSpeed = speed curState.curTempSpeed = speed
playbackService!!.mediaPlayer?.setPlaybackParams(speed, isSkipSilence) playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
} }
} else { } else {
curState.curTempSpeed = speed curState.curTempSpeed = speed
playbackService!!.mediaPlayer?.setPlaybackParams(speed, isSkipSilence) playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
} }
} }
} }

View File

@ -62,20 +62,14 @@ import kotlin.math.min
override fun loadData(): List<Episode> { override fun loadData(): List<Episode> {
allEpisodes = getEpisodes(0, Int.MAX_VALUE, getFilter(), allEpisodesSortOrder, false) allEpisodes = getEpisodes(0, Int.MAX_VALUE, getFilter(), allEpisodesSortOrder, false)
Logd(TAG, "loadData() allEpisodes.size ${allEpisodes.size}") return allEpisodes.subList(0, min(allEpisodes.size-1, page * EPISODES_PER_PAGE))
return allEpisodes.subList(0, page * EPISODES_PER_PAGE)
// return getEpisodes(0, page * EPISODES_PER_PAGE, getFilter(), allEpisodesSortOrder)
} }
override fun loadMoreData(page: Int): List<Episode> { override fun loadMoreData(page: Int): List<Episode> {
val offset = (page - 1) * EPISODES_PER_PAGE val offset = (page - 1) * EPISODES_PER_PAGE
Logd(TAG, "loadMoreData() page: $page $offset ${allEpisodes.size}")
if (offset >= allEpisodes.size) return listOf() if (offset >= allEpisodes.size) return listOf()
val toIndex = offset + EPISODES_PER_PAGE 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(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 { override fun loadTotalItemCount(): Int {
@ -86,10 +80,6 @@ import kotlin.math.min
return EpisodeFilter(prefFilterAllEpisodes) return EpisodeFilter(prefFilterAllEpisodes)
} }
override fun getFragmentTag(): String {
return TAG
}
override fun getPrefName(): String { override fun getPrefName(): String {
return PREF_NAME return PREF_NAME
} }
@ -162,7 +152,7 @@ import kotlin.math.min
override fun onSelectionChanged() { override fun onSelectionChanged() {
super.onSelectionChanged() super.onSelectionChanged()
allEpisodesSortOrder = sortOrder allEpisodesSortOrder = sortOrder
EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(0)) EventFlow.postEvent(FlowEvent.FeedsSortedEvent())
} }
} }

View File

@ -2,33 +2,31 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding
import ac.mdiq.podcini.databinding.InternalPlayerFragmentBinding import ac.mdiq.podcini.databinding.PlayerUiFragmentBinding
import ac.mdiq.podcini.storage.utils.ChapterUtils
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.playback.PlaybackController 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.curSpeedMultiplier
import ac.mdiq.podcini.playback.PlaybackController.Companion.duration import ac.mdiq.podcini.playback.PlaybackController.Companion.duration
import ac.mdiq.podcini.playback.PlaybackController.Companion.fallbackSpeed 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.isPlayingVideoLocally
import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService 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.seekTo
import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive
import ac.mdiq.podcini.playback.base.InTheatre.curMedia 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
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed
import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.playback.cast.CastEnabledActivity 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
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
import ac.mdiq.podcini.receiver.MediaButtonReceiver import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.model.Chapter 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.EpisodeMedia
import ac.mdiq.podcini.storage.model.Playable 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.storage.utils.MediaType
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity 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.dialog.VariableSpeedDialog
import ac.mdiq.podcini.ui.view.ChapterSeekBar import ac.mdiq.podcini.ui.view.ChapterSeekBar
import ac.mdiq.podcini.ui.view.PlayButton 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.Converter
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.TimeSpeedConverter import ac.mdiq.podcini.util.TimeSpeedConverter
@ -52,7 +49,6 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.* import android.view.*
import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
@ -93,23 +89,20 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
private var playerDetailsFragment: PlayerDetailsFragment? = null private var playerDetailsFragment: PlayerDetailsFragment? = null
private lateinit var toolbar: MaterialToolbar 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 playerUI1: PlayerUIFragment? = null
private var playerView2: View? = 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 cardViewSeek: CardView
private lateinit var txtvSeek: TextView
private var controller: PlaybackController? = null private var controller: PlaybackController? = null
private var seekedToChapterStart = false private var seekedToChapterStart = false
// private var currentChapterIndex = -1 // private var currentChapterIndex = -1
private var duration = 0
private var currentMedia: Playable? = null private var currentMedia: Playable? = null
private var currentitem: Episode? = null
private var isShowPlay: Boolean = false private var isShowPlay: Boolean = false
var isCollapsed = true var isCollapsed = true
@ -136,25 +129,21 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
controller = createController() controller = createController()
controller!!.init() controller!!.init()
playerFragment1 = InternalPlayerFragment.newInstance(controller!!) playerUI1 = PlayerUIFragment.newInstance(controller!!)
childFragmentManager.beginTransaction() childFragmentManager.beginTransaction()
.replace(R.id.playerFragment1, playerFragment1!!, "InternalPlayerFragment1") .replace(R.id.playerFragment1, playerUI1!!, "InternalPlayerFragment1")
.commit() .commit()
playerView1 = binding.root.findViewById(R.id.playerFragment1) playerUIView1 = binding.root.findViewById(R.id.playerFragment1)
playerView1?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) playerUIView1?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density))
playerFragment2 = InternalPlayerFragment.newInstance(controller!!) playerUI2 = PlayerUIFragment.newInstance(controller!!)
childFragmentManager.beginTransaction() childFragmentManager.beginTransaction()
.replace(R.id.playerFragment2, playerFragment2!!, "InternalPlayerFragment2") .replace(R.id.playerFragment2, playerUI2!!, "InternalPlayerFragment2")
.commit() .commit()
playerView2 = binding.root.findViewById(R.id.playerFragment2) playerUIView2 = binding.root.findViewById(R.id.playerFragment2)
playerView2?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) playerUIView2?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density))
onCollaped() onCollaped()
cardViewSeek = binding.cardViewSeek cardViewSeek = binding.cardViewSeek
txtvSeek = binding.txtvSeek
return binding.root return binding.root
} }
@ -184,18 +173,18 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
transaction.replace(R.id.itemDescription, playerDetailsFragment!!).commit() transaction.replace(R.id.itemDescription, playerDetailsFragment!!).commit()
} }
isCollapsed = false isCollapsed = false
playerFragment = playerFragment2 playerUI = playerUI2
playerFragment?.updateUi(currentMedia) playerUI?.updateUi(currentMedia)
playerFragment?.butPlay?.setIsShowPlay(isShowPlay) playerUI?.butPlay?.setIsShowPlay(isShowPlay)
playerDetailsFragment?.load() playerDetailsFragment?.updateInfo()
} }
fun onCollaped() { fun onCollaped() {
Logd(TAG, "onCollaped()") Logd(TAG, "onCollaped()")
isCollapsed = true isCollapsed = true
playerFragment = playerFragment1 playerUI = playerUI1
playerFragment?.updateUi(currentMedia) playerUI?.updateUi(currentMedia)
playerFragment?.butPlay?.setIsShowPlay(isShowPlay) playerUI?.butPlay?.setIsShowPlay(isShowPlay)
} }
private fun setChapterDividers(media: Playable?) { private fun setChapterDividers(media: Playable?) {
@ -218,11 +207,15 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
// } // }
fun loadMediaInfo(includingChapters: Boolean) { fun loadMediaInfo(includingChapters: Boolean) {
val actMain = (activity as MainActivity)
if (curMedia == null) { if (curMedia == null) {
(activity as MainActivity).setPlayerVisible(false) if (actMain.isPlayerVisible()) actMain.setPlayerVisible(false)
return 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())) { if (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier() || (includingChapters && !curMedia!!.chaptersLoaded())) {
Logd(TAG, "loadMediaInfo loading details ${curMedia?.getIdentifier()} chapter: $includingChapters") Logd(TAG, "loadMediaInfo loading details ${curMedia?.getIdentifier()} chapter: $includingChapters")
lifecycleScope.launch { lifecycleScope.launch {
@ -232,9 +225,14 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
} }
} }
currentMedia = media currentMedia = media
if (currentMedia is EpisodeMedia) {
val item = (currentMedia as EpisodeMedia).episode
if (item != null) playerDetailsFragment?.setItem(item)
}
updateUi() updateUi()
playerFragment?.updateUi(currentMedia) playerUI?.updateUi(currentMedia)
if (!includingChapters) loadMediaInfo(true) // TODO: disable for now
// if (!includingChapters) loadMediaInfo(true)
}.invokeOnCompletion { throwable -> }.invokeOnCompletion { throwable ->
if (throwable!= null) { if (throwable!= null) {
Log.e(TAG, Log.getStackTraceString(throwable)) Log.e(TAG, Log.getStackTraceString(throwable))
@ -247,16 +245,16 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
return object : PlaybackController(requireActivity()) { return object : PlaybackController(requireActivity()) {
override fun updatePlayButtonShowsPlay(showPlay: Boolean) { override fun updatePlayButtonShowsPlay(showPlay: Boolean) {
isShowPlay = showPlay isShowPlay = showPlay
playerFragment?.butPlay?.setIsShowPlay(showPlay) playerUI?.butPlay?.setIsShowPlay(showPlay)
// playerFragment2?.butPlay?.setIsShowPlay(showPlay) // playerFragment2?.butPlay?.setIsShowPlay(showPlay)
} }
override fun loadMediaInfo() { override fun loadMediaInfo() {
this@AudioPlayerFragment.loadMediaInfo(false) this@AudioPlayerFragment.loadMediaInfo(false)
if (!isCollapsed) playerDetailsFragment?.load() if (!isCollapsed) playerDetailsFragment?.updateInfo()
} }
override fun onPlaybackEnd() { override fun onPlaybackEnd() {
isShowPlay = true isShowPlay = true
playerFragment?.butPlay?.setIsShowPlay(true) playerUI?.butPlay?.setIsShowPlay(true)
// playerFragment2?.butPlay?.setIsShowPlay(true) // playerFragment2?.butPlay?.setIsShowPlay(true)
(activity as MainActivity).setPlayerVisible(false) (activity as MainActivity).setPlayerVisible(false)
} }
@ -274,13 +272,20 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
retainInstance = true retainInstance = true
} }
override fun onResume() {
Logd(TAG, "onResume() isCollapsed: $isCollapsed")
super.onResume()
}
override fun onStart() { override fun onStart() {
Logd(TAG, "onStart() isCollapsed: $isCollapsed")
super.onStart() super.onStart()
procFlowEvents() procFlowEvents()
loadMediaInfo(false) loadMediaInfo(false)
} }
override fun onStop() { override fun onStop() {
Logd(TAG, "onStop()")
super.onStop() super.onStop()
cancelFlowEvents() cancelFlowEvents()
// progressIndicator.visibility = View.GONE // Controller released; we will not receive buffering updates // 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) { private fun onEvenStartPlay(event: FlowEvent.PlayEvent) {
Logd(TAG, "onEvenStartPlay ${event.episode.title}") Logd(TAG, "onEvenStartPlay ${event.episode.title}")
currentitem = event.episode val currentitem = event.episode
if (currentMedia?.getIdentifier() == null || currentitem!!.media?.getIdentifier() != currentMedia?.getIdentifier()) if (currentMedia?.getIdentifier() == null || currentitem.media?.getIdentifier() != currentMedia?.getIdentifier()) {
playerDetailsFragment?.setItem(currentitem!!) currentMedia = currentitem.media
playerDetailsFragment?.setItem(currentitem)
}
(activity as MainActivity).setPlayerVisible(true) (activity as MainActivity).setPlayerVisible(true)
} }
private var eventSink: Job? = null private var eventSink: Job? = null
private fun cancelFlowEvents() { private fun cancelFlowEvents() {
Logd(TAG, "cancelFlowEvents")
eventSink?.cancel() eventSink?.cancel()
eventSink = null eventSink = null
} }
private fun procFlowEvents() { private fun procFlowEvents() {
if (eventSink != null) return if (eventSink != null) return
Logd(TAG, "procFlowEvents")
eventSink = lifecycleScope.launch { eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event -> EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}") Logd(TAG, "Received event: ${event.TAG}")
@ -327,25 +336,27 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
is FlowEvent.PlaybackServiceEvent -> { is FlowEvent.PlaybackServiceEvent -> {
if (event.action == FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) if (event.action == FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)
(activity as MainActivity).bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED (activity as MainActivity).bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
playerFragment?.onPlaybackServiceChanged(event) playerUI?.onPlaybackServiceChanged(event)
} }
is FlowEvent.PlayEvent -> onEvenStartPlay(event) is FlowEvent.PlayEvent -> onEvenStartPlay(event)
is FlowEvent.PlayerErrorEvent -> MediaPlayerErrorDialog.show(activity as Activity, event) is FlowEvent.PlayerErrorEvent -> MediaPlayerErrorDialog.show(activity as Activity, event)
is FlowEvent.FavoritesEvent -> loadMediaInfo(false) is FlowEvent.FavoritesEvent -> loadMediaInfo(false)
is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) loadMediaInfo(false) is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) loadMediaInfo(false)
is FlowEvent.PlaybackPositionEvent -> onPositionUpdate(event)
is FlowEvent.PlaybackPositionEvent -> playerFragment?.onPositionUpdate(event) is FlowEvent.SpeedChangedEvent -> playerUI?.updatePlaybackSpeedButton(event)
is FlowEvent.SpeedChangedEvent -> playerFragment?.updatePlaybackSpeedButton(event)
else -> {} else -> {}
} }
} }
} }
} }
private fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) {
// if (!isCollapsed) loadMediaInfo(false)
playerUI?.onPositionUpdate(event)
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (controller == null) return if (controller == null) return
when { when {
fromUser -> { fromUser -> {
val prog: Float = progress / (seekBar.max.toFloat()) val prog: Float = progress / (seekBar.max.toFloat())
@ -362,10 +373,10 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
// updateUi(controller!!.getMedia) // updateUi(controller!!.getMedia)
// sbPosition.highlightCurrentChapter() // sbPosition.highlightCurrentChapter()
// } // }
txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${Converter.getDurationStringLong(position)}") binding.txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${Converter.getDurationStringLong(position)}")
} else txtvSeek.text = 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 val isEpisodeMedia = media is EpisodeMedia
toolbar.menu?.findItem(R.id.open_feed_item)?.setVisible(isEpisodeMedia) toolbar.menu?.findItem(R.id.open_feed_item)?.setVisible(isEpisodeMedia)
var item = currentitem val item = if (isEpisodeMedia) (media as EpisodeMedia).episode else null
if (item == null && isEpisodeMedia) item = (media as EpisodeMedia).episode
EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item)
val mediaType = curMedia?.getMediaType() val mediaType = curMedia?.getMediaType()
@ -419,15 +429,16 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
override fun onMenuItemClick(menuItem: MenuItem): Boolean { override fun onMenuItemClick(menuItem: MenuItem): Boolean {
val media: Playable = curMedia ?: return false val media: Playable = curMedia ?: return false
val feedItem = if (media is EpisodeMedia) media.episode else null
var feedItem = currentitem
if (feedItem == null && media is EpisodeMedia) feedItem = media.episode
// feedItem: FeedItem? = if (media is EpisodeMedia) media.item else null
if (feedItem != null && EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, feedItem)) return true if (feedItem != null && EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, feedItem)) return true
val itemId = menuItem.itemId val itemId = menuItem.itemId
when (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 -> { R.id.show_video -> {
controller!!.playPause() controller!!.playPause()
VideoPlayerActivityStarter(requireContext(), VideoMode.FULL_SCREEN_VIEW).start() VideoPlayerActivityStarter(requireContext(), VideoMode.FULL_SCREEN_VIEW).start()
@ -463,7 +474,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
fun fadePlayerToToolbar(slideOffset: Float) { fun fadePlayerToToolbar(slideOffset: Float) {
val playerFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.2f).toDouble())) / 0.2f).toFloat() 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?.alpha = 1 - playerFadeProgress
player?.visibility = if (playerFadeProgress > 0.99f) View.INVISIBLE else View.VISIBLE 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() 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 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" val TAG = this::class.simpleName ?: "Anonymous"
private var _binding: InternalPlayerFragmentBinding? = null private var _binding: PlayerUiFragmentBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var imgvCover: ImageView private lateinit var imgvCover: ImageView
var butPlay: PlayButton? = null var butPlay: PlayButton? = null
private var isControlButtonsSet = false 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 txtvLength: TextView
private lateinit var sbPosition: ChapterSeekBar private lateinit var sbPosition: ChapterSeekBar
private var prevMedia: Playable? = null private var prevMedia: Playable? = null
private var showTimeLeft = false private var showTimeLeft = false
@UnstableApi @UnstableApi
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = InternalPlayerFragmentBinding.inflate(inflater) _binding = PlayerUiFragmentBinding.inflate(inflater)
Logd(TAG, "fragment onCreateView") Logd(TAG, "fragment onCreateView")
episodeTitle = binding.titleView
butPlaybackSpeed = binding.butPlaybackSpeed
txtvPlaybackSpeed = binding.txtvPlaybackSpeed
imgvCover = binding.imgvCover imgvCover = binding.imgvCover
butPlay = binding.butPlay butPlay = binding.butPlay
butRev = binding.butRev
txtvRev = binding.txtvRev
butFF = binding.butFF
txtvFF = binding.txtvFF
butSkip = binding.butSkip
txtvSkip = binding.txtvSkip
sbPosition = binding.sbPosition sbPosition = binding.sbPosition
txtvPosition = binding.txtvPosition
txtvLength = binding.txtvLength txtvLength = binding.txtvLength
setupLengthTextView() setupLengthTextView()
setupControlButtons() setupControlButtons()
butPlaybackSpeed.setOnClickListener { binding.butPlaybackSpeed.setOnClickListener {
VariableSpeedDialog.newInstance(booleanArrayOf(true, true, true), null)?.show(childFragmentManager, null) VariableSpeedDialog.newInstance(booleanArrayOf(true, true, true), null)?.show(childFragmentManager, null)
} }
sbPosition.setOnSeekBarChangeListener(this) sbPosition.setOnSeekBarChangeListener(this)
binding.playerUiFragment.setOnClickListener {
binding.internalPlayerFragment.setOnClickListener { Logd(TAG, "playerUiFragment was clicked")
Logd(TAG, "internalPlayerFragment was clicked")
val media = curMedia val media = curMedia
if (media != null) { if (media != null) {
val mediaType = media.getMediaType() val mediaType = media.getMediaType()
@ -540,32 +523,28 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
} else { } else {
controller?.playPause() controller?.playPause()
// controller!!.ensureService() // controller!!.ensureService()
val intent = PlaybackService.getPlayerActivityIntent(requireContext(), mediaType) val intent = getPlayerActivityIntent(requireContext(), mediaType)
startActivity(intent) startActivity(intent)
} }
} }
} }
return binding.root return binding.root
} }
@OptIn(UnstableApi::class) override fun onDestroyView() { @OptIn(UnstableApi::class) override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
@UnstableApi @UnstableApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
butPlay?.setOnClickListener { butPlay?.setOnClickListener {
if (controller == null) return@setOnClickListener if (controller == null) return@setOnClickListener
// val media = curMedia
val media = curMedia if (curMedia != null) {
if (media != null) { if (curMedia?.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING) {
if (media.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING) {
controller!!.playPause() controller!!.playPause()
requireContext().startActivity(PlaybackService.getPlayerActivityIntent(requireContext(), media.getMediaType())) requireContext().startActivity(getPlayerActivityIntent(requireContext(), curMedia!!.getMediaType()))
} else controller!!.playPause() } else controller!!.playPause()
if (!isControlButtonsSet) { if (!isControlButtonsSet) {
sbPosition.visibility = View.VISIBLE sbPosition.visibility = View.VISIBLE
isControlButtonsSet = true isControlButtonsSet = true
@ -573,17 +552,15 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
} }
} }
} }
@OptIn(UnstableApi::class) private fun setupControlButtons() { @OptIn(UnstableApi::class) private fun setupControlButtons() {
butRev.setOnClickListener { binding.butRev.setOnClickListener {
if (controller != null && isPlaybackServiceReady()) { if (controller != null && playbackService?.isServiceReady() == true) {
val curr: Int = position playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000)
seekTo(curr - UserPreferences.rewindSecs * 1000)
sbPosition.visibility = View.VISIBLE sbPosition.visibility = View.VISIBLE
} }
} }
butRev.setOnLongClickListener { binding.butRev.setOnLongClickListener {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, txtvRev) SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, binding.txtvRev)
true true
} }
butPlay?.setOnLongClickListener { butPlay?.setOnLongClickListener {
@ -593,61 +570,53 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
} }
true true
} }
butFF.setOnClickListener { binding.butFF.setOnClickListener {
if (controller != null && isPlaybackServiceReady()) { if (controller != null && playbackService?.isServiceReady() == true) {
val curr: Int = position playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000)
seekTo(curr + UserPreferences.fastForwardSecs * 1000)
sbPosition.visibility = View.VISIBLE sbPosition.visibility = View.VISIBLE
} }
} }
butFF.setOnLongClickListener { binding.butFF.setOnLongClickListener {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, txtvFF) SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, binding.txtvFF)
true true
} }
butSkip.setOnClickListener { binding.butSkip.setOnClickListener {
if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) {
val speedForward = UserPreferences.speedforwardSpeed val speedForward = UserPreferences.speedforwardSpeed
if (speedForward > 0.1f) speedForward(speedForward) if (speedForward > 0.1f) speedForward(speedForward)
} }
} }
butSkip.setOnLongClickListener { binding.butSkip.setOnLongClickListener {
activity?.sendBroadcast(MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT)) activity?.sendBroadcast(MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT))
true true
} }
} }
private fun speedForward(speed: Float) { private fun speedForward(speed: Float) {
// playbackService?.speedForward(speed) // playbackService?.speedForward(speed)
if (playbackService?.mediaPlayer == null || playbackService?.isFallbackSpeed == true) return if (playbackService?.mPlayer == null || playbackService?.isFallbackSpeed == true) return
if (playbackService?.isSpeedForward == false) { if (playbackService?.isSpeedForward == false) {
playbackService?.normalSpeed = playbackService?.mediaPlayer!!.getPlaybackSpeed() playbackService?.normalSpeed = playbackService?.mPlayer!!.getPlaybackSpeed()
playbackService?.mediaPlayer!!.setPlaybackParams(speed, isSkipSilence) playbackService?.mPlayer!!.setPlaybackParams(speed, isSkipSilence)
} else playbackService?.mediaPlayer?.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) } else playbackService?.mPlayer?.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence)
playbackService!!.isSpeedForward = !playbackService!!.isSpeedForward playbackService!!.isSpeedForward = !playbackService!!.isSpeedForward
} }
@OptIn(UnstableApi::class) private fun setupLengthTextView() { @OptIn(UnstableApi::class) private fun setupLengthTextView() {
showTimeLeft = UserPreferences.shouldShowRemainingTime() showTimeLeft = UserPreferences.shouldShowRemainingTime()
txtvLength.setOnClickListener(View.OnClickListener { txtvLength.setOnClickListener(View.OnClickListener {
if (controller == null) return@OnClickListener if (controller == null) return@OnClickListener
showTimeLeft = !showTimeLeft showTimeLeft = !showTimeLeft
UserPreferences.setShowRemainTimeSetting(showTimeLeft) UserPreferences.setShowRemainTimeSetting(showTimeLeft)
onPositionUpdate(FlowEvent.PlaybackPositionEvent(position, duration)) onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia, curPosition, duration))
}) })
} }
fun updatePlaybackSpeedButton(event: FlowEvent.SpeedChangedEvent) { fun updatePlaybackSpeedButton(event: FlowEvent.SpeedChangedEvent) {
val speedStr: String = DecimalFormat("0.00").format(event.newSpeed.toDouble()) val speedStr: String = DecimalFormat("0.00").format(event.newSpeed.toDouble())
txtvPlaybackSpeed.text = speedStr binding.txtvPlaybackSpeed.text = speedStr
butPlaybackSpeed.setSpeed(event.newSpeed) binding.butPlaybackSpeed.setSpeed(event.newSpeed)
} }
@UnstableApi @UnstableApi
fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) { 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 converter = TimeSpeedConverter(curSpeedMultiplier)
val currentPosition: Int = converter.convert(event.position) val currentPosition: Int = converter.convert(event.position)
val duration: Int = converter.convert(event.duration) 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") Log.w(TAG, "Could not react to position observer update because of invalid time")
return return
} }
binding.txtvPosition.text = Converter.getDurationStringLong(currentPosition)
txtvPosition.text = Converter.getDurationStringLong(currentPosition) binding.txtvPosition.setContentDescription(getString(R.string.position,
txtvPosition.setContentDescription(getString(R.string.position,
Converter.getDurationStringLocalized(requireContext(), currentPosition.toLong()))) Converter.getDurationStringLocalized(requireContext(), currentPosition.toLong())))
val showTimeLeft = UserPreferences.shouldShowRemainingTime() val showTimeLeft = UserPreferences.shouldShowRemainingTime()
if (showTimeLeft) { if (showTimeLeft) {
@ -670,7 +638,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
Converter.getDurationStringLocalized(requireContext(), duration.toLong()))) Converter.getDurationStringLocalized(requireContext(), duration.toLong())))
txtvLength.text = Converter.getDurationStringLong(duration) 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) { if (!sbPosition.isPressed) {
val progress: Float = (event.position.toFloat()) / event.duration val progress: Float = (event.position.toFloat()) / event.duration
@ -678,7 +646,6 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
sbPosition.progress = (progress * sbPosition.max).toInt() sbPosition.progress = (progress * sbPosition.max).toInt()
} }
} }
fun onPlaybackServiceChanged(event: FlowEvent.PlaybackServiceEvent) { fun onPlaybackServiceChanged(event: FlowEvent.PlaybackServiceEvent) {
when (event.action) { when (event.action) {
FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN -> (activity as MainActivity).setPlayerVisible(false) 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) // PlaybackServiceEvent.Action.SERVICE_RESTARTED -> (activity as MainActivity).setPlayerVisible(true)
} }
} }
@OptIn(UnstableApi::class) override fun onStart() { @OptIn(UnstableApi::class) override fun onStart() {
Logd(TAG, "onStart() called") Logd(TAG, "onStart() called")
super.onStart() super.onStart()
binding.txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong())
txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()) binding.txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong())
txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()) if (UserPreferences.speedforwardSpeed > 0.1f) binding.txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed)
if (UserPreferences.speedforwardSpeed > 0.1f) txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed) else binding.txtvSkip.visibility = View.GONE
else txtvSkip.visibility = View.GONE
val media = curMedia ?: return val media = curMedia ?: return
updatePlaybackSpeedButton(FlowEvent.SpeedChangedEvent(getCurrentPlaybackSpeed(media))) updatePlaybackSpeedButton(FlowEvent.SpeedChangedEvent(getCurrentPlaybackSpeed(media)))
} }
@UnstableApi @UnstableApi
override fun onPause() { override fun onPause() {
Logd(TAG, "onPause() called") Logd(TAG, "onPause() called")
super.onPause() super.onPause()
controller?.pause() controller?.pause()
} }
@OptIn(UnstableApi::class) override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} @OptIn(UnstableApi::class) override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {}
override fun onStartTrackingTouch(seekBar: SeekBar) {} override fun onStartTrackingTouch(seekBar: SeekBar) {}
@OptIn(UnstableApi::class) override fun onStopTrackingTouch(seekBar: SeekBar) { @OptIn(UnstableApi::class) override fun onStopTrackingTouch(seekBar: SeekBar) {
if (isPlaybackServiceReady()) { if (playbackService?.isServiceReady() == true) {
val prog: Float = seekBar.progress / (seekBar.max.toFloat()) val prog: Float = seekBar.progress / (seekBar.max.toFloat())
seekTo((prog * duration).toInt()) seekTo((prog * duration).toInt())
} }
} }
@UnstableApi @UnstableApi
fun updateUi(media: Playable?) { fun updateUi(media: Playable?) {
Logd(TAG, "updateUi called $media") Logd(TAG, "updateUi called $media")
if (media == null) return if (media == null) return
binding.titleView.text = media.getEpisodeTitle()
episodeTitle.text = media.getEpisodeTitle()
// (activity as MainActivity).setPlayerVisible(true) // (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()) { if (prevMedia?.getIdentifier() != media.getIdentifier()) {
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(media) val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(media)
val imgLocFB = ImageResourceUtils.getFallbackImageLocation(media) val imgLocFB = ImageResourceUtils.getFallbackImageLocation(media)
@ -761,18 +719,14 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
companion object { companion object {
var controller: PlaybackController? = null var controller: PlaybackController? = null
fun newInstance(controller_: PlaybackController) : InternalPlayerFragment { fun newInstance(controller_: PlaybackController) : PlayerUIFragment {
controller = controller_ controller = controller_
return InternalPlayerFragment() return PlayerUIFragment()
} }
} }
} }
companion object { companion object {
val TAG = AudioPlayerFragment::class.simpleName ?: "Anonymous" val TAG = AudioPlayerFragment::class.simpleName ?: "Anonymous"
fun isPlaybackServiceReady() : Boolean {
return playbackService?.isServiceReady() == true
}
} }
} }

View File

@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.collectLatest
@UnstableApi abstract class BaseEpisodesFragment : Fragment(), SelectableAdapter.OnSelectModeListener, Toolbar.OnMenuItemClickListener { @UnstableApi abstract class BaseEpisodesFragment : Fragment(), SelectableAdapter.OnSelectModeListener, Toolbar.OnMenuItemClickListener {
val TAG = this::class.simpleName ?: "Anonymous" val TAG = this::class.simpleName ?: "Anonymous"
@JvmField @JvmField
@ -96,25 +95,19 @@ import kotlinx.coroutines.flow.collectLatest
setupLoadMoreScrollListener() setupLoadMoreScrollListener()
recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar))
swipeActions = SwipeActions(this, getFragmentTag()).attachTo(recyclerView) swipeActions = SwipeActions(this, TAG).attachTo(recyclerView)
lifecycle.addObserver(swipeActions) lifecycle.addObserver(swipeActions)
swipeActions.setFilter(getFilter()) swipeActions.setFilter(getFilter())
refreshSwipeTelltale() refreshSwipeTelltale()
binding.leftActionIcon.setOnClickListener { binding.leftActionIcon.setOnClickListener { swipeActions.showDialog() }
swipeActions.showDialog() binding.rightActionIcon.setOnClickListener { swipeActions.showDialog() }
}
binding.rightActionIcon.setOnClickListener {
swipeActions.showDialog()
}
val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator
if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false
swipeRefreshLayout = binding.swipeRefresh swipeRefreshLayout = binding.swipeRefresh
swipeRefreshLayout.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance)) swipeRefreshLayout.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance))
swipeRefreshLayout.setOnRefreshListener { swipeRefreshLayout.setOnRefreshListener { FeedUpdateManager.runOnceOrAsk(requireContext()) }
FeedUpdateManager.runOnceOrAsk(requireContext())
}
createListAdaptor() 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. // Apparently, none of the visibility check method works reliably on its own, so we just use all.
!userVisibleHint || !isVisible || !isMenuVisible -> return false !userVisibleHint || !isVisible || !isMenuVisible -> return false
listAdapter.longPressedItem == null -> { 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) return super.onContextItemSelected(item)
} }
listAdapter.onContextItemSelected(item) -> return true listAdapter.onContextItemSelected(item) -> return true
@ -328,7 +321,7 @@ import kotlinx.coroutines.flow.collectLatest
speedDialView.visibility = View.GONE speedDialView.visibility = View.GONE
} }
fun onEventMainThread(event: FlowEvent.EpisodeEvent) { private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}") // Logd(TAG, "onEventMainThread() called with ${event.TAG}")
for (item in event.episodes) { for (item in event.episodes) {
val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) 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}") // Logd(TAG, "onEventMainThread() called with ${event.TAG}")
if (currentPlaying != null && currentPlaying!!.isCurMedia) if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event)
currentPlaying!!.notifyPlaybackPositionUpdated(event)
else { else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list") Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until listAdapter.itemCount) { for (i in 0 until listAdapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder 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 currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event) holder.notifyPlaybackPositionUpdated(event)
break break
@ -361,7 +353,6 @@ import kotlinx.coroutines.flow.collectLatest
private fun onKeyUp(event: KeyEvent) { private fun onKeyUp(event: KeyEvent) {
if (!isAdded || !isVisible || !isMenuVisible) return if (!isAdded || !isVisible || !isMenuVisible) return
when (event.keyCode) { when (event.keyCode) {
KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0) KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0)
KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(listAdapter.itemCount) 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) { for (downloadUrl in event.urls) {
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl) val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl)
if (pos >= 0) listAdapter.notifyItemChangedCompat(pos) if (pos >= 0) listAdapter.notifyItemChangedCompat(pos)
@ -393,9 +384,9 @@ import kotlinx.coroutines.flow.collectLatest
Logd(TAG, "Received event: ${event.TAG}") Logd(TAG, "Received event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale()
is FlowEvent.FeedListUpdateEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> loadItems() is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> loadItems()
is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event) is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(event)
is FlowEvent.EpisodeEvent -> onEventMainThread(event) is FlowEvent.EpisodeEvent -> onEpisodeEvent(event)
else -> {} else -> {}
} }
} }
@ -404,8 +395,8 @@ import kotlinx.coroutines.flow.collectLatest
EventFlow.stickyEvents.collectLatest { event -> EventFlow.stickyEvents.collectLatest { event ->
Logd(TAG, "Received sticky event: ${event.TAG}") Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.EpisodeDownloadEvent -> onEventMainThread(event) is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event)
is FlowEvent.FeedUpdateRunningEvent -> onEventMainThread(event) is FlowEvent.FeedUpdateRunningEvent -> onFeedUpdateRunningEvent(event)
else -> {} else -> {}
} }
} }
@ -456,15 +447,15 @@ import kotlinx.coroutines.flow.collectLatest
protected abstract fun loadTotalItemCount(): Int protected abstract fun loadTotalItemCount(): Int
protected abstract fun getFilter(): EpisodeFilter open fun getFilter(): EpisodeFilter {
return EpisodeFilter.unfiltered()
protected abstract fun getFragmentTag(): String }
protected abstract fun getPrefName(): String protected abstract fun getPrefName(): String
protected open fun updateToolbar() {} protected open fun updateToolbar() {}
fun onEventMainThread(event: FlowEvent.FeedUpdateRunningEvent) { private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) {
swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning
} }
@ -475,6 +466,6 @@ import kotlinx.coroutines.flow.collectLatest
companion object { companion object {
private const val KEY_UP_ARROW = "up_arrow" private const val KEY_UP_ARROW = "up_arrow"
const val EPISODES_PER_PAGE: Int = 150 const val EPISODES_PER_PAGE: Int = 50
} }
} }

View File

@ -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.getCurrentChapterIndex
import ac.mdiq.podcini.storage.utils.ChapterUtils.loadChapters import ac.mdiq.podcini.storage.utils.ChapterUtils.loadChapters
import ac.mdiq.podcini.playback.base.InTheatre.curMedia 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.playback.PlaybackController.Companion.seekTo
import ac.mdiq.podcini.storage.model.Chapter import ac.mdiq.podcini.storage.model.Chapter
import ac.mdiq.podcini.storage.utils.EmbeddedChapterImage import ac.mdiq.podcini.storage.utils.EmbeddedChapterImage
@ -159,6 +159,7 @@ class ChaptersFragment : AppCompatDialogFragment() {
} }
fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) {
if (event.media?.getIdentifier() != media?.getIdentifier()) return
updateChapterSelection(getCurrentChapter(media), false) updateChapterSelection(getCurrentChapter(media), false)
adapter.notifyTimeChanged(event.position.toLong()) adapter.notifyTimeChanged(event.position.toLong())
} }
@ -166,7 +167,7 @@ class ChaptersFragment : AppCompatDialogFragment() {
private fun getCurrentChapter(media: Playable?): Int { private fun getCurrentChapter(media: Playable?): Int {
if (controller == null) return -1 if (controller == null) return -1
return getCurrentChapterIndex(media, position) return getCurrentChapterIndex(media, curPosition)
} }
private fun loadMediaInfo(forceRefresh: Boolean) { private fun loadMediaInfo(forceRefresh: Boolean) {
@ -274,12 +275,6 @@ class ChaptersFragment : AppCompatDialogFragment() {
} else { } else {
if (media != null) { if (media != null) {
val imgUrl = EmbeddedChapterImage.getModelFor(media!!,position) 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) holder.image.load(imgUrl)
} }
} }

View File

@ -33,8 +33,6 @@ import ac.mdiq.podcini.util.event.FlowEvent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.* import android.view.*
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -63,13 +61,11 @@ import java.util.*
private var runningDownloads: Set<String> = HashSet() private var runningDownloads: Set<String> = HashSet()
private var items: MutableList<Episode> = mutableListOf() private var items: MutableList<Episode> = mutableListOf()
private lateinit var infoBar: TextView
private lateinit var adapter: DownloadsListAdapter private lateinit var adapter: DownloadsListAdapter
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var recyclerView: EpisodesRecyclerView private lateinit var recyclerView: EpisodesRecyclerView
private lateinit var swipeActions: SwipeActions private lateinit var swipeActions: SwipeActions
private lateinit var speedDialView: SpeedDialView private lateinit var speedDialView: SpeedDialView
private lateinit var progressBar: ProgressBar
private lateinit var emptyView: EmptyViewHandler private lateinit var emptyView: EmptyViewHandler
private var displayUpArrow = false private var displayUpArrow = false
@ -114,10 +110,7 @@ import java.util.*
val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator
if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false if (animator is SimpleItemAnimator) animator.supportsChangeAnimations = false
progressBar = binding.progLoading binding.progLoading.visibility = View.VISIBLE
progressBar.visibility = View.VISIBLE
infoBar = binding.infoBar
val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root) val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root)
speedDialView = multiSelectDial.fabSD speedDialView = multiSelectDial.fabSD
@ -243,7 +236,7 @@ import java.util.*
override fun onContextItemSelected(item: MenuItem): Boolean { override fun onContextItemSelected(item: MenuItem): Boolean {
val selectedItem: Episode? = adapter.longPressedItem val selectedItem: Episode? = adapter.longPressedItem
if (selectedItem == null) { 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) return super.onContextItemSelected(item)
} }
if (adapter.onContextItemSelected(item)) return true if (adapter.onContextItemSelected(item)) return true
@ -287,12 +280,12 @@ import java.util.*
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onPlaybackPositionEvent called with ${event.TAG}") // 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 { else {
Logd(TAG, "onPlaybackPositionEvent ${event.TAG} search list") Logd(TAG, "onPlaybackPositionEvent ${event.TAG} search list")
for (i in 0 until adapter.itemCount) { for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder 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 currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event) holder.notifyPlaybackPositionUpdated(event)
break break
@ -334,7 +327,7 @@ import java.util.*
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
items = result.toMutableList() items = result.toMutableList()
// adapter.setDummyViews(0) // adapter.setDummyViews(0)
progressBar.visibility = View.GONE binding.progLoading.visibility = View.GONE
adapter.updateItems(result) adapter.updateItems(result)
refreshInfoBar() refreshInfoBar()
} }
@ -362,12 +355,10 @@ import java.util.*
var info = String.format(Locale.getDefault(), "%d%s", items.size, getString(R.string.episodes_suffix)) var info = String.format(Locale.getDefault(), "%d%s", items.size, getString(R.string.episodes_suffix))
if (items.isNotEmpty()) { if (items.isNotEmpty()) {
var sizeMB: Long = 0 var sizeMB: Long = 0
for (item in items) { for (item in items) sizeMB += item.media?.size ?: 0
sizeMB += item.media?.size?:0
}
info += "" + (sizeMB / 1000000) + " MB" info += "" + (sizeMB / 1000000) + " MB"
} }
infoBar.text = info binding.infoBar.text = info
} }
override fun onStartSelectMode() { override fun onStartSelectMode() {

View File

@ -35,8 +35,6 @@ class EpisodeHomeFragment : Fragment() {
private var _binding: EpisodeHomeFragmentBinding? = null private var _binding: EpisodeHomeFragmentBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
// private val ioScope = CoroutineScope(Dispatchers.IO) // IO dispatcher for initialization
private var startIndex = 0 private var startIndex = 0
private var ttsSpeed = 1.0f private var ttsSpeed = 1.0f
@ -181,6 +179,9 @@ class EpisodeHomeFragment : Fragment() {
} }
menu.findItem(R.id.share_notes)?.setVisible(readMode) menu.findItem(R.id.share_notes)?.setVisible(readMode)
menu.findItem(R.id.switchJS)?.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) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@ -191,12 +192,10 @@ class EpisodeHomeFragment : Fragment() {
@OptIn(UnstableApi::class) override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @OptIn(UnstableApi::class) override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) { when (menuItem.itemId) {
R.id.switch_home -> { R.id.switch_home -> {
Logd(TAG, "switch_home selected")
switchMode() switchMode()
return true return true
} }
R.id.switchJS -> { R.id.switchJS -> {
Logd(TAG, "switchJS selected")
jsEnabled = !jsEnabled jsEnabled = !jsEnabled
showWebContent() showWebContent()
return true return true
@ -287,11 +286,7 @@ class EpisodeHomeFragment : Fragment() {
fun newInstance(item: Episode): EpisodeHomeFragment { fun newInstance(item: Episode): EpisodeHomeFragment {
val fragment = EpisodeHomeFragment() val fragment = EpisodeHomeFragment()
Logd(TAG, "item.itemIdentifier ${item.identifier}") Logd(TAG, "item.itemIdentifier ${item.identifier}")
if (item.identifier != currentItem?.identifier) { if (item.identifier != currentItem?.identifier) currentItem = item
currentItem = item
} else {
// currentItem?.feed = item.feed
}
return fragment return fragment
} }
} }

View File

@ -11,6 +11,7 @@ import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo
import ac.mdiq.podcini.playback.base.InTheatre import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.preferences.UsageStatistics import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UserPreferences 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.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeMedia
@ -79,20 +80,11 @@ import kotlin.math.max
private lateinit var shownotesCleaner: ShownotesCleaner private lateinit var shownotesCleaner: ShownotesCleaner
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var root: ViewGroup
private lateinit var webvDescription: ShownotesWebView 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 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 butAction1: ImageView
private lateinit var butAction2: ImageView private lateinit var butAction2: ImageView
private lateinit var noMediaLabel: View
private var actionButton1: EpisodeActionButton? = null private var actionButton1: EpisodeActionButton? = null
private var actionButton2: EpisodeActionButton? = null private var actionButton2: EpisodeActionButton? = null
@ -101,7 +93,7 @@ import kotlin.math.max
super.onCreateView(inflater, container, savedInstanceState) super.onCreateView(inflater, container, savedInstanceState)
_binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false) _binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false)
root = binding.root // root = binding.root
Logd(TAG, "fragment onCreateView") Logd(TAG, "fragment onCreateView")
toolbar = binding.toolbar toolbar = binding.toolbar
@ -110,13 +102,9 @@ import kotlin.math.max
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.setOnMenuItemClickListener(this) toolbar.setOnMenuItemClickListener(this)
txtvPodcast = binding.txtvPodcast binding.txtvPodcast.setOnClickListener { openPodcast() }
txtvPodcast.setOnClickListener { openPodcast() } binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL)
txtvTitle = binding.txtvTitle binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END
txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL)
txtvDuration = binding.txtvDuration
txtvPublished = binding.txtvPublished
txtvTitle.ellipsize = TextUtils.TruncateAt.END
webvDescription = binding.webvDescription webvDescription = binding.webvDescription
webvDescription.setTimecodeSelectedListener { time: Int? -> webvDescription.setTimecodeSelectedListener { time: Int? ->
val cMedia = curMedia val cMedia = curMedia
@ -127,14 +115,10 @@ import kotlin.math.max
imgvCover = binding.imgvCover imgvCover = binding.imgvCover
imgvCover.setOnClickListener { openPodcast() } imgvCover.setOnClickListener { openPodcast() }
progbarDownload = binding.circularProgressBar
progbarLoading = binding.progbarLoading
homeButtonAction = binding.homeButton
butAction1 = binding.butAction1 butAction1 = binding.butAction1
butAction2 = binding.butAction2 butAction2 = binding.butAction2
noMediaLabel = binding.noMediaLabel
homeButtonAction.setOnClickListener { binding.homeButton.setOnClickListener {
if (!item?.link.isNullOrEmpty()) { if (!item?.link.isNullOrEmpty()) {
homeFragment = EpisodeHomeFragment.newInstance(item!!) homeFragment = EpisodeHomeFragment.newInstance(item!!)
(activity as MainActivity).loadChildFragment(homeFragment!!) (activity as MainActivity).loadChildFragment(homeFragment!!)
@ -242,7 +226,7 @@ import kotlin.math.max
@UnstableApi override fun onResume() { @UnstableApi override fun onResume() {
super.onResume() super.onResume()
if (itemLoaded) { if (itemLoaded) {
progbarLoading.visibility = View.GONE binding.progbarLoading.visibility = View.GONE
updateAppearance() updateAppearance()
} }
} }
@ -250,13 +234,14 @@ import kotlin.math.max
@OptIn(UnstableApi::class) override fun onDestroyView() { @OptIn(UnstableApi::class) override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
_binding = null
binding.root.removeView(webvDescription)
root.removeView(webvDescription)
webvDescription.clearHistory() webvDescription.clearHistory()
webvDescription.clearCache(true) webvDescription.clearCache(true)
webvDescription.clearView() webvDescription.clearView()
webvDescription.destroy() webvDescription.destroy()
_binding = null
} }
@UnstableApi private fun onFragmentLoaded() { @UnstableApi private fun onFragmentLoaded() {
@ -276,14 +261,14 @@ import kotlin.math.max
// these are already available via button1 and button2 // 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) 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 if (item!!.feed != null) binding.txtvPodcast.text = item!!.feed!!.title
txtvTitle.text = item!!.title binding.txtvTitle.text = item!!.title
binding.itemLink.text = item!!.link binding.itemLink.text = item!!.link
if (item?.pubDate != null) { if (item?.pubDate != null) {
val pubDateStr = DateFormatter.formatAbbrev(context, Date(item!!.pubDate)) val pubDateStr = DateFormatter.formatAbbrev(context, Date(item!!.pubDate))
txtvPublished.text = pubDateStr binding.txtvPublished.text = pubDateStr
txtvPublished.setContentDescription(DateFormatter.formatForAccessibility(Date(item!!.pubDate))) binding.txtvPublished.setContentDescription(DateFormatter.formatForAccessibility(Date(item!!.pubDate)))
} }
val media = item?.media val media = item?.media
@ -326,14 +311,14 @@ import kotlin.math.max
} }
@UnstableApi private fun updateButtons() { @UnstableApi private fun updateButtons() {
progbarDownload.visibility = View.GONE binding.circularProgressBar.visibility = View.GONE
val dls = DownloadServiceInterface.get() val dls = DownloadServiceInterface.get()
if (item != null && item!!.media != null && item!!.media!!.downloadUrl != null) { if (item != null && item!!.media != null && item!!.media!!.downloadUrl != null) {
val url = item!!.media!!.downloadUrl!! val url = item!!.media!!.downloadUrl!!
if (dls != null && dls.isDownloadingEpisode(url)) { if (dls != null && dls.isDownloadingEpisode(url)) {
progbarDownload.visibility = View.VISIBLE binding.circularProgressBar.visibility = View.VISIBLE
progbarDownload.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), item) binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), item)
progbarDownload.setIndeterminate(dls.isEpisodeQueued(url)) binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url))
} }
} }
@ -344,12 +329,12 @@ import kotlin.math.max
butAction1.visibility = View.INVISIBLE butAction1.visibility = View.INVISIBLE
actionButton2 = VisitWebsiteActionButton(item!!) actionButton2 = VisitWebsiteActionButton(item!!)
} }
noMediaLabel.visibility = View.VISIBLE binding.noMediaLabel.visibility = View.VISIBLE
} else { } else {
noMediaLabel.visibility = View.GONE binding.noMediaLabel.visibility = View.GONE
if (media.getDuration() > 0) { if (media.getDuration() > 0) {
txtvDuration.text = Converter.getDurationStringLong(media.getDuration()) binding.txtvDuration.text = Converter.getDurationStringLong(media.getDuration())
txtvDuration.setContentDescription(Converter.getDurationStringLocalized(requireContext(), media.getDuration().toLong())) binding.txtvDuration.setContentDescription(Converter.getDurationStringLocalized(requireContext(), media.getDuration().toLong()))
} }
if (item != null) { if (item != null) {
actionButton1 = when { actionButton1 = when {
@ -439,7 +424,7 @@ import kotlin.math.max
} }
@UnstableApi private fun load() { @UnstableApi private fun load() {
if (!itemLoaded) progbarLoading.visibility = View.VISIBLE if (!itemLoaded) binding.progbarLoading.visibility = View.VISIBLE
Logd(TAG, "load() called") Logd(TAG, "load() called")
lifecycleScope.launch { lifecycleScope.launch {
@ -453,7 +438,7 @@ import kotlin.math.max
feedItem feedItem
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
progbarLoading.visibility = View.GONE binding.progbarLoading.visibility = View.GONE
item = result item = result
onFragmentLoaded() onFragmentLoaded()
itemLoaded = true itemLoaded = true
@ -465,7 +450,7 @@ import kotlin.math.max
} }
fun setItem(item_: Episode) { fun setItem(item_: Episode) {
item = item_ item = unmanagedCopy(item_)
} }
companion object { companion object {

View File

@ -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.LogsAndStats.getFeedDownloadLog
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope 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.DownloadResult
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
@ -89,6 +90,7 @@ import java.util.concurrent.Semaphore
private var enableFilter: Boolean = true private var enableFilter: Boolean = true
private val ioScope = CoroutineScope(Dispatchers.IO) private val ioScope = CoroutineScope(Dispatchers.IO)
private var onInit: Boolean = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -194,7 +196,6 @@ import java.util.concurrent.Semaphore
super.onStart() super.onStart()
procFlowEvents() procFlowEvents()
loadItems() loadItems()
// realmFeedMonitor()
} }
override fun onStop() { override fun onStop() {
@ -269,11 +270,14 @@ import java.util.concurrent.Semaphore
R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext(), feed) R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext(), feed)
R.id.refresh_complete_item -> { R.id.refresh_complete_item -> {
Thread { Thread {
feed!!.nextPageLink = feed!!.downloadUrl
feed!!.pageNr = 0
try { try {
runBlocking { resetPagedFeedPage(feed).join() } if (feed != null) {
FeedUpdateManager.runOnce(requireContext(), feed) val feed_ = unmanagedCopy(feed!!)
feed_.nextPageLink = feed_.downloadUrl
feed_.pageNr = 0
upsertBlk(feed_) {}
FeedUpdateManager.runOnce(requireContext(), feed_)
}
} catch (e: ExecutionException) { } catch (e: ExecutionException) {
throw RuntimeException(e) throw RuntimeException(e)
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
@ -302,19 +306,10 @@ import java.util.concurrent.Semaphore
return true 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 { override fun onContextItemSelected(item: MenuItem): Boolean {
val selectedItem: Episode? = adapter.longPressedItem val selectedItem: Episode? = adapter.longPressedItem
if (selectedItem == null) { 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) return super.onContextItemSelected(item)
} }
if (adapter.onContextItemSelected(item)) return true 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) { private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) {
// Logd(TAG, "onEpisodeEvent() called with ${event.TAG}") // Logd(TAG, "onEpisodeEvent() called with ${event.TAG}")
if (feed == null || episodes.isEmpty()) return if (feed == null || episodes.isEmpty()) return
@ -367,19 +335,8 @@ import java.util.concurrent.Semaphore
while (i < size) { while (i < size) {
val item = event.episodes[i] val item = event.episodes[i]
if (item.feedId != feed!!.id) continue if (item.feedId != feed!!.id) continue
// Unmanaged embedded objects don't support parent access
// Logd(TAG, "item.media.parent: ${item.media?.parent<Episode>()?.title}")
val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id) val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) { 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.removeAt(pos)
episodes.add(pos, item) episodes.add(pos, item)
adapter.notifyItemChangedCompat(pos) adapter.notifyItemChangedCompat(pos)
@ -449,12 +406,12 @@ import java.util.concurrent.Semaphore
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}") // 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 { else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list") Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until adapter.itemCount) { for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = binding.recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder 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 currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event) holder.notifyPlaybackPositionUpdated(event)
break break
@ -483,12 +440,11 @@ import java.util.concurrent.Semaphore
is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) is FlowEvent.FavoritesEvent -> onFavoriteEvent(event)
is FlowEvent.PlayEvent -> onEvenStartPlay(event) is FlowEvent.PlayEvent -> onEvenStartPlay(event)
is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(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.EpisodeEvent -> onEpisodeEvent(event)
is FlowEvent.EpisodesFilterOrSortEvent -> onEpisodesFilterSortEvent(event)
is FlowEvent.PlayerSettingsEvent -> loadItems() is FlowEvent.PlayerSettingsEvent -> loadItems()
is FlowEvent.EpisodePlayedEvent -> onEpisodePlayedEvent(event) is FlowEvent.EpisodePlayedEvent -> onEpisodePlayedEvent(event)
is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event) is FlowEvent.FeedListEvent -> if (feed != null && event.contains(feed!!)) loadItems()
is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale()
else -> {} 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() { override fun onStartSelectMode() {
swipeActions.detach() swipeActions.detach()
if (feed != null && feed!!.isLocalFeed) dialBinding.fabSD.removeActionItemById(R.id.download_batch) 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) 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) { private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) {
nextPageLoader.setLoadingState(event.isFeedUpdateRunning) nextPageLoader.setLoadingState(event.isFeedUpdateRunning)
if (!event.isFeedUpdateRunning) nextPageLoader.root.visibility = View.GONE if (!event.isFeedUpdateRunning) nextPageLoader.root.visibility = View.GONE
@ -612,13 +551,22 @@ import java.util.concurrent.Semaphore
binding.header.butFilter.setOnLongClickListener { binding.header.butFilter.setOnLongClickListener {
if (feed != null) { if (feed != null) {
enableFilter = !enableFilter enableFilter = !enableFilter
if (enableFilter) binding.header.butFilter.setColorFilter(Color.WHITE) episodes.clear()
else binding.header.butFilter.setColorFilter(Color.RED) if (enableFilter) {
onEpisodesFilterSortEvent(FlowEvent.EpisodesFilterOrSortEvent(FlowEvent.EpisodesFilterOrSortEvent.Action.FILTER_CHANGED, feed!!)) 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 true
} }
binding.header.txtvFailure.setOnClickListener { showErrorDetails() } binding.header.txtvFailure.setOnClickListener { showErrorDetails() }
binding.header.counts.text = adapter.itemCount.toString() binding.header.counts.text = adapter.itemCount.toString()
headerCreated = true headerCreated = true
@ -659,31 +607,34 @@ import java.util.concurrent.Semaphore
lifecycleScope.launch { lifecycleScope.launch {
try { try {
feed = withContext(Dispatchers.IO) { feed = withContext(Dispatchers.IO) {
val feed_ = getFeed(feedID, true) val feed_ = getFeed(feedID)
if (feed_ != null) { if (feed_ != null) {
episodes.clear() episodes.clear()
if (!feed_.preferences?.filterString.isNullOrEmpty()) { if (enableFilter && !feed_.preferences?.filterString.isNullOrEmpty()) {
val episodes_ = feed_.episodes.filter { feed_.episodeFilter.matches(it) } val episodes_ = feed_.episodes.filter { feed_.episodeFilter.matches(it) }
episodes.addAll(episodes_) episodes.addAll(episodes_)
} else episodes.addAll(feed_.episodes) } else episodes.addAll(feed_.episodes)
val sortOrder = fromCode(feed_.preferences?.sortOrderCode?:0) val sortOrder = fromCode(feed_.preferences?.sortOrderCode?:0)
if (sortOrder != null) getPermutor(sortOrder).reorder(episodes) if (sortOrder != null) getPermutor(sortOrder).reorder(episodes)
var hasNonMediaItems = false if (onInit) {
for (item in episodes) { var hasNonMediaItems = false
// TODO: perhaps shouldn't set for all items, do it in the adaptor? for (item in episodes) {
item.feed = feed_ if (item.media == null) {
if (item.media == null) hasNonMediaItems = true hasNonMediaItems = true
// Logd(TAG, "loadItems ${item.media?.downloaded} ${item.title}") break
} }
if (hasNonMediaItems) { }
ioScope.launch { if (hasNonMediaItems) {
withContext(Dispatchers.IO) { ioScope.launch {
if (!ttsReady) { withContext(Dispatchers.IO) {
initializeTTS(requireContext()) if (!ttsReady) {
semaphore.acquire() initializeTTS(requireContext())
semaphore.acquire()
}
} }
} }
} }
onInit = false
} }
} }
feed_ feed_
@ -695,7 +646,7 @@ import java.util.concurrent.Semaphore
binding.progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
adapter.setDummyViews(0) adapter.setDummyViews(0)
if (feed != null && episodes.isNotEmpty()) { if (feed != null && episodes.isNotEmpty()) {
adapter.updateItems(episodes) adapter.updateItems(episodes, feed)
binding.header.counts.text = episodes.size.toString() binding.header.counts.text = episodes.size.toString()
} }
updateToolbar() updateToolbar()
@ -737,14 +688,14 @@ import java.util.concurrent.Semaphore
if (feed != null) { if (feed != null) {
Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]") Logd(TAG, "persist Episode Filter(): feedId = [$feed.id], filterValues = [$newFilterValues]")
runOnIOScope { runOnIOScope {
feed.preferences?.filterString = newFilterValues.joinToString()
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) { if (feed_ != null) {
realm.write { 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 sortOrder = if (feed?.sortOrder == null) SortOrder.DATE_NEW_OLD
else feed.sortOrder else feed.sortOrder
} }
override fun onAddItem(title: Int, ascending: SortOrder, descending: SortOrder, ascendingIsDefault: Boolean) { 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 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 || ascending == SortOrder.DURATION_SHORT_LONG || ascending == SortOrder.RANDOM
@ -765,24 +715,19 @@ import java.util.concurrent.Semaphore
super.onAddItem(title, ascending, descending, ascendingIsDefault) super.onAddItem(title, ascending, descending, ascendingIsDefault)
} }
} }
@UnstableApi override fun onSelectionChanged() { @UnstableApi override fun onSelectionChanged() {
super.onSelectionChanged() super.onSelectionChanged()
if (feed != null) { 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") Logd(TAG, "persist Episode SortOrder")
runOnIOScope { runOnIOScope {
feed.sortOrder = sortOrder
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find() val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) { if (feed_ != null) {
realm.write { 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))
} }
} }
} }

View File

@ -3,8 +3,8 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.databinding.FeedinfoBinding 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.FeedUpdateManager.runOnce
import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher
import ac.mdiq.podcini.net.utils.HtmlToPlainText import ac.mdiq.podcini.net.utils.HtmlToPlainText
import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.database.Feeds.updateFeedDownloadURL import ac.mdiq.podcini.storage.database.Feeds.updateFeedDownloadURL
@ -32,7 +32,6 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
@ -66,15 +65,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private lateinit var feed: Feed private lateinit var feed: Feed
private lateinit var imgvCover: ImageView 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 imgvBackground: ImageView
private lateinit var infoContainer: View
private lateinit var header: View
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) { private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) {
@ -115,18 +106,10 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
appBar.addOnOffsetChangedListener(iconTintManager) appBar.addOnOffsetChangedListener(iconTintManager)
imgvCover = binding.header.imgvCover imgvCover = binding.header.imgvCover
txtvTitle = binding.header.txtvTitle
txtvAuthorHeader = binding.header.txtvAuthor
imgvBackground = binding.imgvBackground imgvBackground = binding.imgvBackground
infoContainer = binding.infoContainer
header = binding.header.root
// https://github.com/bumptech/glide/issues/529 // https://github.com/bumptech/glide/issues/529
// imgvBackground.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000) // 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.text = feed.episodes.size.toString() + " episodes"
binding.header.episodes.setOnClickListener { binding.header.episodes.setOnClickListener {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id) val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
@ -134,13 +117,12 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
} }
binding.btnvRelatedFeeds.setOnClickListener { 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) (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 val feedId = feed.id
parentFragmentManager.beginTransaction().replace(R.id.statisticsFragmentContainer, parentFragmentManager.beginTransaction().replace(R.id.statisticsFragmentContainer,
FeedStatisticsFragment.newInstance(feedId, false), "feed_statistics_fragment") FeedStatisticsFragment.newInstance(feedId, false), "feed_statistics_fragment")
@ -158,8 +140,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
val horizontalSpacing = resources.getDimension(R.dimen.additional_horizontal_spacing).toInt() val horizontalSpacing = resources.getDimension(R.dimen.additional_horizontal_spacing).toInt()
header.setPadding(horizontalSpacing, header.paddingTop, horizontalSpacing, header.paddingBottom) binding.header.root.setPadding(horizontalSpacing, binding.header.root.paddingTop, horizontalSpacing, binding.header.root.paddingBottom)
infoContainer.setPadding(horizontalSpacing, infoContainer.paddingTop, horizontalSpacing, infoContainer.paddingBottom) binding.infoContainer.setPadding(horizontalSpacing, binding.infoContainer.paddingTop, horizontalSpacing, binding.infoContainer.paddingBottom)
} }
private fun showFeed() { private fun showFeed() {
@ -173,22 +155,22 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
error(R.mipmap.ic_launcher) error(R.mipmap.ic_launcher)
} }
txtvTitle.text = feed.title binding.header.txtvTitle.text = feed.title
txtvTitle.setMaxLines(6) binding.header.txtvTitle.setMaxLines(6)
val description: String = HtmlToPlainText.getPlainText(feed.description?:"") 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 binding.txtvUrl.text = feed.downloadUrl
txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0) binding.txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0)
if (feed.paymentLinks.isEmpty()) { if (feed.paymentLinks.isEmpty()) {
lblSupport.visibility = View.GONE binding.lblSupport.visibility = View.GONE
txtvFundingUrl.visibility = View.GONE binding.txtvFundingUrl.visibility = View.GONE
} else { } else {
lblSupport.visibility = View.VISIBLE binding.lblSupport.visibility = View.VISIBLE
val fundingList: ArrayList<FeedFunding> = feed.paymentLinks val fundingList: ArrayList<FeedFunding> = feed.paymentLinks
// Filter for duplicates, but keep items in the order that they have in the feed. // 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.append("\n")
} }
str = StringBuilder(StringUtils.trim(str.toString())) str = StringBuilder(StringUtils.trim(str.toString()))
txtvFundingUrl.text = str.toString() binding.txtvFundingUrl.text = str.toString()
} }
refreshToolbarState() refreshToolbarState()
} }
@ -260,8 +242,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
object : EditUrlSettingsDialog(activity as Activity, feed) { object : EditUrlSettingsDialog(activity as Activity, feed) {
override fun setUrl(url: String?) { override fun setUrl(url: String?) {
feed.downloadUrl = url feed.downloadUrl = url
txtvUrl.text = feed.downloadUrl binding.txtvUrl.text = feed.downloadUrl
txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0) binding.txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0)
} }
}.show() }.show()
} }

View File

@ -275,8 +275,8 @@ class FeedSettingsFragment : Fragment() {
// EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feedPrefs!!.feedID)) // EventFlow.postEvent(FlowEvent.FeedListUpdateEvent(feedPrefs!!.feedID))
} }
updateVolumeAdaptationValue() updateVolumeAdaptationValue()
if (feed != null && feedPrefs!!.volumeAdaptionSetting != null) // if (feed != null && feedPrefs!!.volumeAdaptionSetting != null)
EventFlow.postEvent(FlowEvent.VolumeAdaptionChangedEvent(feedPrefs!!.volumeAdaptionSetting!!, feed!!.id)) // EventFlow.postEvent(FlowEvent.VolumeAdaptionChangedEvent(feedPrefs!!.volumeAdaptionSetting!!, feed!!.id))
false false
} }
} }
@ -297,32 +297,6 @@ class FeedSettingsFragment : Fragment() {
} }
} }
// @OptIn(UnstableApi::class) private fun setupNewEpisodesAction() {
// if (feedPreferences == null) return
//
// findPreference<Preference>(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<ListPreference>(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() { @OptIn(UnstableApi::class) private fun setupKeepUpdatedPreference() {
if (feedPrefs == null) return if (feedPrefs == null) return
val pref = findPreference<SwitchPreferenceCompat>("keepUpdated") val pref = findPreference<SwitchPreferenceCompat>("keepUpdated")

View File

@ -37,9 +37,7 @@ import kotlin.math.min
private var startDate : Long = 0L private var startDate : Long = 0L
private var endDate : Long = Date().time private var endDate : Long = Date().time
override fun getFragmentTag(): String { var allHistory: List<Episode> = listOf()
return TAG
}
override fun getPrefName(): String { override fun getPrefName(): String {
return TAG return TAG
@ -89,10 +87,6 @@ import kotlin.math.min
cancelFlowEvents() cancelFlowEvents()
} }
override fun getFilter(): EpisodeFilter {
return EpisodeFilter.unfiltered()
}
@OptIn(UnstableApi::class) override fun onMenuItemClick(item: MenuItem): Boolean { @OptIn(UnstableApi::class) override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true if (super.onOptionsItemSelected(item)) return true
when (item.itemId) { when (item.itemId) {
@ -157,15 +151,15 @@ import kotlin.math.min
} }
override fun loadData(): List<Episode> { override fun loadData(): List<Episode> {
val hList = getHistory(0, page * EPISODES_PER_PAGE, startDate, endDate, sortOrder).toMutableList() allHistory = getHistory(0, Int.MAX_VALUE, startDate, endDate, sortOrder).toMutableList()
// FeedItemPermutors.getPermutor(sortOrder).reorder(hList) return allHistory.subList(0, min(allHistory.size-1, page * EPISODES_PER_PAGE))
return hList
} }
override fun loadMoreData(page: Int): List<Episode> { override fun loadMoreData(page: Int): List<Episode> {
val hList = getHistory((page - 1) * EPISODES_PER_PAGE, EPISODES_PER_PAGE, startDate, endDate, sortOrder).toMutableList() val offset = (page - 1) * EPISODES_PER_PAGE
// FeedItemPermutors.getPermutor(sortOrder).reorder(hList) if (offset >= allHistory.size) return listOf()
return hList val toIndex = offset + EPISODES_PER_PAGE
return allHistory.subList(offset, min(allHistory.size, toIndex))
} }
override fun loadTotalItemCount(): Int { 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, fun getHistory(offset: Int, limit: Int, start: Long = 0L, end: Long = Date().time,
sortOrder: SortOrder = SortOrder.PLAYED_DATE_NEW_OLD): List<Episode> { sortOrder: SortOrder = SortOrder.PLAYED_DATE_NEW_OLD): List<Episode> {
Logd(TAG, "getHistory() called") 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<Episode> = mutableListOf() var episodes: MutableList<Episode> = mutableListOf()
for (m in medias) { for (m in medias) {
if (m.episode != null) episodes.add(m.episode!!) if (m.episode != null) episodes.add(m.episode!!)

View File

@ -9,7 +9,7 @@ import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize
import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems 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.Episodes.getEpisodesCount
import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.model.DatasetStats import ac.mdiq.podcini.storage.model.DatasetStats
@ -413,7 +413,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
// queueSize = queue?.episodeIds?.size ?: 0 // queueSize = queue?.episodeIds?.size ?: 0
// } // }
Logd(TAG, "getDatasetStats: queueSize: $queueSize") Logd(TAG, "getDatasetStats: queueSize: $queueSize")
return DatasetStats(queueSize, numDownloadedItems, EpisodeCleanupAlgorithmFactory.build().getReclaimableItems(), numItems, numFeeds) return DatasetStats(queueSize, numDownloadedItems, AutoCleanups.build().getReclaimableItems(), numItems, numFeeds)
} }
} }
} }

View File

@ -96,10 +96,6 @@ import kotlin.concurrent.Volatile
private var dialog: Dialog? = null 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 { @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater) _binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater)
binding.closeButton.visibility = View.INVISIBLE binding.closeButton.visibility = View.INVISIBLE
@ -170,9 +166,6 @@ import kotlin.concurrent.Volatile
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
_binding = null _binding = null
// updater?.dispose()
// download?.dispose()
// parser?.dispose()
} }
@OptIn(UnstableApi::class) override fun onSaveInstanceState(outState: Bundle) { @OptIn(UnstableApi::class) override fun onSaveInstanceState(outState: Bundle) {
@ -332,7 +325,7 @@ import kotlin.concurrent.Volatile
EventFlow.events.collectLatest { event -> EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}") Logd(TAG, "Received event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event) is FlowEvent.FeedListEvent -> onFeedListChanged(event)
else -> {} else -> {}
} }
} }
@ -348,7 +341,7 @@ import kotlin.concurrent.Volatile
} }
} }
private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent) { private fun onFeedListChanged(event: FlowEvent.FeedListEvent) {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val feeds = withContext(Dispatchers.IO) { val feeds = withContext(Dispatchers.IO) {

View File

@ -56,7 +56,7 @@ class OnlineSearchFragment : Fragment() {
break 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 { @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {

View File

@ -7,7 +7,7 @@ import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource
import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier
import ac.mdiq.podcini.playback.base.InTheatre.curMedia 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.playback.PlaybackController.Companion.seekTo
import ac.mdiq.podcini.storage.database.Episodes.persistEpisode import ac.mdiq.podcini.storage.database.Episodes.persistEpisode
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope 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. * Displays the description of a Playable object in a Webview.
*/ */
@UnstableApi @UnstableApi
class PlayerDetailsFragment : Fragment() { class PlayerDetailsFragment : Fragment() {
private lateinit var shownoteView: ShownotesWebView private lateinit var shownoteView: ShownotesWebView
private var shownotesCleaner: ShownotesCleaner? = null private var shownotesCleaner: ShownotesCleaner? = null
@ -67,8 +67,8 @@ class PlayerDetailsFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private var prevItem: Episode? = null private var prevItem: Episode? = null
private var media: Playable? = null private var playable: Playable? = null
private var item: Episode? = null private var currentItem: Episode? = null
private var displayedChapterIndex = -1 private var displayedChapterIndex = -1
private var cleanedNotes: String? = null private var cleanedNotes: String? = null
@ -80,8 +80,8 @@ class PlayerDetailsFragment : Fragment() {
private val currentChapter: Chapter? private val currentChapter: Chapter?
get() { get() {
if (media == null || media!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null
return media!!.getChapters()[displayedChapterIndex] return playable!!.getChapters()[displayedChapterIndex]
} }
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -115,11 +115,13 @@ class PlayerDetailsFragment : Fragment() {
} }
override fun onStart() { override fun onStart() {
Logd(TAG, "onStart()")
super.onStart() super.onStart()
procFlowEvents() procFlowEvents()
} }
override fun onStop() { override fun onStop() {
Logd(TAG, "onStop()")
super.onStop() super.onStop()
cancelFlowEvents() cancelFlowEvents()
} }
@ -136,34 +138,34 @@ class PlayerDetailsFragment : Fragment() {
return shownoteView.onContextItemSelected(item) return shownoteView.onContextItemSelected(item)
} }
internal fun load() { internal fun updateInfo() {
// if (isLoading) return // if (isLoading) return
lifecycleScope.launch { lifecycleScope.launch {
Logd(TAG, "in load()") Logd(TAG, "in updateInfo")
isLoading = true isLoading = true
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (item == null) { if (currentItem == null) {
media = curMedia playable = curMedia
if (media != null && media is EpisodeMedia) { if (playable != null && playable is EpisodeMedia) {
val episodeMedia = media as EpisodeMedia val episodeMedia = playable as EpisodeMedia
item = episodeMedia.episode currentItem = episodeMedia.episode
showHomeText = false showHomeText = false
homeText = null homeText = null
} }
} }
if (item != null) { if (currentItem != null) {
media = item!!.media playable = currentItem!!.media
if (prevItem?.identifier != item!!.identifier) cleanedNotes = null if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null
if (cleanedNotes == null) { if (cleanedNotes == null) {
Logd(TAG, "calling load description ${item!!.description==null} ${item!!.title}") Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}")
cleanedNotes = shownotesCleaner?.processShownotes(item?.description ?: "", media?.getDuration()?:0) cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration()?:0)
} }
prevItem = item prevItem = currentItem
} }
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Logd(TAG, "subscribe: ${media?.getEpisodeTitle()}") Logd(TAG, "subscribe: ${playable?.getEpisodeTitle()}")
displayMediaInfo(media!!) displayMediaInfo(playable!!)
shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank")
Logd(TAG, "Webview loaded") Logd(TAG, "Webview loaded")
} }
@ -177,17 +179,17 @@ class PlayerDetailsFragment : Fragment() {
showHomeText = !showHomeText showHomeText = !showHomeText
runOnIOScope { runOnIOScope {
if (showHomeText) { if (showHomeText) {
homeText = item!!.transcript homeText = currentItem!!.transcript
if (homeText == null && item?.link != null) { if (homeText == null && currentItem?.link != null) {
val url = item!!.link!! val url = currentItem!!.link!!
val htmlSource = fetchHtmlSource(url) val htmlSource = fetchHtmlSource(url)
val readability4J = Readability4J(item!!.link!!, htmlSource) val readability4J = Readability4J(currentItem!!.link!!, htmlSource)
val article = readability4J.parse() val article = readability4J.parse()
readerhtml = article.contentWithDocumentsCharsetOrUtf8 readerhtml = article.contentWithDocumentsCharsetOrUtf8
if (!readerhtml.isNullOrEmpty()) { if (!readerhtml.isNullOrEmpty()) {
item!!.setTranscriptIfLonger(readerhtml) currentItem!!.setTranscriptIfLonger(readerhtml)
homeText = item!!.transcript homeText = currentItem!!.transcript
persistEpisode(item) persistEpisode(currentItem)
} }
} }
if (!homeText.isNullOrEmpty()) { 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 withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() }
} else { } else {
// val shownotesCleaner = ShownotesCleaner(requireContext()) // val shownotesCleaner = ShownotesCleaner(requireContext())
cleanedNotes = shownotesCleaner?.processShownotes(item?.description ?: "", media?.getDuration() ?: 0) cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration() ?: 0)
if (!cleanedNotes.isNullOrEmpty()) { if (!cleanedNotes.isNullOrEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
shownoteView.loadDataWithBaseURL("https://127.0.0.1", shownoteView.loadDataWithBaseURL("https://127.0.0.1",
@ -218,12 +220,12 @@ class PlayerDetailsFragment : Fragment() {
} }
@UnstableApi private fun displayMediaInfo(media: Playable) { @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()) val pubDateStr = DateFormatter.formatAbbrev(context, media.getPubDate())
binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle()) binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle())
if (media is EpisodeMedia) { if (media is EpisodeMedia) {
if (item?.feedId != null) { if (currentItem?.feedId != null) {
val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), item!!.feedId!!) val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!)
binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) }
} }
} else { } else {
@ -231,8 +233,8 @@ class PlayerDetailsFragment : Fragment() {
} }
binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) } binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) }
binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr) binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr)
binding.txtvEpisodeTitle.text = item?.title binding.txtvEpisodeTitle.text = currentItem?.title
binding.txtvEpisodeTitle.setOnLongClickListener { copyText(item?.title?:"") } binding.txtvEpisodeTitle.setOnLongClickListener { copyText(currentItem?.title?:"") }
binding.txtvEpisodeTitle.setOnClickListener { binding.txtvEpisodeTitle.setOnClickListener {
val lines = binding.txtvEpisodeTitle.lineCount val lines = binding.txtvEpisodeTitle.lineCount
val animUnit = 1500 val animUnit = 1500
@ -262,9 +264,9 @@ class PlayerDetailsFragment : Fragment() {
private fun updateChapterControlVisibility() { private fun updateChapterControlVisibility() {
var chapterControlVisible = false var chapterControlVisible = false
when { when {
media?.getChapters() != null -> chapterControlVisible = media!!.getChapters().isNotEmpty() playable?.getChapters() != null -> chapterControlVisible = playable!!.getChapters().isNotEmpty()
media is EpisodeMedia -> { playable is EpisodeMedia -> {
val fm: EpisodeMedia? = (media as EpisodeMedia?) val fm: EpisodeMedia? = (playable as EpisodeMedia?)
// If an item has chapters but they are not loaded yet, still display the button. // If an item has chapters but they are not loaded yet, still display the button.
chapterControlVisible = fm?.episode != null && fm.episode!!.chapters.isNotEmpty() chapterControlVisible = fm?.episode != null && fm.episode!!.chapters.isNotEmpty()
} }
@ -278,9 +280,9 @@ class PlayerDetailsFragment : Fragment() {
} }
private fun refreshChapterData(chapterIndex: Int) { private fun refreshChapterData(chapterIndex: Int) {
if (media != null && chapterIndex > -1) { if (playable != null && chapterIndex > -1) {
if (media!!.getPosition() > media!!.getDuration() || chapterIndex >= media!!.getChapters().size - 1) { if (playable!!.getPosition() > playable!!.getDuration() || chapterIndex >= playable!!.getChapters().size - 1) {
displayedChapterIndex = media!!.getChapters().size - 1 displayedChapterIndex = playable!!.getChapters().size - 1
binding.butNextChapter.visibility = View.INVISIBLE binding.butNextChapter.visibility = View.INVISIBLE
} else { } else {
displayedChapterIndex = chapterIndex displayedChapterIndex = chapterIndex
@ -291,17 +293,17 @@ class PlayerDetailsFragment : Fragment() {
} }
private fun displayCoverImage() { private fun displayCoverImage() {
if (media == null) return if (playable == null) return
if (displayedChapterIndex == -1 || media!!.getChapters().isEmpty() || media!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { if (displayedChapterIndex == -1 || playable!!.getChapters().isEmpty() || playable!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) {
val imageLoader = binding.imgvCover.context.imageLoader val imageLoader = binding.imgvCover.context.imageLoader
val imageRequest = ImageRequest.Builder(requireContext()) val imageRequest = ImageRequest.Builder(requireContext())
.data(media!!.getImageLocation()) .data(playable!!.getImageLocation())
.setHeader("User-Agent", "Mozilla/5.0") .setHeader("User-Agent", "Mozilla/5.0")
.placeholder(R.color.light_gray) .placeholder(R.color.light_gray)
.listener(object : ImageRequest.Listener { .listener(object : ImageRequest.Listener {
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
val fallbackImageRequest = ImageRequest.Builder(requireContext()) val fallbackImageRequest = ImageRequest.Builder(requireContext())
.data(ImageResourceUtils.getFallbackImageLocation(media!!)) .data(ImageResourceUtils.getFallbackImageLocation(playable!!))
.setHeader("User-Agent", "Mozilla/5.0") .setHeader("User-Agent", "Mozilla/5.0")
.error(R.mipmap.ic_launcher) .error(R.mipmap.ic_launcher)
.target(binding.imgvCover) .target(binding.imgvCover)
@ -314,7 +316,7 @@ class PlayerDetailsFragment : Fragment() {
imageLoader.enqueue(imageRequest) imageLoader.enqueue(imageRequest)
} else { } else {
val imgLoc = EmbeddedChapterImage.getModelFor(media!!, displayedChapterIndex) val imgLoc = EmbeddedChapterImage.getModelFor(playable!!, displayedChapterIndex)
val imageLoader = binding.imgvCover.context.imageLoader val imageLoader = binding.imgvCover.context.imageLoader
val imageRequest = ImageRequest.Builder(requireContext()) val imageRequest = ImageRequest.Builder(requireContext())
.data(imgLoc) .data(imgLoc)
@ -323,7 +325,7 @@ class PlayerDetailsFragment : Fragment() {
.listener(object : ImageRequest.Listener { .listener(object : ImageRequest.Listener {
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
val fallbackImageRequest = ImageRequest.Builder(requireContext()) val fallbackImageRequest = ImageRequest.Builder(requireContext())
.data(ImageResourceUtils.getFallbackImageLocation(media!!)) .data(ImageResourceUtils.getFallbackImageLocation(playable!!))
.setHeader("User-Agent", "Mozilla/5.0") .setHeader("User-Agent", "Mozilla/5.0")
.error(R.mipmap.ic_launcher) .error(R.mipmap.ic_launcher)
.target(binding.imgvCover) .target(binding.imgvCover)
@ -343,19 +345,19 @@ class PlayerDetailsFragment : Fragment() {
when { when {
displayedChapterIndex < 1 -> seekTo(0) displayedChapterIndex < 1 -> seekTo(0)
(position - 10000 * curSpeedMultiplier) < curr.start -> { (curPosition - 10000 * curSpeedMultiplier) < curr.start -> {
refreshChapterData(displayedChapterIndex - 1) 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()) else -> seekTo(curr.start.toInt())
} }
} }
@UnstableApi private fun seekToNextChapter() { @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) 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) { private fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) {
val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(media, event.position) if (playable?.getIdentifier() != event.media?.getIdentifier()) return
if (newChapterIndex > -1 && newChapterIndex != displayedChapterIndex) { val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position)
if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) {
refreshChapterData(newChapterIndex) refreshChapterData(newChapterIndex)
} }
} }
fun setItem(item_: Episode) { fun setItem(item_: Episode) {
Logd(TAG, "setItem ${item_.title}") Logd(TAG, "setItem ${item_.title}")
if (item?.identifier != item_.identifier) { if (currentItem?.identifier != item_.identifier) {
item = item_ currentItem = item_
showHomeText = false showHomeText = false
homeText = null homeText = null
} }

View File

@ -44,8 +44,6 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.* import android.view.*
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -74,15 +72,13 @@ import java.util.*
private var _binding: QueueFragmentBinding? = null private var _binding: QueueFragmentBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var infoBar: TextView
private lateinit var recyclerView: EpisodesRecyclerView private lateinit var recyclerView: EpisodesRecyclerView
private lateinit var emptyView: EmptyViewHandler private lateinit var emptyView: EmptyViewHandler
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var swipeActions: SwipeActions private lateinit var swipeActions: SwipeActions
private lateinit var speedDialView: SpeedDialView private lateinit var speedDialView: SpeedDialView
private lateinit var progressBar: ProgressBar
private var displayUpArrow = false private var displayUpArrow = false
private var queueItems: MutableList<Episode> = mutableListOf() private var queueItems: MutableList<Episode> = mutableListOf()
@ -112,10 +108,8 @@ import java.util.*
(activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow) (activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow)
toolbar.inflateMenu(R.menu.queue) toolbar.inflateMenu(R.menu.queue)
refreshToolbarState() refreshToolbarState()
progressBar = binding.progressBar binding.progressBar.visibility = View.VISIBLE
progressBar.visibility = View.VISIBLE
infoBar = binding.infoBar
recyclerView = binding.recyclerView recyclerView = binding.recyclerView
val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator
if (animator != null && animator is SimpleItemAnimator) animator.supportsChangeAnimations = false if (animator != null && animator is SimpleItemAnimator) animator.supportsChangeAnimations = false
@ -335,12 +329,12 @@ import java.util.*
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}") // Logd(TAG, "onEventMainThread() called with ${event.TAG}")
if (adapter != null) { 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 { else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list") Logd(TAG, "onPlaybackPositionEvent() ${event.TAG} search list")
for (i in 0 until adapter!!.itemCount) { for (i in 0 until adapter!!.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder 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 currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event) holder.notifyPlaybackPositionUpdated(event)
break break
@ -370,7 +364,7 @@ import java.util.*
private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) { private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) {
Log.d(TAG,"speedPresetChanged called") Log.d(TAG,"speedPresetChanged called")
for (item in queueItems) { 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 val selectedItem: Episode? = adapter!!.longPressedItem
if (selectedItem == null) { if (selectedItem == null) {
Log.i(TAG, "Selected item was null, ignoring selection") Logd(TAG, "Selected item was null, ignoring selection")
return super.onContextItemSelected(item) return super.onContextItemSelected(item)
} }
if (adapter!!.onContextItemSelected(item)) return true if (adapter!!.onContextItemSelected(item)) return true
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems.toList(), selectedItem.id) val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems.toList(), selectedItem.id)
if (pos < 0) { 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) return super.onContextItemSelected(item)
} }
@ -536,7 +530,7 @@ import java.util.*
info += "" info += ""
info += Converter.getDurationStringLocalized(requireActivity(), timeLeft) info += Converter.getDurationStringLocalized(requireActivity(), timeLeft)
} }
infoBar.text = info binding.infoBar.text = info
toolbar.title = "${getString(R.string.queue_label)}: ${curQueue.name}" toolbar.title = "${getString(R.string.queue_label)}: ${curQueue.name}"
} }
@ -551,7 +545,7 @@ import java.util.*
queueItems.clear() queueItems.clear()
queueItems.addAll(curQueue.episodes) queueItems.addAll(curQueue.episodes)
progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
adapter?.setDummyViews(0) adapter?.setDummyViews(0)
adapter?.updateItems(queueItems) adapter?.updateItems(queueItems)
if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG)
@ -562,13 +556,13 @@ import java.util.*
swipeActions.detach() swipeActions.detach()
speedDialView.visibility = View.VISIBLE speedDialView.visibility = View.VISIBLE
refreshToolbarState() refreshToolbarState()
infoBar.visibility = View.GONE binding.infoBar.visibility = View.GONE
} }
override fun onEndSelectMode() { override fun onEndSelectMode() {
speedDialView.close() speedDialView.close()
speedDialView.visibility = View.GONE speedDialView.visibility = View.GONE
infoBar.visibility = View.VISIBLE binding.infoBar.visibility = View.VISIBLE
swipeActions.attachTo(recyclerView) swipeActions.attachTo(recyclerView)
} }
@ -603,7 +597,7 @@ import java.util.*
private fun reorderQueue(sortOrder: SortOrder?, broadcastUpdate: Boolean) : Job { private fun reorderQueue(sortOrder: SortOrder?, broadcastUpdate: Boolean) : Job {
Logd(TAG, "reorderQueue called") Logd(TAG, "reorderQueue called")
if (sortOrder == null) { if (sortOrder == null) {
Log.w(TAG, "reorderQueue() - sortOrder is null. Do nothing.") Logd(TAG, "reorderQueue() - sortOrder is null. Do nothing.")
return Job() return Job()
} }
val permutor = getPermutor(sortOrder) val permutor = getPermutor(sortOrder)

View File

@ -215,14 +215,6 @@ class QuickDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener {
val podcast: PodcastSearchResult? = getItem(position) val podcast: PodcastSearchResult? = getItem(position)
holder.imageView!!.contentDescription = podcast?.title 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) { holder.imageView?.load(podcast?.imageUrl) {
placeholder(R.color.light_gray) placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher) error(R.mipmap.ic_launcher)

View File

@ -23,7 +23,6 @@ import kotlin.math.min
* Shows all episodes (possibly filtered by user). * Shows all episodes (possibly filtered by user).
*/ */
@UnstableApi class RemoteEpisodesFragment : BaseEpisodesFragment() { @UnstableApi class RemoteEpisodesFragment : BaseEpisodesFragment() {
// val TAG = this::class.simpleName ?: "Anonymous"
private val episodeList: MutableList<Episode> = mutableListOf() private val episodeList: MutableList<Episode> = mutableListOf()
@ -31,17 +30,10 @@ import kotlin.math.min
val root = super.onCreateView(inflater, container, savedInstanceState) val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView") Logd(TAG, "fragment onCreateView")
// val episodes_ = requireArguments().getSerializable(EXTRA_EPISODES) as? ArrayList<FeedItem>
// if (episodes_ != null) episodeList.addAll(episodes_)
toolbar.inflateMenu(R.menu.episodes) toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label) toolbar.setTitle(R.string.episodes_label)
updateToolbar() updateToolbar()
listAdapter.setOnSelectModeListener(null) listAdapter.setOnSelectModeListener(null)
// updateFilterUi()
// txtvInformation.setOnClickListener {
// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null)
// }
return root return root
} }
@ -66,21 +58,16 @@ import kotlin.math.min
} }
override fun loadMoreData(page: Int): List<Episode> { override fun loadMoreData(page: Int): List<Episode> {
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 { override fun loadTotalItemCount(): Int {
return episodeList.size return episodeList.size
} }
override fun getFilter(): EpisodeFilter {
return EpisodeFilter.unfiltered()
}
override fun getFragmentTag(): String {
return TAG
}
override fun getPrefName(): String { override fun getPrefName(): String {
return PREF_NAME return PREF_NAME
} }
@ -97,14 +84,6 @@ import kotlin.math.min
if (super.onOptionsItemSelected(item)) return true if (super.onOptionsItemSelected(item)) return true
when (item.itemId) { 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 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 { companion object {
const val PREF_NAME: String = "EpisodesListFragment" const val PREF_NAME: String = "EpisodesListFragment"
const val EXTRA_EPISODES: String = "episodes_list"
fun newInstance(episodes: MutableList<Episode>): RemoteEpisodesFragment { fun newInstance(episodes: MutableList<Episode>): RemoteEpisodesFragment {
val i = RemoteEpisodesFragment() val i = RemoteEpisodesFragment()

View File

@ -253,7 +253,7 @@ import java.lang.ref.WeakReference
EventFlow.events.collectLatest { event -> EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}") Logd(TAG, "Received event: ${event.TAG}")
when (event) { 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.EpisodeEvent -> onEventMainThread(event)
is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event) is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event)
else -> {} else -> {}
@ -295,13 +295,13 @@ import java.lang.ref.WeakReference
} }
fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { 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) currentPlaying!!.notifyPlaybackPositionUpdated(event)
else { else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list") Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until adapter.itemCount) { for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder 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 currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event) holder.notifyPlaybackPositionUpdated(event)
break break
@ -509,13 +509,6 @@ import java.lang.ref.WeakReference
false 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) { holder.imageView.load(podcast.imageUrl) {
placeholder(R.color.light_gray) placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher) error(R.mipmap.ic_launcher)

View File

@ -5,10 +5,10 @@ import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.feedOrder 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.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.getTags import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences 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.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
@ -38,14 +38,12 @@ import android.widget.*
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.cardview.widget.CardView
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
@ -72,14 +70,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var subscriptionRecycler: RecyclerView private lateinit var subscriptionRecycler: RecyclerView
private lateinit var listAdapter: ListAdapter private lateinit var listAdapter: SubscriptionsAdapter<*>
private lateinit var emptyView: EmptyViewHandler 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 toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var progressBar: ProgressBar
private lateinit var speedDialView: SpeedDialView private lateinit var speedDialView: SpeedDialView
private var tagFilterIndex = 1 private var tagFilterIndex = 1
@ -89,6 +82,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private var feedList: MutableList<Feed> = mutableListOf() private var feedList: MutableList<Feed> = mutableListOf()
private var feedListFiltered: List<Feed> = mutableListOf() private var feedListFiltered: List<Feed> = mutableListOf()
private var useGrid: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
retainInstance = true retainInstance = true
@ -120,20 +115,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
subscriptionRecycler.addItemDecoration(GridDividerItemDecorator()) subscriptionRecycler.addItemDecoration(GridDividerItemDecorator())
registerForContextMenu(subscriptionRecycler) registerForContextMenu(subscriptionRecycler)
subscriptionRecycler.addOnScrollListener(LiftOnScrollListener(binding.appbar)) 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) initAdapter()
subscriptionRecycler.adapter = listAdapter
setupEmptyView() setupEmptyView()
resetTags() resetTags()
@ -163,31 +146,19 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} else false } else false
} }
progressBar = binding.progressBar binding.progressBar.visibility = View.VISIBLE
progressBar.visibility = View.VISIBLE
val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd
subscriptionAddButton.setOnClickListener { subscriptionAddButton.setOnClickListener {
if (activity is MainActivity) (activity as MainActivity).loadChildFragment(AddFeedFragment()) if (activity is MainActivity) (activity as MainActivity).loadChildFragment(AddFeedFragment())
} }
feedsInfoMsg = binding.feedsInfoMessage binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
// feedsInfoMsg.setOnClickListener { binding.swipeRefresh.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance))
// SubscriptionsFilterDialog().show( binding.swipeRefresh.setOnRefreshListener {
// 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 {
FeedUpdateManager.runOnceOrAsk(requireContext()) FeedUpdateManager.runOnceOrAsk(requireContext())
} }
val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root) val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root)
speedDialView = speedDialBinding.fabSD speedDialView = speedDialBinding.fabSD
speedDialView.overlayLayout = speedDialBinding.fabSDOverlay speedDialView.overlayLayout = speedDialBinding.fabSDOverlay
speedDialView.inflate(R.menu.nav_feed_action_speeddial) 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<Feed>()).handleAction(actionItem.id) FeedMultiSelectActionHandler(activity as MainActivity, listAdapter.selectedItems.filterIsInstance<Feed>()).handleAction(actionItem.id)
true true
} }
loadSubscriptions() loadSubscriptions()
return binding.root 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() { override fun onStart() {
super.onStart() super.onStart()
initAdapter()
procFlowEvents() 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) listAdapter.setItems(feedListFiltered)
} }
@ -259,7 +244,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
EventFlow.events.collectLatest { event -> EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}") Logd(TAG, "Received event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event) is FlowEvent.FeedListEvent -> onFeedListChanged(event)
is FlowEvent.EpisodePlayedEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions() is FlowEvent.EpisodePlayedEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions()
is FlowEvent.FeedTagsChangedEvent -> resetTags() is FlowEvent.FeedTagsChangedEvent -> resetTags()
else -> {} else -> {}
@ -270,7 +255,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
EventFlow.stickyEvents.collectLatest { event -> EventFlow.stickyEvents.collectLatest { event ->
Logd(TAG, "Received sticky event: ${event.TAG}") Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.FeedUpdateRunningEvent -> swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning is FlowEvent.FeedUpdateRunningEvent -> binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning
else -> {} else -> {}
} }
} }
@ -311,9 +296,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
if ( feedListFiltered.size > result.size) listAdapter.endSelectMode() if ( feedListFiltered.size > result.size) listAdapter.endSelectMode()
feedList = result.toMutableList() feedList = result.toMutableList()
filterOnTag() filterOnTag()
progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
listAdapter.setItems(feedListFiltered) listAdapter.setItems(feedListFiltered)
feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
emptyView.updateVisibility() emptyView.updateVisibility()
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -416,7 +401,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} }
override fun onContextItemSelected(item: MenuItem): Boolean { override fun onContextItemSelected(item: MenuItem): Boolean {
val feed: Feed = listAdapter.getSelectedItem() ?: return false val feed: Feed = listAdapter.selectedItem ?: return false
val itemId = item.itemId val itemId = item.itemId
if (itemId == R.id.multi_select) { if (itemId == R.id.multi_select) {
speedDialView.visibility = View.VISIBLE speedDialView.visibility = View.VISIBLE
@ -426,8 +411,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() } return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() }
} }
private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent?) { private fun onFeedListChanged(event: FlowEvent.FeedListEvent) {
updateFeedMap() // val feeds_ = realm.query(Feed::class,"id IN $0", event.feedIds).find()
// updateFeedMap(feeds_)
loadSubscriptions() loadSubscriptions()
} }
@ -535,12 +521,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private inner class ListAdapter private abstract inner class SubscriptionsAdapter<T : RecyclerView.ViewHolder?> : SelectableAdapter<T>(activity as MainActivity), View.OnCreateContextMenuListener {
: SelectableAdapter<ListAdapter.ViewHolder?>(activity as MainActivity), View.OnCreateContextMenuListener {
private var feedList: List<Feed> protected var feedList: List<Feed>
private var selectedItem: Feed? = null var selectedItem: Feed? = null
private var longPressedPosition: Int = 0 // used to init actionMode protected var longPressedPosition: Int = 0 // used to init actionMode
val selectedItems: List<Any> val selectedItems: List<Any>
get() { get() {
val items = ArrayList<Feed>() val items = ArrayList<Feed>()
@ -560,14 +545,49 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
fun getItem(position: Int): Any { fun getItem(position: Int): Any {
return feedList[position] return feedList[position]
} }
fun getSelectedItem(): Feed? { override fun getItemCount(): Int {
return selectedItem 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<Feed>) {
this.feedList = listItems
notifyDataSetChanged()
}
}
private inner class ListAdapter : SubscriptionsAdapter<ViewHolderExpanded>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderExpanded {
val itemView: View = LayoutInflater.from(activity).inflate(R.layout.subscription_item, parent, false) 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] val feed: Feed = feedList[position]
holder.bind(feed) holder.bind(feed)
if (inActionMode()) { if (inActionMode()) {
@ -622,89 +642,149 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} }
} }
} }
override fun getItemCount(): Int { }
return feedList.size
private inner class GridAdapter : SubscriptionsAdapter<ViewHolderBrief>() {
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 { @UnstableApi override fun onBindViewHolder(holder: ViewHolderBrief, position: Int) {
if (position >= feedList.size) return RecyclerView.NO_ID // Dummy views val feed: Feed = feedList[position]
return feedList[position].id holder.bind(feed)
}
@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()) { if (inActionMode()) {
// inflater.inflate(R.menu.multi_select_context_popup, menu) holder.selectCheckbox.visibility = View.VISIBLE
// menu.findItem(R.id.multi_select).setVisible(true) 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 { } else {
inflater.inflate(R.menu.nav_feed_context, menu) holder.selectView.visibility = View.GONE
// menu.findItem(R.id.multi_select).setVisible(true) holder.coverImage.alpha = 1.0f
menu.setHeaderTitle(selectedItem?.title)
} }
MenuItemUtils.setOnClickListeners(menu) { item: MenuItem -> holder.coverImage.setOnClickListener {
this@SubscriptionsFragment.onContextItemSelected(item) if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition))
else {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
(activity as MainActivity).loadChildFragment(fragment)
}
} }
} holder.coverImage.setOnLongClickListener {
fun onContextItemSelected(item: MenuItem): Boolean { longPressedPosition = holder.bindingAdapterPosition
if (item.itemId == R.id.multi_select) { selectedItem = feed
startSelectMode(longPressedPosition) startSelectMode(longPressedPosition)
return true true
} }
return false holder.itemView.setOnTouchListener { _: View?, e: MotionEvent ->
} if (e.isFromSource(InputDevice.SOURCE_MOUSE) && e.buttonState == MotionEvent.BUTTON_SECONDARY) {
fun setItems(listItems: List<Feed>) { if (!inActionMode()) {
this.feedList = listItems longPressedPosition = holder.bindingAdapterPosition
notifyDataSetChanged() selectedItem = feed
} }
}
private inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { false
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.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()
} }
} }

View File

@ -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.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.PlaybackController.Companion.isPlayingVideoLocally import ac.mdiq.podcini.playback.PlaybackController.Companion.isPlayingVideoLocally
import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService 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.PlaybackController.Companion.seekTo
import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerBase
import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.base.PlayerStatus
@ -79,17 +79,40 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
var controller: PlaybackController? = null var controller: PlaybackController? = null
var isFavorite = false 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 { @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState) super.onCreateView(inflater, container, savedInstanceState)
_binding = VideoEpisodeFragmentBinding.inflate(LayoutInflater.from(requireContext())) _binding = VideoEpisodeFragmentBinding.inflate(LayoutInflater.from(requireContext()))
root = binding.root root = binding.root
controller = newPlaybackController() controller = newPlaybackController()
controller!!.init() controller!!.init()
// loadMediaInfo() // loadMediaInfo()
setupView() setupView()
return root return root
} }
@ -104,15 +127,14 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
setupVideoAspectRatio() setupVideoAspectRatio()
if (videoSurfaceCreated && controller != null) { if (videoSurfaceCreated && controller != null) {
Logd(TAG, "Videosurface already created, setting videosurface now") 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() { override fun loadMediaInfo() {
this@VideoEpisodeFragment.loadMediaInfo() this@VideoEpisodeFragment.loadMediaInfo()
} }
override fun onPlaybackEnd() { override fun onPlaybackEnd() {
activity?.finish() activity?.finish()
} }
@ -131,7 +153,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
super.onStop() super.onStop()
cancelFlowEvents() cancelFlowEvents()
if (!PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) videoControlsHider.removeCallbacks(hideVideoControls) if (!PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) videoControlsHider.removeCallbacks(hideVideoControls)
// Controller released; we will not receive buffering updates // Controller released; we will not receive buffering updates
binding.progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
} }
@ -151,7 +172,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
_binding = null _binding = null
controller?.release() controller?.release()
controller = null // prevent leak controller = null // prevent leak
// scope.cancel()
} }
private var eventSink: Job? = null private var eventSink: Job? = null
@ -204,7 +224,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
@OptIn(UnstableApi::class) private fun loadMediaInfo() { @OptIn(UnstableApi::class) private fun loadMediaInfo() {
Logd(TAG, "loadMediaInfo called") Logd(TAG, "loadMediaInfo called")
if (curMedia == null) return if (curMedia == null) return
if (MediaPlayerBase.status == PlayerStatus.PLAYING && !isPlayingVideoLocally) { if (MediaPlayerBase.status == PlayerStatus.PLAYING && !isPlayingVideoLocally) {
Logd(TAG, "Closing, no longer video") Logd(TAG, "Closing, no longer video")
destroyingDueToReload = true destroyingDueToReload = true
@ -245,7 +264,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
Log.e(TAG, Log.getStackTraceString(e)) Log.e(TAG, Log.getStackTraceString(e))
} }
} }
} }
private fun loadInBackground(): Episode? { private fun loadInBackground(): Episode? {
@ -266,11 +284,9 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
private fun setupView() { private fun setupView() {
showTimeLeft = shouldShowRemainingTime() showTimeLeft = shouldShowRemainingTime()
Logd(TAG, "setupView showTimeLeft: $showTimeLeft") Logd(TAG, "setupView showTimeLeft: $showTimeLeft")
binding.durationLabel.setOnClickListener { binding.durationLabel.setOnClickListener {
showTimeLeft = !showTimeLeft showTimeLeft = !showTimeLeft
val media = curMedia ?: return@setOnClickListener val media = curMedia ?: return@setOnClickListener
val converter = TimeSpeedConverter(curSpeedMultiplier) val converter = TimeSpeedConverter(curSpeedMultiplier)
val length: String val length: String
if (showTimeLeft) { if (showTimeLeft) {
@ -281,7 +297,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
length = getDurationStringLong(duration) length = getDurationStringLong(duration)
} }
binding.durationLabel.text = length binding.durationLabel.text = length
setShowRemainTimeSetting(showTimeLeft) setShowRemainTimeSetting(showTimeLeft)
Logd("timeleft on click", if (showTimeLeft) "true" else "false") Logd("timeleft on click", if (showTimeLeft) "true" else "false")
} }
@ -304,15 +319,12 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
binding.videoView.holder.addCallback(surfaceHolderCallback) binding.videoView.holder.addCallback(surfaceHolderCallback)
binding.bottomControlsContainer.fitsSystemWindows = true binding.bottomControlsContainer.fitsSystemWindows = true
// binding.videoView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION // binding.videoView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
setupVideoControlsToggler() setupVideoControlsToggler()
// (activity as AppCompatActivity).window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) // (activity as AppCompatActivity).window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
binding.videoPlayerContainer.setOnTouchListener(onVideoviewTouched) binding.videoPlayerContainer.setOnTouchListener(onVideoviewTouched)
binding.videoPlayerContainer.viewTreeObserver.addOnGlobalLayoutListener { binding.videoPlayerContainer.viewTreeObserver.addOnGlobalLayoutListener {
binding.videoView.setAvailableSize(binding.videoPlayerContainer.width.toFloat(), binding.videoPlayerContainer.height.toFloat()) binding.videoView.setAvailableSize(binding.videoPlayerContainer.width.toFloat(), binding.videoPlayerContainer.height.toFloat())
} }
webvDescription = binding.webvDescription webvDescription = binding.webvDescription
// webvDescription.setTimecodeSelectedListener { time: Int? -> // webvDescription.setTimecodeSelectedListener { time: Int? ->
// val cMedia = getMedia // val cMedia = getMedia
@ -325,45 +337,11 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
// } // }
// registerForContextMenu(webvDescription) // registerForContextMenu(webvDescription)
// webvDescription.visibility = View.GONE // webvDescription.visibility = View.GONE
binding.toggleViews.setOnClickListener { (activity as? VideoplayerActivity)?.toggleViews() }
binding.toggleViews.setOnClickListener {
(activity as? VideoplayerActivity)?.toggleViews()
}
binding.audioOnly.setOnClickListener { binding.audioOnly.setOnClickListener {
(activity as? VideoplayerActivity)?.switchToAudioOnly = true (activity as? VideoplayerActivity)?.switchToAudioOnly = true
(activity as? VideoplayerActivity)?.finish() (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() { fun toggleVideoControlsVisibility() {
@ -393,17 +371,14 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
binding.skipAnimationImage.setImageResource(R.drawable.ic_fast_rewind_video_white) binding.skipAnimationImage.setImageResource(R.drawable.ic_fast_rewind_video_white)
params.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL params.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL
} }
binding.skipAnimationImage.visibility = View.VISIBLE binding.skipAnimationImage.visibility = View.VISIBLE
binding.skipAnimationImage.layoutParams = params binding.skipAnimationImage.layoutParams = params
binding.skipAnimationImage.startAnimation(skipAnimation) binding.skipAnimationImage.startAnimation(skipAnimation)
skipAnimation.setAnimationListener(object : Animation.AnimationListener { skipAnimation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {} override fun onAnimationStart(animation: Animation) {}
override fun onAnimationEnd(animation: Animation) { override fun onAnimationEnd(animation: Animation) {
binding.skipAnimationImage.visibility = View.GONE binding.skipAnimationImage.visibility = View.GONE
} }
override fun onAnimationRepeat(animation: Animation) {} override fun onAnimationRepeat(animation: Animation) {}
}) })
} }
@ -417,7 +392,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
Logd(TAG, "Videoview holder created") Logd(TAG, "Videoview holder created")
videoSurfaceCreated = true 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() setupVideoAspectRatio()
} }
@ -431,27 +407,24 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
fun notifyVideoSurfaceAbandoned() { fun notifyVideoSurfaceAbandoned() {
// playbackService?.notifyVideoSurfaceAbandoned() // playbackService?.notifyVideoSurfaceAbandoned()
playbackService?.mediaPlayer?.pause(abandonFocus = true, reinit = false) playbackService?.mPlayer?.pause(abandonFocus = true, reinit = false)
playbackService?.mediaPlayer?.resetVideoSurface() playbackService?.mPlayer?.resetVideoSurface()
} }
fun setVideoSurface(holder: SurfaceHolder?) { // fun setVideoSurface(holder: SurfaceHolder?) {
playbackService?.mediaPlayer?.setVideoSurface(holder) // playbackService?.mPlayer?.setVideoSurface(holder)
} // }
@UnstableApi @UnstableApi
fun onRewind() { fun onRewind() {
if (controller == null) return if (controller == null) return
playbackService?.mPlayer?.seekDelta(-rewindSecs * 1000)
val curr = position
seekTo(curr - rewindSecs * 1000)
setupVideoControlsToggler() setupVideoControlsToggler()
} }
@UnstableApi @UnstableApi
fun onPlayPause() { fun onPlayPause() {
if (controller == null) return if (controller == null) return
controller!!.playPause() controller!!.playPause()
setupVideoControlsToggler() setupVideoControlsToggler()
} }
@ -459,9 +432,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
@UnstableApi @UnstableApi
fun onFastForward() { fun onFastForward() {
if (controller == null) return if (controller == null) return
playbackService?.mPlayer?.seekDelta(fastForwardSecs * 1000)
val curr = position
seekTo(curr + fastForwardSecs * 1000)
setupVideoControlsToggler() setupVideoControlsToggler()
} }
@ -512,11 +483,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
private fun onPositionObserverUpdate() { private fun onPositionObserverUpdate() {
if (controller == null) return if (controller == null) return
val converter = TimeSpeedConverter(curSpeedMultiplier) val converter = TimeSpeedConverter(curSpeedMultiplier)
val currentPosition = converter.convert(position) val currentPosition = converter.convert(curPosition)
val duration_ = converter.convert(duration) val duration_ = converter.convert(duration)
val remainingTime = converter.convert(duration - position) val remainingTime = converter.convert(duration - curPosition)
// Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition)); // Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition));
if (currentPosition == Playable.INVALID_TIME || duration_ == Playable.INVALID_TIME) { if (currentPosition == Playable.INVALID_TIME || duration_ == Playable.INVALID_TIME) {
Log.w(TAG, "Could not react to position observer update because of 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) { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (controller == null) return if (controller == null) return
if (fromUser) { if (fromUser) {
prog = progress / (seekBar.max.toFloat()) prog = progress / (seekBar.max.toFloat())
val converter = TimeSpeedConverter(curSpeedMultiplier) val converter = TimeSpeedConverter(curSpeedMultiplier)
@ -559,7 +528,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) { override fun onStopTrackingTouch(seekBar: SeekBar) {
seekTo((prog * duration).toInt()) seekTo((prog * duration).toInt())
binding.seekCardView.scaleX = 1f binding.seekCardView.scaleX = 1f
binding.seekCardView.scaleY = 1f binding.seekCardView.scaleY = 1f
binding.seekCardView.animate() binding.seekCardView.animate()
@ -574,6 +542,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
val TAG: String = VideoEpisodeFragment::class.simpleName ?: "Anonymous" val TAG: String = VideoEpisodeFragment::class.simpleName ?: "Anonymous"
val videoSize: Pair<Int, Int>? val videoSize: Pair<Int, Int>?
get() = playbackService?.mediaPlayer?.getVideoSize() get() = playbackService?.mPlayer?.getVideoSize()
} }
} }

View File

@ -50,14 +50,6 @@ abstract class StatisticsListAdapter protected constructor(@JvmField protected v
} else { } else {
val holder = h as StatisticsHolder val holder = h as StatisticsHolder
val statsItem = statisticsData!![position - 1] 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) { holder.image.load(statsItem.feed.imageUrl) {
placeholder(R.color.light_gray) placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher) error(R.mipmap.ic_launcher)

View File

@ -75,7 +75,7 @@ class CoverLoader(private val activity: MainActivity) {
.data(uri) .data(uri)
.setHeader("User-Agent", "Mozilla/5.0") .setHeader("User-Agent", "Mozilla/5.0")
.listener(object : ImageRequest.Listener { .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") Logd("CoverLoader", "Trying to get fallback image")
val fallbackImageRequest = ImageRequest.Builder(activity) val fallbackImageRequest = ImageRequest.Builder(activity)
.data(fallbackUri) .data(fallbackUri)
@ -99,13 +99,13 @@ class CoverLoader(private val activity: MainActivity) {
override fun onStart(placeholder: Drawable?) { override fun onStart(placeholder: Drawable?) {
} }
override fun onError(errorDrawable: Drawable?) { override fun onError(error: Drawable?) {
setTitleVisibility(fallbackTitle.get(), true) setTitleVisibility(fallbackTitle.get(), true)
} }
override fun onSuccess(resource: Drawable) { override fun onSuccess(result: Drawable) {
val ivCover = cover.get() val ivCover = cover.get()
ivCover!!.setImageDrawable(resource) ivCover!!.setImageDrawable(result)
setTitleVisibility(fallbackTitle.get(), textAndImageCombined) setTitleVisibility(fallbackTitle.get(), textAndImageCombined)
} }

View File

@ -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.PlaybackSpeedActivityStarter
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
import ac.mdiq.podcini.util.Converter.getDurationStringLong import ac.mdiq.podcini.util.Converter.getDurationStringLong
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.TimeSpeedConverter import ac.mdiq.podcini.util.TimeSpeedConverter
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
@ -45,11 +46,12 @@ object WidgetUpdater {
* Update the widgets with the given parameters. Must be called in a background thread. * Update the widgets with the given parameters. Must be called in a background thread.
*/ */
fun updateWidget(context: Context, widgetState: WidgetState?) { 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) val startMediaPlayer =
VideoPlayerActivityStarter(context).pendingIntent if (widgetState.media != null && widgetState.media.getMediaType() === MediaType.VIDEO) VideoPlayerActivityStarter(context).pendingIntent
else MainActivityStarter(context).withOpenPlayer().pendingIntent else MainActivityStarter(context).withOpenPlayer().pendingIntent
val startPlaybackSpeedDialog = PlaybackSpeedActivityStarter(context).pendingIntent val startPlaybackSpeedDialog = PlaybackSpeedActivityStarter(context).pendingIntent
val views = RemoteViews(context.packageName, R.layout.player_widget) 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.imgvCover, startMediaPlayer)
views.setOnClickPendingIntent(R.id.butPlaybackSpeed, startPlaybackSpeedDialog) 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 { try {
val imgLoc = widgetState.media.getImageLocation() val imgLoc = widgetState.media.getImageLocation()
val imgLoc1 = getFallbackImageLocation(widgetState.media) 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 { CoroutineScope(Dispatchers.IO).launch {
val request = ImageRequest.Builder(context) val request = ImageRequest.Builder(context)
.data(imgLoc) .data(imgLoc)
@ -162,6 +147,7 @@ object WidgetUpdater {
val widgetIds = manager.getAppWidgetIds(playerWidget) val widgetIds = manager.getAppWidgetIds(playerWidget)
for (id in widgetIds) { for (id in widgetIds) {
Logd(TAG, "updating widget $id")
val options = manager.getAppWidgetOptions(id) val options = manager.getAppWidgetOptions(id)
// val prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE) // val prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE)
val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -5,12 +5,14 @@ import ac.mdiq.podcini.net.download.DownloadStatus
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.FeedPreferences 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.SortOrder
import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import android.content.Context import android.content.Context
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.util.Consumer import androidx.core.util.Consumer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -26,7 +28,7 @@ import kotlin.math.max
sealed class FlowEvent { sealed class FlowEvent {
val TAG = this::class.simpleName ?: "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() { data class PlaybackServiceEvent(val action: Action) : FlowEvent() {
enum class Action { SERVICE_STARTED, SERVICE_SHUT_DOWN, } enum class Action { SERVICE_STARTED, SERVICE_SHUT_DOWN, }
@ -124,34 +126,25 @@ sealed class FlowEvent {
} }
} }
data class FeedListUpdateEvent(val feedIds: List<Long> = emptyList()) : FlowEvent() { data class FeedListEvent(val action: Action, val feedIds: List<Long> = emptyList()) : FlowEvent() {
constructor(feed: Feed) : this(listOf(feed.id)) enum class Action { ADDED, REMOVED, ERROR, UNKNOWN }
constructor(feedId: Long) : this(listOf(feedId))
constructor(feeds: List<Feed>, junk: String = "") : this(feeds.map { it.id }) constructor(action: Action, feedId: Long) : this(action, listOf(feedId))
fun contains(feed: Feed): Boolean { fun contains(feed: Feed): Boolean {
return feedIds.contains(feed.id) 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 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 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() data class DownloadLogEvent(val dummy: Unit = Unit) : FlowEvent()
@ -160,8 +153,6 @@ sealed class FlowEvent {
get() = map.keys get() = map.keys
} }
// data class NewEpisodeDownloadEvent(val url: String) : FlowEvent() {}
// TODO: need better handling at receving end // TODO: need better handling at receving end
data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent() 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 FeedTagsChangedEvent(val dummy: Unit = Unit) : FlowEvent()
data class FeedUpdateRunningEvent(val isFeedUpdateRunning: Boolean) : 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 SyncServiceEvent(val messageResId: Int, val message: String = "") : FlowEvent()
data class DiscoveryDefaultUpdateEvent(val dummy: Unit = Unit) : FlowEvent() data class DiscoveryDefaultUpdateEvent(val dummy: Unit = Unit) : FlowEvent()
data class DiscoveryCompletedEvent(val dummy: Unit = Unit) : FlowEvent()
} }
object EventFlow { object EventFlow {
val collectorCount = MutableStateFlow(0)
val events: MutableSharedFlow<FlowEvent> = MutableSharedFlow(replay = 0) val events: MutableSharedFlow<FlowEvent> = MutableSharedFlow(replay = 0)
val stickyEvents: MutableSharedFlow<FlowEvent> = MutableSharedFlow(replay = 1) val stickyEvents: MutableSharedFlow<FlowEvent> = MutableSharedFlow(replay = 1)
val keyEvents: MutableSharedFlow<KeyEvent> = MutableSharedFlow(replay = 0) val keyEvents: MutableSharedFlow<KeyEvent> = MutableSharedFlow(replay = 0)
@ -211,7 +197,7 @@ object EventFlow {
val caller = if (stackTrace.size > 3) stackTrace[3] else null val caller = if (stackTrace.size > 3) stackTrace[3] else null
Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted: $event") Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted: $event")
} }
GlobalScope.launch(Dispatchers.Default) { CoroutineScope(Dispatchers.Default).launch {
events.emit(event) events.emit(event)
} }
} }
@ -222,7 +208,7 @@ object EventFlow {
val caller = if (stackTrace.size > 3) stackTrace[3] else null val caller = if (stackTrace.size > 3) stackTrace[3] else null
Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted sticky: $event") Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted sticky: $event")
} }
GlobalScope.launch(Dispatchers.Default) { CoroutineScope(Dispatchers.Default).launch {
stickyEvents.emit(event) stickyEvents.emit(event)
} }
} }
@ -233,7 +219,7 @@ object EventFlow {
val caller = if (stackTrace.size > 3) stackTrace[3] else null val caller = if (stackTrace.size > 3) stackTrace[3] else null
Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted key: $event") Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted key: $event")
} }
GlobalScope.launch(Dispatchers.Default) { CoroutineScope(Dispatchers.Default).launch {
keyEvents.emit(event) keyEvents.emit(event)
} }
} }

View File

@ -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<Episode> {
/**
* 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
}
}
}

View File

@ -19,64 +19,38 @@ object EpisodesPermutors {
var permutor: Permutor<Episode>? = null var permutor: Permutor<Episode>? = null
when (sortOrder) { when (sortOrder) {
SortOrder.EPISODE_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> SortOrder.EPISODE_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemTitle(f1).compareTo(itemTitle(f2)) }
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.EPISODE_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> SortOrder.DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> pubDate(f2).compareTo(pubDate(f1)) }
itemTitle(f2).compareTo(itemTitle(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.DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> SortOrder.EPISODE_FILENAME_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemLink(f1).compareTo(itemLink(f2)) }
pubDate(f1).compareTo(pubDate(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.DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> SortOrder.PLAYED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> playDate(f2).compareTo(playDate(f1)) }
pubDate(f2).compareTo(pubDate(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.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? -> SortOrder.FEED_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> feedTitle(f1).compareTo(feedTitle(f2)) }
feedTitle(f1).compareTo(feedTitle(f2)) SortOrder.FEED_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> feedTitle(f2).compareTo(feedTitle(f1)) }
}
SortOrder.FEED_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? ->
feedTitle(f2).compareTo(feedTitle(f1))
}
SortOrder.RANDOM -> permutor = object : Permutor<Episode> { SortOrder.RANDOM -> permutor = object : Permutor<Episode> {
override fun reorder(queue: MutableList<Episode>?) {if (!queue.isNullOrEmpty()) queue.shuffle()} override fun reorder(queue: MutableList<Episode>?) {
if (!queue.isNullOrEmpty()) queue.shuffle()
}
} }
SortOrder.SMART_SHUFFLE_OLD_NEW -> permutor = object : Permutor<Episode> { SortOrder.SMART_SHUFFLE_OLD_NEW -> permutor = object : Permutor<Episode> {
override fun reorder(queue: MutableList<Episode>?) {if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList<Episode?>, true) } override fun reorder(queue: MutableList<Episode>?) {
if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList<Episode?>, true)
}
} }
SortOrder.SMART_SHUFFLE_NEW_OLD -> permutor = object : Permutor<Episode> { SortOrder.SMART_SHUFFLE_NEW_OLD -> permutor = object : Permutor<Episode> {
override fun reorder(queue: MutableList<Episode>?) {if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList<Episode?>, false) } override fun reorder(queue: MutableList<Episode>?) {
} if (!queue.isNullOrEmpty()) smartShuffle(queue as MutableList<Episode?>, 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))
} }
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) { if (comparator != null) {
val comparator2: Comparator<Episode> = comparator val comparator2: Comparator<Episode> = comparator
@ -87,7 +61,6 @@ object EpisodesPermutors {
return permutor!! return permutor!!
} }
// Null-safe accessors
private fun pubDate(item: Episode?): Date { private fun pubDate(item: Episode?): Date {
return if (item == null) Date() else Date(item.pubDate) return if (item == null) Date() else Date(item.pubDate)
} }
@ -154,9 +127,9 @@ object EpisodesPermutors {
} }
// Sort each individual list by PubDate (ascending/descending) // Sort each individual list by PubDate (ascending/descending)
val itemComparator: Comparator<Episode> = if (ascending) val itemComparator: Comparator<Episode> =
Comparator { f1: Episode, f2: Episode -> f1.pubDate?.compareTo(f2.pubDate)?:-1 } 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 } else Comparator { f1: Episode, f2: Episode -> f2.pubDate?.compareTo(f1.pubDate)?:-1 }
val feeds: MutableList<List<Episode>> = ArrayList() val feeds: MutableList<List<Episode>> = ArrayList()
for ((_, value) in map) { 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 <E> the type of elements in the list
</E> */
interface Permutor<E> {
/**
* Reorders the specified list.
* @param queue A (modifiable) list of elements to be reordered
*/
fun reorder(queue: MutableList<E>?)
}
} }

Some files were not shown because too many files have changed in this diff Show More