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

View File

@ -24,7 +24,7 @@ How to submit a feature request
Translating Podcini
----------------------
If you would like to translate the app into another language or improve an existing translation, you can visit the [Transifex project page](to be announced/). From there, you can either join a language team if it already exists or create a new language team.
If you would like to translate the app into another language or improve an existing translation, you can visit the [Transifex project page](https://app.transifex.com/xilinjia/podcini/dashboard/). From there, you can either join a language team if it already exists or create a new language team.
Submit a pull request
---------------------

View File

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

View File

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

View File

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

View File

@ -100,7 +100,7 @@ class MediaPlayerBaseTest {
}
private fun writeTestPlayable(downloadUrl: String?, fileUrl: String?): Playable {
val f = Feed(0, null, "f", "l", "d", null, null, null, null, "i", null, null, "l", false)
val f = Feed(0, null, "f", "l", "d", null, null, null, null, "i", null, null, "l")
val prefs = FeedPreferences(f.id, false, FeedPreferences.AutoDeleteAction.NEVER,
VolumeAdaptionSetting.OFF, null, null)
f.preferences = prefs

View File

@ -62,7 +62,7 @@ class TaskManagerTest {
private fun writeTestQueue(pref: String): List<Episode>? {
val NUM_ITEMS = 10
val f = Feed(0, null, "title", "link", "d", null, null, null, null, "id", null, "null", "url", false)
val f = Feed(0, null, "title", "link", "d", null, null, null, null, "id", null, "null", "url")
f.episodes.clear()
for (i in 0 until NUM_ITEMS) {
f.episodes.add(Episode(0, pref + i, pref + i, "link", Date(), Episode.PLAYED, f))

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ package ac.mdiq.podcini.playback.cast
import android.content.Context
import ac.mdiq.podcini.playback.base.MediaPlayerBase
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
/**
* Stub implementation of CastPsmp for Free build flavour

View File

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

View File

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

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.isNetworkRestricted
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.storage.database.Episodes.autodownloadEpisodeMedia
import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia
import ac.mdiq.podcini.util.Logd
@UnstableApi

View File

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

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

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 feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"")
if (feedItem == null) {
Log.i(TAG, "Unknown feed item: $action")
Logd(TAG, "Unknown feed item: $action")
return null
}
if (feedItem.media == null) {
Log.i(TAG, "Feed item has no media: $action")
Logd(TAG, "Feed item has no media: $action")
return null
}
var idRemove: Long? = null

View File

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

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.service.PlaybackService
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isCasting
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isRunning
import ac.mdiq.podcini.playback.service.PlaybackService.LocalBinder
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
@ -39,6 +43,71 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
private var initialized = false
private var eventsRegistered = false
private val mConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
if (service is LocalBinder) {
playbackService = service.service
onPlaybackServiceConnected()
if (!released) {
queryService()
Logd(TAG, "Connection to Service established")
} else Logd(TAG, "Connection to playback service has been established, but controller has already been released")
}
}
override fun onServiceDisconnected(name: ComponentName) {
playbackService = null
initialized = false
Logd(TAG, "Disconnected from Service")
}
}
private var prevStatus = PlayerStatus.STOPPED
private val statusUpdate: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Logd(TAG, "BroadcastReceiver onReceive")
if (playbackService != null && mPlayerInfo != null) {
val info = mPlayerInfo!!
Logd(TAG, "statusUpdate onReceive $prevStatus ${MediaPlayerBase.status} ${info.playerStatus} ${curMedia?.getIdentifier()} ${info.playable?.getIdentifier()}.")
if (prevStatus != info.playerStatus || curMedia == null || curMedia!!.getIdentifier() != info.playable?.getIdentifier()) {
MediaPlayerBase.status = info.playerStatus
prevStatus = MediaPlayerBase.status
curMedia = info.playable
handleStatus()
}
} else {
Logd(TAG, "statusUpdate onReceive: Couldn't receive status update: playbackService was null")
if (isRunning) bindToService()
else {
MediaPlayerBase.status = PlayerStatus.STOPPED
handleStatus()
}
}
}
}
private val notificationReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val type = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_TYPE, -1)
val code = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_CODE, -1)
if (code == -1 || type == -1) {
Logd(TAG, "Bad arguments. Won't handle intent")
return
}
when (type) {
PlaybackService.NOTIFICATION_TYPE_RELOAD -> {
if (playbackService == null && isRunning) {
bindToService()
return
}
mediaInfoLoaded = false
queryService()
}
PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END -> onPlaybackEnd()
}
}
}
@Synchronized
fun init() {
Logd(TAG, "controller init")
@ -46,7 +115,7 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
procFlowEvents()
eventsRegistered = true
}
if (PlaybackService.isRunning) initServiceRunning()
if (isRunning) initServiceRunning()
else updatePlayButtonShowsPlay(true)
}
@ -133,76 +202,11 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
*/
private fun bindToService() {
Logd(TAG, "Trying to connect to service")
check(PlaybackService.isRunning) { "Trying to bind but service is not running" }
check(isRunning) { "Trying to bind but service is not running" }
val bound = activity.bindService(Intent(activity, PlaybackService::class.java), mConnection, 0)
Logd(TAG, "Result for service binding: $bound")
}
private val mConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
if (service is LocalBinder) {
playbackService = service.service
onPlaybackServiceConnected()
if (!released) {
queryService()
Logd(TAG, "Connection to Service established")
} else Log.i(TAG, "Connection to playback service has been established, but controller has already been released")
}
}
override fun onServiceDisconnected(name: ComponentName) {
playbackService = null
initialized = false
Logd(TAG, "Disconnected from Service")
}
}
private var prevStatus = PlayerStatus.STOPPED
private val statusUpdate: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Logd(TAG, "BroadcastReceiver onReceive")
if (playbackService != null && mPlayerInfo != null) {
val info = mPlayerInfo!!
Logd(TAG, "statusUpdate onReceive $prevStatus ${MediaPlayerBase.status} ${info.playerStatus} ${curMedia?.getIdentifier()} ${info.playable?.getIdentifier()}.")
if (prevStatus != info.playerStatus || curMedia == null || curMedia!!.getIdentifier() != info.playable?.getIdentifier()) {
MediaPlayerBase.status = info.playerStatus
prevStatus = MediaPlayerBase.status
curMedia = info.playable
handleStatus()
}
} else {
Log.w(TAG, "statusUpdate onReceive: Couldn't receive status update: playbackService was null")
if (PlaybackService.isRunning) bindToService()
else {
MediaPlayerBase.status = PlayerStatus.STOPPED
handleStatus()
}
}
}
}
private val notificationReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val type = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_TYPE, -1)
val code = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_CODE, -1)
if (code == -1 || type == -1) {
Logd(TAG, "Bad arguments. Won't handle intent")
return
}
when (type) {
PlaybackService.NOTIFICATION_TYPE_RELOAD -> {
if (playbackService == null && PlaybackService.isRunning) {
bindToService()
return
}
mediaInfoLoaded = false
queryService()
}
PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END -> onPlaybackEnd()
}
}
}
open fun onPlaybackEnd() {}
/**
@ -258,7 +262,7 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
if (curMedia == null) return
if (playbackService == null) {
PlaybackServiceStarter(activity, curMedia!!).start()
Log.w(TAG, "playbackservice was null, restarted!")
// Log.w(TAG, "playbackservice was null, restarted!")
}
}
@ -266,26 +270,26 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
if (curMedia == null) return
if (playbackService == null) {
PlaybackServiceStarter(activity, curMedia!!).start()
Log.w(TAG, "playbackservice was null, restarted!")
Logd(TAG, "playbackservice was null, restarted!")
return
}
when (MediaPlayerBase.status) {
PlayerStatus.FALLBACK -> fallbackSpeed(1.0f)
PlayerStatus.PLAYING -> {
playbackService?.mediaPlayer?.pause(true, reinit = false)
playbackService?.mPlayer?.pause(true, reinit = false)
playbackService?.isSpeedForward = false
playbackService?.isFallbackSpeed = false
// if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END))
}
PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
playbackService?.mediaPlayer?.resume()
playbackService?.mPlayer?.resume()
playbackService?.taskManager?.restartSleepTimer()
// if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!))
}
PlayerStatus.PREPARING -> isStartWhenPrepared = !isStartWhenPrepared
PlayerStatus.INITIALIZED -> {
if (playbackService != null) isStartWhenPrepared = true
playbackService?.mediaPlayer?.prepare()
playbackService?.mPlayer?.prepare()
playbackService?.taskManager?.restartSleepTimer()
}
else -> {
@ -300,35 +304,35 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
var playbackService: PlaybackService? = null
val position: Int
get() = playbackService?.currentPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME
val curPosition: Int
get() = playbackService?.curPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME
val duration: Int
get() = playbackService?.duration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME
get() = playbackService?.curDuration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME
val curSpeedMultiplier: Float
get() = playbackService?.currentPlaybackSpeed ?: getCurrentPlaybackSpeed(curMedia)
get() = playbackService?.curSpeed ?: getCurrentPlaybackSpeed(curMedia)
val isPlayingVideoLocally: Boolean
get() = when {
PlaybackService.isCasting -> false
isCasting -> false
playbackService != null -> currentMediaType == MediaType.VIDEO
else -> curMedia?.getMediaType() == MediaType.VIDEO
}
private var isStartWhenPrepared: Boolean
get() = playbackService?.mediaPlayer?.startWhenPrepared?.get() ?: false
get() = playbackService?.mPlayer?.startWhenPrepared?.get() ?: false
set(s) {
playbackService?.mediaPlayer?.startWhenPrepared?.set(s)
playbackService?.mPlayer?.startWhenPrepared?.set(s)
}
private val mPlayerInfo: MediaPlayerInfo?
get() = playbackService?.mediaPlayer?.playerInfo
get() = playbackService?.mPlayer?.playerInfo
fun seekTo(time: Int) {
if (playbackService != null) {
playbackService!!.mediaPlayer?.seekTo(time)
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(time, duration))
playbackService!!.mPlayer?.seekTo(time)
// if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, time, duration))
}
}
@ -337,24 +341,24 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
when (MediaPlayerBase.status) {
PlayerStatus.PLAYING -> {
MediaPlayerBase.status = PlayerStatus.FALLBACK
fallbackSpeed_(speed)
setToFallback(speed)
}
PlayerStatus.FALLBACK -> {
MediaPlayerBase.status = PlayerStatus.PLAYING
fallbackSpeed_(speed)
setToFallback(speed)
}
else -> {}
}
}
}
private fun fallbackSpeed_(speed: Float) {
if (playbackService?.mediaPlayer == null || playbackService!!.isSpeedForward) return
private fun setToFallback(speed: Float) {
if (playbackService?.mPlayer == null || playbackService!!.isSpeedForward) return
if (!playbackService!!.isFallbackSpeed) {
playbackService!!.normalSpeed = playbackService!!.mediaPlayer!!.getPlaybackSpeed()
playbackService!!.mediaPlayer!!.setPlaybackParams(speed, isSkipSilence)
} else playbackService!!.mediaPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence)
playbackService!!.normalSpeed = playbackService!!.mPlayer!!.getPlaybackSpeed()
playbackService!!.mPlayer!!.setPlaybackParams(speed, isSkipSilence)
} else playbackService!!.mPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence)
playbackService!!.isFallbackSpeed = !playbackService!!.isFallbackSpeed
}
@ -362,5 +366,28 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
fun sleepTimerActive(): Boolean {
return playbackService?.taskManager?.isSleepTimerActive ?: false
}
/**
* Returns an intent which starts an audio- or videoplayer, depending on the
* type of media that is being played. If the playbackservice is not
* running, the type of the last played media will be looked up.
*/
@JvmStatic
fun getPlayerActivityIntent(context: Context): Intent {
val showVideoPlayer = if (isRunning) currentMediaType == MediaType.VIDEO && !isCasting
else curState.curIsVideo
return if (showVideoPlayer) VideoPlayerActivityStarter(context).intent
else MainActivityStarter(context).withOpenPlayer().getIntent()
}
/**
* Same as [.getPlayerActivityIntent], but here the type of activity
* depends on the medaitype that is provided as an argument.
*/
@JvmStatic
fun getPlayerActivityIntent(context: Context, mediaType: MediaType?): Intent {
return if (mediaType == MediaType.VIDEO && !isCasting) VideoPlayerActivityStarter(context).intent
else MainActivityStarter(context).withOpenPlayer().getIntent()
}
}
}

View File

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

View File

@ -49,18 +49,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
status = PlayerStatus.STOPPED
}
// fun startWhenPrepared.get(): Boolean {
// return startWhenPrepared.get()
// }
// fun startWhenPrepared.set(startWhenPrepared: Boolean) {
// this.startWhenPrepared.set(startWhenPrepared)
// }
// open fun getPlayable(): Playable? {
// return curMedia
// }
protected open fun setPlayable(playable: Playable?) {
if (playable != null && playable !== curMedia) {
curMedia = playable
@ -154,12 +142,15 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
/**
* Seek a specific position from the current position
* @param d offset from current position (positive or negative)
* @param delta offset from current position (positive or negative)
*/
fun seekDelta(d: Int) {
val currentPosition = getPosition()
if (currentPosition != Playable.INVALID_TIME) seekTo(currentPosition + d)
else Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta")
fun seekDelta(delta: Int) {
val curPosition = getPosition()
if (curPosition != Playable.INVALID_TIME) {
val prevMedia = curMedia
seekTo(curPosition + delta)
}
else Log.e(TAG, "seekDelta getPosition() returned INVALID_TIME in seekDelta")
}
/**
@ -268,18 +259,15 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@Synchronized
protected fun setPlayerStatus(newStatus: PlayerStatus, newMedia: Playable?, position: Int = Playable.INVALID_TIME) {
Logd(TAG, this.javaClass.simpleName + ": Setting player status to " + newStatus)
this.oldStatus = status
status = newStatus
if (newMedia != null) setPlayable(newMedia)
if (newMedia != null && newStatus != PlayerStatus.INDETERMINATE) {
when {
oldStatus == PlayerStatus.PLAYING && newStatus != PlayerStatus.PLAYING -> callback.onPlaybackPause(newMedia, position)
oldStatus != PlayerStatus.PLAYING && newStatus == PlayerStatus.PLAYING -> callback.onPlaybackStart(newMedia, position)
}
}
callback.statusChanged(MediaPlayerInfo(oldStatus, status, curMedia))
}
@ -289,29 +277,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
return (audioManager.mode != AudioManager.MODE_NORMAL || audioManager.isMusicActive)
}
interface MediaPlayerCallback {
fun statusChanged(newInfo: MediaPlayerInfo?)
// TODO: not used
fun shouldStop() {}
fun onMediaChanged(reloadUI: Boolean)
fun onPostPlayback(media: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean)
fun onPlaybackStart(playable: Playable, position: Int)
fun onPlaybackPause(playable: Playable?, position: Int)
fun getNextInQueue(currentMedia: Playable?): Playable?
fun findMedia(url: String): Playable?
fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean)
fun ensureMediaInfoLoaded(media: Playable)
}
class MediaPlayerInfo(@JvmField val oldPlayerStatus: PlayerStatus?, @JvmField var playerStatus: PlayerStatus, @JvmField var playable: Playable?)
companion object {
@ -354,7 +319,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
val newPosition = currentPosition - rewindTime.toInt()
return max(newPosition.toDouble(), 0.0).toInt()
} else return currentPosition
}
/**
@ -363,19 +327,11 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@JvmStatic
fun getCurrentPlaybackSpeed(media: Playable?): Float {
var playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL
var mediaType: MediaType? = null
val mediaType: MediaType? = media?.getMediaType()
if (media != null) {
mediaType = media.getMediaType()
playbackSpeed = curState.curTempSpeed
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) {
val item = media.episode
if (item != null) {
val feed = item.feed
if (feed?.preferences != null) {
playbackSpeed = feed.preferences!!.playSpeed
Logd(TAG, "using feed speed $playbackSpeed")
} else Logd(TAG, "Can not get feed specific playback speed: $feed")
}
if (media.episode?.feed?.preferences != null) playbackSpeed = media.episode!!.feed!!.preferences!!.playSpeed
}
}
if (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = UserPreferences.getPlaybackSpeed(mediaType)

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

View File

@ -1,6 +1,5 @@
package ac.mdiq.podcini.playback.service
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
@ -8,6 +7,7 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.MediaPlayerBase
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
@ -58,7 +58,6 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.Volatile
/**
* Manages the MediaPlayer object of the PlaybackService.
*/
@ -82,13 +81,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
private val formats: List<Format>
get() {
val formats: MutableList<Format> = arrayListOf()
val formats_: MutableList<Format> = arrayListOf()
val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList()
val trackGroups = trackInfo.getTrackGroups(audioRendererIndex)
for (i in 0 until trackGroups.length) {
formats.add(trackGroups[i].getFormat(0))
formats_.add(trackGroups[i].getFormat(0))
}
return formats
return formats_
}
private val audioRendererIndex: Int
@ -139,7 +138,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
private fun prepareWR() {
Logd(TAG, "prepareWR() called")
if (mediaSource == null && mediaItem == null) return
if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false)
else exoPlayer?.setMediaItem(mediaItem!!)
exoPlayer?.prepare()
@ -164,7 +162,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
exoPlayer?.setAudioAttributes(b.build(), true)
}
private fun metadata(p: Playable): MediaMetadata {
private fun buildMetadata(p: Playable): MediaMetadata {
val builder = MediaMetadata.Builder()
.setArtist(p.getFeedTitle())
.setTitle(p.getEpisodeTitle())
@ -208,18 +206,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
* not do anything.
* Whether playback starts immediately depends on the given parameters. See below for more details.
*
* States:
* During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
*
* If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
* 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
*
* If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
* will enter the ERROR state.
*
* This method is executed on an internal executor service.
*
* @param playable The Playable object that is supposed to be played. This parameter must not be null.
* @param stream The type of playback. If false, the Playable object MUST provide access to a locally available file via
* getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
@ -230,32 +223,28 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
* @param prepareImmediately Set to true if the method should also prepare the episode for playback.
*/
override fun playMediaObject(playable: Playable, stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
Logd(TAG, "playMediaObject $forceReset $stream $startWhenPrepared $prepareImmediately $status ${playable.getEpisodeTitle()} ")
Logd(TAG, "playMediaObject status=$status stream=$stream startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ")
if (curMedia != null) {
if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) {
// episode is already playing -> ignore method call
Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
return
} else {
Logd(TAG, "playMediaObject starts new media ${curMedia!!.getIdentifier()} ${prevMedia?.getIdentifier()} $status")
// set temporarily to pause in order to update list with current position
if (status == PlayerStatus.PLAYING) {
val pos = curMedia?.getPosition() ?: -1
seekTo(pos)
callback.onPlaybackPause(curMedia, pos)
}
// stop playback of this episode
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED)
exoPlayer?.stop()
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) {
callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
}
prevMedia = curMedia
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}
Logd(TAG, "playMediaObject starts new media playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}")
// set temporarily to pause in order to update list with current position
if (status == PlayerStatus.PLAYING) {
val pos = curMedia?.getPosition() ?: -1
seekTo(pos)
callback.onPlaybackPause(curMedia, pos)
}
// stop playback of this episode
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
prevMedia = curMedia
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}
Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}")
curMedia = playable
this.isStreaming = stream
mediaType = curMedia!!.getMediaType()
@ -263,7 +252,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
createMediaPlayer()
this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
val metadata = metadata(curMedia!!)
val metadata = buildMetadata(curMedia!!)
try {
callback.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(false)
@ -313,7 +302,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
seekTo(newPosition)
}
// play()
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
exoPlayer?.play()
// Can't set params when paused - so always set it on start in case they changed
@ -331,9 +319,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition())
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END))
if (isStreaming && reinit) reinit()
} else {
Logd(TAG, "Ignoring call to pause: Player is in $status state")
}
} else Logd(TAG, "Ignoring call to pause: Player is in $status state")
}
override fun prepare() {
@ -346,7 +332,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
if (curMedia != null) {
val pos = curMedia!!.getPosition()
if (pos > 0) seekTo(pos)
if (curMedia!!.getDuration() <= 0) {
if (curMedia != null && curMedia!!.getDuration() <= 0) {
Logd(TAG, "Setting duration of media")
curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt())
}
@ -367,21 +353,24 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
}
}
override fun seekTo(t0: Int) {
var t = t0
override fun seekTo(t: Int) {
var t = t
if (t < 0) t = 0
Logd(TAG, "seekTo() called")
Logd(TAG, "seekTo() called $t")
if (t >= getDuration()) {
Logd(TAG, "Seek reached end of file, skipping to next episode")
exoPlayer?.seekTo(t.toLong())
exoPlayer?.seekTo(t.toLong()) // can set curMedia to null
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
audioSeekCompleteListener?.run()
endPlayback(true, wasSkipped = true, true, toStoppedState = true)
t = getPosition()
// return
}
when (status) {
PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
Logd(TAG, "seekTo() called $t")
if (seekLatch != null && seekLatch!!.count > 0) {
try {
seekLatch!!.await(3, TimeUnit.SECONDS)
@ -391,8 +380,9 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
}
seekLatch = CountDownLatch(1)
statusBeforeSeeking = status
setPlayerStatus(PlayerStatus.SEEKING, curMedia, getPosition())
setPlayerStatus(PlayerStatus.SEEKING, curMedia, t)
exoPlayer?.seekTo(t.toLong())
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
audioSeekCompleteListener?.run()
if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t)
try {
@ -411,25 +401,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
}
override fun getDuration(): Int {
var retVal = Playable.INVALID_TIME
if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED)
retVal = if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt()
if (retVal <= 0) {
val playableDur = curMedia?.getDuration() ?: -1
if (playableDur > 0) retVal = playableDur
}
return retVal
return curMedia?.getDuration() ?: Playable.INVALID_TIME
}
override fun getPosition(): Int {
var retVal = Playable.INVALID_TIME
// Log.d(TAG, "getPosition() ${playable?.getIdentifier()} $status")
if (status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt()
if (retVal <= 0) {
val playablePos = curMedia?.getPosition() ?: -1
if (playablePos >= 0) retVal = playablePos
}
if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition()
return retVal
}
@ -461,16 +439,14 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
volumeRight *= adaptionFactor
}
}
if (volumeLeft > 1) {
exoPlayer!!.volume = 1f
exoPlayer?.volume = 1f
loudnessEnhancer?.setEnabled(true)
loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt())
} else {
exoPlayer!!.volume = volumeLeft
exoPlayer?.volume = volumeLeft
loudnessEnhancer?.setEnabled(false)
}
Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight")
}
@ -543,6 +519,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
}
override fun createMediaPlayer() {
Logd(TAG, "createMediaPlayer()")
release()
if (curMedia == null) {
status = PlayerStatus.STOPPED
@ -559,21 +536,19 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
// we're relying on the position stored in the Playable object for post-playback processing
val position = getPosition()
if (position >= 0) curMedia?.setPosition(position)
Logd(TAG, "endPlayback $hasEnded $wasSkipped $shouldContinue $toStoppedState")
Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState")
val currentMedia = curMedia
var nextMedia: Playable? = null
if (shouldContinue) {
// Load next episode if previous episode was in the queue and if there
// is an episode in the queue left.
// Load next episode if previous episode was in the queue and if there is an episode in the queue left.
// Start playback immediately if continuous playback is enabled
nextMedia = callback.getNextInQueue(currentMedia)
if (nextMedia != null) {
Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false")
// curMedia = null
if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null)
callback.onPlaybackEnded(nextMedia.getMediaType(), false)
// setting media to null signals to playMediaObject() that
// we're taking care of post-playback processing
// setting media to null signals to playMediaObject that we're taking care of post-playback processing
curMedia = null
playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying)
}
@ -585,7 +560,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
callback.onPlaybackEnded(null, true)
curMedia = null
exoPlayer?.stop()
// stop()
releaseWifiLockIfNecessary()
if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null)
else Logd(TAG, "Ignored call to stop: Current player state is: $status")
@ -689,7 +663,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
const val BUFFERING_STARTED: Int = -1
const val BUFFERING_ENDED: Int = -2
const val ERROR_CODE_OFFSET: Int = 1000
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null

View File

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

View File

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

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

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

View File

@ -1,20 +1,28 @@
package ac.mdiq.podcini.preferences.fragments
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.PodciniApp.Companion.forceRestart
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.sync.SyncService.Companion.isValidGuid
import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.model.EpisodeAction.Companion.readFromJsonObject
import ac.mdiq.podcini.net.sync.model.SyncServiceException
import ac.mdiq.podcini.preferences.ExportWriter
import ac.mdiq.podcini.preferences.UserPreferences.getDataFolder
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl
import ac.mdiq.podcini.storage.database.Episodes.getEpisodes
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.transport.DatabaseTransporter
import ac.mdiq.podcini.storage.transport.PreferencesTransporter
import ac.mdiq.podcini.storage.transport.ExportWriter
import ac.mdiq.podcini.storage.transport.EpisodeProgressReader
import ac.mdiq.podcini.storage.transport.EpisodesProgressWriter
import ac.mdiq.podcini.storage.transport.FavoritesWriter
import ac.mdiq.podcini.storage.transport.HtmlWriter
import ac.mdiq.podcini.storage.transport.OpmlWriter
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.preferences.OpmlTransporter.*
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded
import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.util.Logd
import android.app.Activity.RESULT_OK
import android.app.ProgressDialog
import android.content.ActivityNotFoundException
@ -23,16 +31,20 @@ import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.text.format.Formatter
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.OptIn
import androidx.annotation.StringRes
import androidx.core.app.ShareCompat.IntentBuilder
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -40,7 +52,12 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.json.JSONArray
import java.io.*
import java.nio.channels.FileChannel
import java.nio.charset.Charset
import java.text.SimpleDateFormat
import java.util.*
@ -87,10 +104,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.import_export_pref)
}
// override fun onStop() {
// super.onStop()
// }
private fun dateStampFilename(fname: String): String {
return String.format(fname, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()))
}
@ -136,7 +149,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
exportPreferences()
true
}
findPreference<Preference>(PREF_FAVORITE_EXPORT)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
openExportPathPicker(Export.FAVORITES, chooseFavoritesExportPathLauncher, FavoritesWriter())
true
@ -166,7 +178,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
try {
val output = worker.exportFile()
withContext(Dispatchers.Main) {
showExportSuccessSnackbar(output?.uri, exportType.contentType)
showExportSuccessSnackbar(output.uri, exportType.contentType)
}
} catch (e: Exception) {
showExportErrorDialog(e)
@ -412,7 +424,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
}
class DocumentFileExportWorker(private val exportWriter: ExportWriter, private val context: Context, private val outputFileUri: Uri) {
suspend fun exportFile(): DocumentFile {
return withContext(Dispatchers.IO) {
val output = DocumentFile.fromSingleUri(context, outputFileUri)
@ -429,20 +440,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
} catch (e: IOException) {
throw e
} finally {
if (writer != null) {
try {
writer.close()
} catch (e: IOException) {
throw e
}
}
if (outputStream != null) {
try {
outputStream.close()
} catch (e: IOException) {
throw e
}
}
if (writer != null) try { writer.close() } catch (e: IOException) { throw e }
if (outputStream != null) try { outputStream.close() } catch (e: IOException) { throw e }
}
}
}
@ -452,15 +451,13 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
* Writes an OPML file into the export directory in the background.
*/
class ExportWorker private constructor(private val exportWriter: ExportWriter, private val output: File, private val context: Context) {
constructor(exportWriter: ExportWriter, context: Context) : this(exportWriter, File(getDataFolder(EXPORT_DIR),
DEFAULT_OUTPUT_NAME + "." + exportWriter.fileExtension()), context)
suspend fun exportFile(): File? {
return withContext(Dispatchers.IO) {
if (output.exists()) {
val success = output.delete()
Log.w(TAG, "Overwriting previously exported file: $success")
Logd(TAG, "Overwriting previously exported file: $success")
}
var writer: OutputStreamWriter? = null
@ -476,7 +473,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
}
}
}
companion object {
private const val EXPORT_DIR = "export/"
private val TAG: String = ExportWorker::class.simpleName ?: "Anonymous"
@ -484,6 +480,397 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
}
}
object PreferencesTransporter {
private val TAG: String = PreferencesTransporter::class.simpleName ?: "Anonymous"
@Throws(IOException::class)
fun exportToDocument(uri: Uri, context: Context) {
try {
val chosenDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Destination directory is not valid")
val exportSubDir = chosenDir.createDirectory("Podcini-Prefs") ?: throw IOException("Error creating subdirectory Podcini-Prefs")
val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file ->
file.name.startsWith("shared_prefs")
}?.firstOrNull()
if (sharedPreferencesDir != null) {
sharedPreferencesDir.listFiles()!!.forEach { file ->
val destFile = exportSubDir.createFile("text/xml", file.name)
if (destFile != null) copyFile(file, destFile, context)
}
} else {
Log.e("Error", "shared_prefs directory not found")
}
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally { }
}
private fun copyFile(sourceFile: File, destFile: DocumentFile, context: Context) {
try {
val inputStream = FileInputStream(sourceFile)
val outputStream = context.contentResolver.openOutputStream(destFile.uri)
if (outputStream != null) copyStream(inputStream, outputStream)
inputStream.close()
outputStream?.close()
} catch (e: IOException) {
Log.e("Error", "Error copying file: $e")
throw e
}
}
private fun copyFile(sourceFile: DocumentFile, destFile: File, context: Context) {
try {
val inputStream = context.contentResolver.openInputStream(sourceFile.uri)
val outputStream = FileOutputStream(destFile)
if (inputStream != null) copyStream(inputStream, outputStream)
inputStream?.close()
outputStream.close()
} catch (e: IOException) {
Log.e("Error", "Error copying file: $e")
throw e
}
}
private fun copyStream(inputStream: InputStream, outputStream: OutputStream) {
val buffer = ByteArray(1024)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
@Throws(IOException::class)
fun importBackup(uri: Uri, context: Context) {
try {
val exportedDir = DocumentFile.fromTreeUri(context, uri) ?: throw IOException("Backup directory is not valid")
val sharedPreferencesDir = context.applicationContext.filesDir.parentFile?.listFiles { file ->
file.name.startsWith("shared_prefs")
}?.firstOrNull()
if (sharedPreferencesDir != null) {
sharedPreferencesDir.listFiles()?.forEach { file ->
// val prefName = file.name.substring(0, file.name.lastIndexOf('.'))
file.delete()
}
} else Log.e("Error", "shared_prefs directory not found")
val files = exportedDir.listFiles()
var hasPodciniRPrefs = false
for (file in files) {
if (file?.isFile == true && file.name?.endsWith(".xml") == true && file.name!!.contains("podcini.R")) {
hasPodciniRPrefs = true
break
}
}
for (file in files) {
if (file?.isFile == true && file.name?.endsWith(".xml") == true) {
var destName = file.name!!
// contains info on existing widgets, no need to import
if (destName.contains("PlayerWidgetPrefs")) continue
// for importing from Podcini version 5 and below
if (!hasPodciniRPrefs) {
when {
destName.contains("podcini") -> destName = destName.replace("podcini", "podcini.R")
destName.contains("EpisodeItemListRecyclerView") -> destName = destName.replace("EpisodeItemListRecyclerView", "EpisodesRecyclerView")
}
}
when {
// for debug version importing release version
BuildConfig.DEBUG && !destName.contains(".debug") -> destName = destName.replace("podcini.R", "podcini.R.debug")
// for release version importing debug version
!BuildConfig.DEBUG && destName.contains(".debug") -> destName = destName.replace(".debug", "")
}
val destFile = File(sharedPreferencesDir, destName)
copyFile(file, destFile, context)
}
}
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally { }
}
}
object DatabaseTransporter {
private val TAG: String = DatabaseTransporter::class.simpleName ?: "Anonymous"
@Throws(IOException::class)
fun exportToDocument(uri: Uri?, context: Context) {
var pfd: ParcelFileDescriptor? = null
var fileOutputStream: FileOutputStream? = null
try {
pfd = context.contentResolver.openFileDescriptor(uri!!, "wt")
fileOutputStream = FileOutputStream(pfd!!.fileDescriptor)
exportToStream(fileOutputStream, context)
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
IOUtils.closeQuietly(fileOutputStream)
if (pfd != null) try { pfd.close() } catch (e: IOException) { Logd(TAG, "Unable to close ParcelFileDescriptor") }
}
}
@Throws(IOException::class)
fun exportToStream(outFileStream: FileOutputStream, context: Context) {
var src: FileChannel? = null
var dst: FileChannel? = null
try {
val realmPath = realm.configuration.path
Logd(TAG, "exportToStream realmPath: $realmPath")
val currentDB = File(realmPath)
if (currentDB.exists()) {
src = FileInputStream(currentDB).channel
dst = outFileStream.channel
val srcSize = src.size()
dst.transferFrom(src, 0, srcSize)
val newDstSize = dst.size()
if (newDstSize != srcSize)
throw IOException(String.format("Unable to write entire database. Expected to write %s, but wrote %s.", Formatter.formatShortFileSize(context, srcSize), Formatter.formatShortFileSize(context, newDstSize)))
} else {
throw IOException("Can not access current database")
}
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
IOUtils.closeQuietly(src)
IOUtils.closeQuietly(dst)
}
}
@Throws(IOException::class)
fun importBackup(inputUri: Uri?, context: Context) {
val TEMP_DB_NAME = "temp.realm"
var inputStream: InputStream? = null
try {
val tempDB = context.getDatabasePath(TEMP_DB_NAME)
inputStream = context.contentResolver.openInputStream(inputUri!!)
FileUtils.copyInputStreamToFile(inputStream, tempDB)
val realmPath = realm.configuration.path
val currentDB = File(realmPath)
val success = currentDB.delete()
if (!success) throw IOException("Unable to delete old database")
FileUtils.moveFile(tempDB, currentDB)
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
IOUtils.closeQuietly(inputStream)
}
}
}
/** Reads OPML documents. */
object EpisodeProgressReader {
private const val TAG = "EpisodeProgressReader"
@OptIn(UnstableApi::class)
fun readDocument(reader: Reader) {
val jsonString = reader.readText()
val jsonArray = JSONArray(jsonString)
val remoteActions = mutableListOf<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 {
private val TAG: String = ImportExportPreferencesFragment::class.simpleName ?: "Anonymous"
private const val PREF_OPML_EXPORT = "prefOpmlExport"

View File

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

View File

@ -7,7 +7,7 @@ import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.util.config.ClientConfigurator
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery
import ac.mdiq.podcini.storage.database.Episodes.autodownloadEpisodeMedia
import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia
import ac.mdiq.podcini.util.Logd
// modified from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html

View File

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

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.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.net.utils.NetworkUtils.isAutoDownloadAllowed
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.ACTION_SHUTDOWN_PLAYBACK_SERVICE
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.episodeCacheSize
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownloadOnBattery
import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueues
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.database.RealmDB.realm
@ -29,7 +22,6 @@ import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.PowerUtils.deviceCharging
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import ac.mdiq.podcini.util.sorting.EpisodesPermutors.getPermutor
@ -43,28 +35,12 @@ import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Job
import java.io.File
import java.text.DateFormat
import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
import kotlin.math.abs
import kotlin.math.min
object Episodes {
private val TAG: String = Episodes::class.simpleName ?: "Anonymous"
/**
* Executor service used by the autodownloadUndownloadedEpisodes method.
*/
private val autodownloadExec: ExecutorService = Executors.newSingleThreadExecutor { r: Runnable? ->
val t = Thread(r)
t.priority = Thread.MIN_PRIORITY
t
}
var downloadAlgorithm = AutomaticDownloadAlgorithm()
/**
* @param offset The first episode that should be loaded.
* @param limit The maximum number of episodes that should be loaded.
@ -113,101 +89,6 @@ object Episodes {
return if (media != null) realm.copyFromRealm(media) else null
}
/**
* Looks for non-downloaded episodes in the queue or list of unread episodes and request a download if
* 1. Network is available
* 2. The device is charging or the user allows auto download on battery
* 3. There is free space in the episode cache
* This method is executed on an internal single thread executor.
* @param context Used for accessing the DB.
* @return A Future that can be used for waiting for the methods completion.
*/
@UnstableApi
fun autodownloadEpisodeMedia(context: Context): Future<*> {
Logd(TAG, "autodownloadEpisodeMedia")
return autodownloadExec.submit(downloadAlgorithm.autoDownloadEpisodeMedia(context))
}
/**
* Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller
* 'playbackCompletionDate'-value will be deleted first.
* This method should NOT be executed on the GUI thread.
* @param context Used for accessing the DB.
*/
fun performAutoCleanup(context: Context) {
EpisodeCleanupAlgorithmFactory.build().performCleanup(context)
}
/**
* Implements the automatic download algorithm used by Podcini. This class assumes that
* the client uses the [EpisodeCleanupAlgorithm].
*/
open class AutomaticDownloadAlgorithm {
/**
* Looks for undownloaded episodes in the queue or list of new items and request a download if
* 1. Network is available
* 2. The device is charging or the user allows auto download on battery
* 3. There is free space in the episode cache
* This method is executed on an internal single thread executor.
* @param context Used for accessing the DB.
* @return A Runnable that will be submitted to an ExecutorService.
*/
@UnstableApi open fun autoDownloadEpisodeMedia(context: Context): Runnable? {
return Runnable {
// true if we should auto download based on network status
// val networkShouldAutoDl = (isAutoDownloadAllowed)
val networkShouldAutoDl = (isAutoDownloadAllowed && isEnableAutodownload)
// true if we should auto download based on power status
val powerShouldAutoDl = (deviceCharging(context) || isEnableAutodownloadOnBattery)
Logd(TAG, "prepare autoDownloadUndownloadedItems $networkShouldAutoDl $powerShouldAutoDl")
// we should only auto download if both network AND power are happy
if (networkShouldAutoDl && powerShouldAutoDl) {
Logd(TAG, "Performing auto-dl of undownloaded episodes")
val candidates: MutableList<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
@OptIn(UnstableApi::class) @JvmStatic
fun deleteMediaOfEpisode(context: Context, episode: Episode) : Job {
@ -228,7 +109,7 @@ object Episodes {
private fun deleteMediaSync(context: Context, episode: Episode): Boolean {
Logd(TAG, "deleteMediaSync called")
val media = episode.media ?: return false
Log.i(TAG, String.format(Locale.US, "Requested to delete EpisodeMedia [id=%d, title=%s, downloaded=%s", media.id, media.getEpisodeTitle(), media.downloaded))
Logd(TAG, String.format(Locale.US, "Requested to delete EpisodeMedia [id=%d, title=%s, downloaded=%s", media.id, media.getEpisodeTitle(), media.downloaded))
var localDelete = false
val url = media.fileUrl
when {
@ -312,17 +193,7 @@ object Episodes {
}
}
}
if (removedFromQueue.isNotEmpty()) {
curQueue.episodes.clear()
curQueue.episodes.addAll(queueItems)
// upsertBlk(curQueue) {}
}
// TODO: need to update download logs?
// val adapter = getInstance()
// adapter.open()
// if (removedFromQueue.isNotEmpty()) adapter.setQueue(queueItems)
// adapter.removeFeedItems(episodes)
// adapter.close()
if (removedFromQueue.isNotEmpty()) removeFromAllQueues(*removedFromQueue.toTypedArray())
for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode))
@ -372,7 +243,6 @@ object Episodes {
* Adds a Episode object to the playback history. A Episode object is in the playback history if
* its playback completion date is set to a non-null value. This method will set the playback completion date to the
* current date regardless of the current value.
*
* @param episode Episode that should be added to the playback history.
* @param date PlaybackCompletionDate for `media`
*/
@ -414,67 +284,4 @@ object Episodes {
}
}
}
/**
* Publishers sometimes mess up their feed by adding episodes twice or by changing the ID of existing episodes.
* This class tries to guess if publishers actually meant another episode,
* even if their feed explicitly says that the episodes are different.
*/
object EpisodeDuplicateGuesser {
fun seemDuplicates(item1: Episode, item2: Episode): Boolean {
if (sameAndNotEmpty(item1.identifier, item2.identifier)) return true
val media1 = item1.media
val media2 = item2.media
if (media1 == null || media2 == null) return false
if (sameAndNotEmpty(media1.getStreamUrl(), media2.getStreamUrl())) return true
return (titlesLookSimilar(item1, item2) && datesLookSimilar(item1, item2) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2))
}
fun sameAndNotEmpty(string1: String?, string2: String?): Boolean {
if (string1.isNullOrEmpty() || string2.isNullOrEmpty()) return false
return string1 == string2
}
private fun datesLookSimilar(item1: Episode, item2: Episode): Boolean {
if (item1.getPubDate() == null || item2.getPubDate() == null) return false
val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) // MM/DD/YY
val dateOriginal = dateFormat.format(item2.getPubDate()!!)
val dateNew = dateFormat.format(item1.getPubDate()!!)
return dateOriginal == dateNew // Same date; time is ignored.
}
private fun durationsLookSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean {
return abs((media1.getDuration() - media2.getDuration()).toDouble()) < 10 * 60L * 1000L
}
private fun mimeTypeLooksSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean {
var mimeType1 = media1.mimeType
var mimeType2 = media2.mimeType
if (mimeType1 == null || mimeType2 == null) return true
if (mimeType1.contains("/") && mimeType2.contains("/")) {
mimeType1 = mimeType1.substring(0, mimeType1.indexOf("/"))
mimeType2 = mimeType2.substring(0, mimeType2.indexOf("/"))
}
return (mimeType1 == mimeType2)
}
private fun titlesLookSimilar(item1: Episode, item2: Episode): Boolean {
return sameAndNotEmpty(canonicalizeTitle(item1.title), canonicalizeTitle(item2.title))
}
private fun canonicalizeTitle(title: String?): String {
if (title == null) return ""
return title
.trim { it <= ' ' }
.replace('“', '"')
.replace('”', '"')
.replace('„', '"')
.replace('—', '-')
}
}
}

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

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.isQueueKeepSorted
import ac.mdiq.podcini.preferences.UserPreferences.queueKeepSortedOrder
import ac.mdiq.podcini.storage.database.Episodes.autodownloadEpisodeMedia
import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
@ -186,7 +186,7 @@ object Queues {
queue.episodes.addAll(qItems)
}
for (event in events) EventFlow.postEvent(event)
} else Log.w(TAG, "Queue was not modified by call to removeQueueItem")
} else Logd(TAG, "Queue was not modified by call to removeQueueItem")
// TODO: what's this for?
if (queue.id == curQueue.id && context != null) autodownloadEpisodeMedia(context)

View File

@ -16,9 +16,12 @@ import kotlinx.coroutines.*
import kotlin.coroutines.ContinuationInterceptor
object RealmDB {
val TAG: String = RealmDB::class.simpleName ?: "Anonymous"
private val TAG: String = RealmDB::class.simpleName ?: "Anonymous"
private const val SCHEMA_VERSION_NUMBER = 4L
private val ioScope = CoroutineScope(Dispatchers.IO)
val ioScope = CoroutineScope(Dispatchers.IO)
val realm: Realm
init {
@ -33,7 +36,7 @@ object RealmDB {
DownloadResult::class,
Chapter::class))
.name("Podcini.realm")
.schemaVersion(3)
.schemaVersion(SCHEMA_VERSION_NUMBER)
.build()
realm = Realm.open(config)
}

View File

@ -1,7 +1,6 @@
package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.podcini.util.Logd
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.ext.realmSetOf
import io.realm.kotlin.types.RealmList
@ -13,7 +12,6 @@ import io.realm.kotlin.types.annotations.Index
import io.realm.kotlin.types.annotations.PrimaryKey
import org.apache.commons.lang3.builder.ToStringBuilder
import org.apache.commons.lang3.builder.ToStringStyle
import java.util.*
/**
@ -54,7 +52,6 @@ class Episode : RealmObject {
@Ignore
var feed: Feed? = null
get() {
// Logd(TAG, "feed.get() ${field == null} ${title}")
if (field == null && feedId != null) field = getFeed(feedId!!)
return field
}
@ -138,26 +135,6 @@ class Episode : RealmObject {
// this.hasChapters = false
}
/**
* This constructor is used by DBReader.
*/
// constructor(id: Long, title: String?, link: String?, pubDate: Date?, paymentLink: String?, feedId: Long,
// hasChapters: Boolean, imageUrl: String?, state: Int,
// itemIdentifier: String?, autoDownloadEnabled: Boolean, podcastIndexChapterUrl: String?) {
// this.id = id
// this.title = title
// this.link = link
// this.pubDate = pubDate?.time ?: 0
// this.paymentLink = paymentLink
// this.feedId = feedId
//// this.hasChapters = hasChapters
// this.imageUrl = imageUrl
// this.playState = state
// this.identifier = itemIdentifier
// this.isAutoDownloadEnabled = autoDownloadEnabled
// this.podcastIndexChapterUrl = podcastIndexChapterUrl
// }
/**
* This constructor should be used for creating test objects.
*/

View File

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

View File

@ -29,7 +29,7 @@ class Feed : RealmObject {
var fileUrl: String? = null
var downloadUrl: String? = null
var downloaded: Boolean = false
// var downloaded: Boolean = false
/**
* title as defined by the feed.
@ -156,65 +156,33 @@ class Feed : RealmObject {
}
/**
* This constructor is used for restoring a feed from the database.
* This constructor is used for test purposes.
*/
constructor(id: Long, lastUpdate: String?, title: String?, customTitle: String?, link: String?,
description: String?, paymentLinks: String?, author: String?, language: String?,
type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?,
downloadUrl: String?, downloaded: Boolean, paged: Boolean, nextPageLink: String?,
filter: String?, sortOrder: SortOrder?, lastUpdateFailed: Boolean) {
constructor(id: Long, lastUpdate: String?, title: String?, link: String?, description: String?, paymentLink: String?,
author: String?, language: String?, type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?,
downloadUrl: String?) {
this.id = id
this.fileUrl = fileUrl
this.downloadUrl = downloadUrl
this.downloaded = downloaded
this.eigenTitle = title
this.customTitle = customTitle
this.lastUpdate = lastUpdate
this.link = link
this.description = description
this.paymentLinks = extractPaymentLinks(paymentLinks)
this.paymentLinks = extractPaymentLinks(paymentLink)
this.author = author
this.language = language
this.type = type
this.identifier = feedIdentifier
this.imageUrl = imageUrl
this.isPaged = paged
this.isPaged = false
this.nextPageLink = nextPageLink
// if (filter != null) this.episodeFilter = EpisodeFilter(filter)
// else this.episodeFilter = EpisodeFilter()
this.preferences?.filterString = filter ?: ""
this.preferences?.filterString = ""
this.sortOrder = sortOrder
this.preferences?.sortOrderCode = sortOrder?.code ?: 0
this.lastUpdateFailed = lastUpdateFailed
}
/**
* This constructor is used for test purposes.
*/
constructor(id: Long, lastUpdate: String?, title: String?, link: String?, description: String?, paymentLink: String?,
author: String?, language: String?, type: String?, feedIdentifier: String?, imageUrl: String?, fileUrl: String?,
downloadUrl: String?, downloaded: Boolean)
: this(id,
lastUpdate,
title,
null,
link,
description,
paymentLink,
author,
language,
type,
feedIdentifier,
imageUrl,
fileUrl,
downloadUrl,
downloaded,
false,
null,
null,
null,
false)
/**
* This constructor can be used when parsing feed data. Only the 'lastUpdate' and 'items' field are initialized.
*/
@ -228,7 +196,6 @@ class Feed : RealmObject {
this.lastUpdate = lastUpdate
fileUrl = null
this.downloadUrl = url
downloaded = false
}
/**
@ -247,7 +214,7 @@ class Feed : RealmObject {
preferences = FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password)
}
fun getHumanReadableIdentifier(): String? {
fun getTextIdentifier(): String? {
return when {
!customTitle.isNullOrEmpty() -> customTitle
!eigenTitle.isNullOrEmpty() -> eigenTitle

View File

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

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UsageStatistics.logAction
import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming
@ -11,6 +10,7 @@ import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.content.Context

View File

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

View File

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

View File

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

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

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

View File

@ -22,13 +22,6 @@ class SimpleIconListAdapter<T : SimpleIconListAdapter.ListItem>(private val cont
val binding = SimpleIconListItemBinding.bind(view!!)
binding.title.text = item.title
binding.subtitle.text = item.subtitle
// if (item.imageUrl.isNotBlank()) Glide.with(context)
// .load(item.imageUrl)
// .apply(RequestOptions()
// .diskCacheStrategy(DiskCacheStrategy.NONE)
// .fitCenter()
// .dontAnimate())
// .into(binding.icon)
binding.icon.load(item.imageUrl)
return view
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -37,9 +37,7 @@ import kotlin.math.min
private var startDate : Long = 0L
private var endDate : Long = Date().time
override fun getFragmentTag(): String {
return TAG
}
var allHistory: List<Episode> = listOf()
override fun getPrefName(): String {
return TAG
@ -89,10 +87,6 @@ import kotlin.math.min
cancelFlowEvents()
}
override fun getFilter(): EpisodeFilter {
return EpisodeFilter.unfiltered()
}
@OptIn(UnstableApi::class) override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
when (item.itemId) {
@ -157,15 +151,15 @@ import kotlin.math.min
}
override fun loadData(): List<Episode> {
val hList = getHistory(0, page * EPISODES_PER_PAGE, startDate, endDate, sortOrder).toMutableList()
// FeedItemPermutors.getPermutor(sortOrder).reorder(hList)
return hList
allHistory = getHistory(0, Int.MAX_VALUE, startDate, endDate, sortOrder).toMutableList()
return allHistory.subList(0, min(allHistory.size-1, page * EPISODES_PER_PAGE))
}
override fun loadMoreData(page: Int): List<Episode> {
val hList = getHistory((page - 1) * EPISODES_PER_PAGE, EPISODES_PER_PAGE, startDate, endDate, sortOrder).toMutableList()
// FeedItemPermutors.getPermutor(sortOrder).reorder(hList)
return hList
val offset = (page - 1) * EPISODES_PER_PAGE
if (offset >= allHistory.size) return listOf()
val toIndex = offset + EPISODES_PER_PAGE
return allHistory.subList(offset, min(allHistory.size, toIndex))
}
override fun loadTotalItemCount(): Int {
@ -215,7 +209,7 @@ import kotlin.math.min
fun getHistory(offset: Int, limit: Int, start: Long = 0L, end: Long = Date().time,
sortOrder: SortOrder = SortOrder.PLAYED_DATE_NEW_OLD): List<Episode> {
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()
for (m in medias) {
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.episodeCacheSize
import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.storage.algorithms.EpisodeCleanupAlgorithmFactory
import ac.mdiq.podcini.storage.algorithms.AutoCleanups
import ac.mdiq.podcini.storage.database.Episodes.getEpisodesCount
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.model.DatasetStats
@ -413,7 +413,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
// queueSize = queue?.episodeIds?.size ?: 0
// }
Logd(TAG, "getDatasetStats: queueSize: $queueSize")
return DatasetStats(queueSize, numDownloadedItems, EpisodeCleanupAlgorithmFactory.build().getReclaimableItems(), numItems, numFeeds)
return DatasetStats(queueSize, numDownloadedItems, AutoCleanups.build().getReclaimableItems(), numItems, numFeeds)
}
}
}

View File

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

View File

@ -56,7 +56,7 @@ class OnlineSearchFragment : Fragment() {
break
}
}
if (searchProvider == null) Log.i(TAG,"Podcast searcher not found")
if (searchProvider == null) Logd(TAG,"Podcast searcher not found")
}
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {

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

View File

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

View File

@ -215,14 +215,6 @@ class QuickDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener {
val podcast: PodcastSearchResult? = getItem(position)
holder.imageView!!.contentDescription = podcast?.title
// if (!podcast?.imageUrl.isNullOrBlank()) Glide.with(mainActivityRef.get()!!)
// .load(podcast?.imageUrl)
// .apply(RequestOptions()
// .placeholder(R.color.light_gray)
// .transform(FitCenter(), RoundedCorners((8 * mainActivityRef.get()!!.resources.displayMetrics.density).toInt()))
// .dontAnimate())
// .into(holder.imageView!!)
holder.imageView?.load(podcast?.imageUrl) {
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)

View File

@ -23,7 +23,6 @@ import kotlin.math.min
* Shows all episodes (possibly filtered by user).
*/
@UnstableApi class RemoteEpisodesFragment : BaseEpisodesFragment() {
// val TAG = this::class.simpleName ?: "Anonymous"
private val episodeList: MutableList<Episode> = mutableListOf()
@ -31,17 +30,10 @@ import kotlin.math.min
val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
// val episodes_ = requireArguments().getSerializable(EXTRA_EPISODES) as? ArrayList<FeedItem>
// if (episodes_ != null) episodeList.addAll(episodes_)
toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label)
updateToolbar()
listAdapter.setOnSelectModeListener(null)
// updateFilterUi()
// txtvInformation.setOnClickListener {
// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null)
// }
return root
}
@ -66,21 +58,16 @@ import kotlin.math.min
}
override fun loadMoreData(page: Int): List<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 {
return episodeList.size
}
override fun getFilter(): EpisodeFilter {
return EpisodeFilter.unfiltered()
}
override fun getFragmentTag(): String {
return TAG
}
override fun getPrefName(): String {
return PREF_NAME
}
@ -97,14 +84,6 @@ import kotlin.math.min
if (super.onOptionsItemSelected(item)) return true
when (item.itemId) {
// R.id.filter_items -> {
// AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null)
// return true
// }
// R.id.episodes_sort -> {
// AllEpisodesSortDialog().show(childFragmentManager.beginTransaction(), "SortDialog")
// return true
// }
else -> return false
}
}
@ -127,44 +106,8 @@ import kotlin.math.min
}
}
private fun updateFilterUi() {
// swipeActions.setFilter(getFilter())
// when {
// getFilter().values.isNotEmpty() -> {
// txtvInformation.visibility = View.VISIBLE
// emptyView.setMessage(R.string.no_all_episodes_filtered_label)
// }
// else -> {
// txtvInformation.visibility = View.GONE
// emptyView.setMessage(R.string.no_all_episodes_label)
// }
// }
// toolbar.menu?.findItem(R.id.action_favorites)?.setIcon(
// if (getFilter().showIsFavorite) R.drawable.ic_star else R.drawable.ic_star_border)
}
// class AllEpisodesSortDialog : ItemSortDialog() {
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// sortOrder = allEpisodesSortOrder
// }
//
// override fun onAddItem(title: Int, ascending: SortOrder, descending: SortOrder, ascendingIsDefault: Boolean) {
// if (ascending == SortOrder.DATE_OLD_NEW || ascending == SortOrder.DURATION_SHORT_LONG) {
// super.onAddItem(title, ascending, descending, ascendingIsDefault)
// }
// }
//
// override fun onSelectionChanged() {
// super.onSelectionChanged()
// allEpisodesSortOrder = sortOrder
// EventBus.getDefault().post(FeedListUpdateEvent(0))
// }
// }
companion object {
const val PREF_NAME: String = "EpisodesListFragment"
const val EXTRA_EPISODES: String = "episodes_list"
fun newInstance(episodes: MutableList<Episode>): RemoteEpisodesFragment {
val i = RemoteEpisodesFragment()

View File

@ -253,7 +253,7 @@ import java.lang.ref.WeakReference
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.FeedListUpdateEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> search()
is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> search()
is FlowEvent.EpisodeEvent -> onEventMainThread(event)
is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event)
else -> {}
@ -295,13 +295,13 @@ import java.lang.ref.WeakReference
}
fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) {
if (currentPlaying != null && currentPlaying!!.isCurMedia)
if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia)
currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder
if (holder != null && holder.isCurMedia) {
if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) {
currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event)
break
@ -509,13 +509,6 @@ import java.lang.ref.WeakReference
false
}
// if (!podcast.imageUrl.isNullOrBlank()) Glide.with(mainActivityRef.get()!!)
// .load(podcast.imageUrl)
// .apply(RequestOptions()
// .placeholder(R.color.light_gray)
// .fitCenter()
// .dontAnimate())
// .into(holder.imageView)
holder.imageView.load(podcast.imageUrl) {
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)

View File

@ -5,10 +5,10 @@ import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.feedOrder
import ac.mdiq.podcini.preferences.UserPreferences.useGridLayout
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.Feeds.updateFeedMap
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed
@ -38,14 +38,12 @@ import android.widget.*
import androidx.annotation.OptIn
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.cardview.widget.CardView
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.elevation.SurfaceColors
@ -72,14 +70,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private val binding get() = _binding!!
private lateinit var subscriptionRecycler: RecyclerView
private lateinit var listAdapter: ListAdapter
private lateinit var listAdapter: SubscriptionsAdapter<*>
private lateinit var emptyView: EmptyViewHandler
private lateinit var feedsInfoMsg: LinearLayout
private lateinit var feedsFilteredMsg: TextView
private lateinit var feedCount: TextView
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var progressBar: ProgressBar
private lateinit var speedDialView: SpeedDialView
private var tagFilterIndex = 1
@ -89,6 +82,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private var feedList: MutableList<Feed> = mutableListOf()
private var feedListFiltered: List<Feed> = mutableListOf()
private var useGrid: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
@ -120,20 +115,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
subscriptionRecycler.addItemDecoration(GridDividerItemDecorator())
registerForContextMenu(subscriptionRecycler)
subscriptionRecycler.addOnScrollListener(LiftOnScrollListener(binding.appbar))
// subscriptionAdapter = object : SubscriptionsAdapter(activity as MainActivity) {
// override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
// super.onCreateContextMenu(menu, v, menuInfo)
// MenuItemUtils.setOnClickListeners(menu) { item: MenuItem ->
// this@SubscriptionsFragment.onContextItemSelected(item)
// }
// }
// }
listAdapter = ListAdapter()
val gridLayoutManager = GridLayoutManager(context, 1, RecyclerView.VERTICAL, false)
subscriptionRecycler.layoutManager = gridLayoutManager
listAdapter.setOnSelectModeListener(this)
subscriptionRecycler.adapter = listAdapter
initAdapter()
setupEmptyView()
resetTags()
@ -163,31 +146,19 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} else false
}
progressBar = binding.progressBar
progressBar.visibility = View.VISIBLE
binding.progressBar.visibility = View.VISIBLE
val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd
subscriptionAddButton.setOnClickListener {
if (activity is MainActivity) (activity as MainActivity).loadChildFragment(AddFeedFragment())
}
feedsInfoMsg = binding.feedsInfoMessage
// feedsInfoMsg.setOnClickListener {
// SubscriptionsFilterDialog().show(
// childFragmentManager, "filter")
// }
feedsFilteredMsg = binding.feedsFilteredMessage
feedCount = binding.count
feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
swipeRefreshLayout = binding.swipeRefresh
swipeRefreshLayout.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance))
swipeRefreshLayout.setOnRefreshListener {
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
binding.swipeRefresh.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance))
binding.swipeRefresh.setOnRefreshListener {
FeedUpdateManager.runOnceOrAsk(requireContext())
}
val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root)
speedDialView = speedDialBinding.fabSD
speedDialView.overlayLayout = speedDialBinding.fabSDOverlay
speedDialView.inflate(R.menu.nav_feed_action_speeddial)
@ -201,14 +172,28 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
FeedMultiSelectActionHandler(activity as MainActivity, listAdapter.selectedItems.filterIsInstance<Feed>()).handleAction(actionItem.id)
true
}
loadSubscriptions()
return binding.root
}
private fun initAdapter() {
if (useGrid != useGridLayout) {
useGrid = useGridLayout
var spanCount = 1
if (useGrid!!) {
listAdapter = GridAdapter()
spanCount = 3
} else listAdapter = ListAdapter()
subscriptionRecycler.layoutManager = GridLayoutManager(context, spanCount, RecyclerView.VERTICAL, false)
listAdapter.setOnSelectModeListener(this)
subscriptionRecycler.adapter = listAdapter
listAdapter.setItems(feedListFiltered)
}
}
override fun onStart() {
super.onStart()
initAdapter()
procFlowEvents()
}
@ -242,7 +227,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
}
}
feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
listAdapter.setItems(feedListFiltered)
}
@ -259,7 +244,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.FeedListUpdateEvent -> onFeedListChanged(event)
is FlowEvent.FeedListEvent -> onFeedListChanged(event)
is FlowEvent.EpisodePlayedEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions()
is FlowEvent.FeedTagsChangedEvent -> resetTags()
else -> {}
@ -270,7 +255,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
EventFlow.stickyEvents.collectLatest { event ->
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.FeedUpdateRunningEvent -> swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning
is FlowEvent.FeedUpdateRunningEvent -> binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning
else -> {}
}
}
@ -311,9 +296,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
if ( feedListFiltered.size > result.size) listAdapter.endSelectMode()
feedList = result.toMutableList()
filterOnTag()
progressBar.visibility = View.GONE
binding.progressBar.visibility = View.GONE
listAdapter.setItems(feedListFiltered)
feedCount.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
emptyView.updateVisibility()
}
} catch (e: Throwable) {
@ -416,7 +401,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
override fun onContextItemSelected(item: MenuItem): Boolean {
val feed: Feed = listAdapter.getSelectedItem() ?: return false
val feed: Feed = listAdapter.selectedItem ?: return false
val itemId = item.itemId
if (itemId == R.id.multi_select) {
speedDialView.visibility = View.VISIBLE
@ -426,8 +411,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() }
}
private fun onFeedListChanged(event: FlowEvent.FeedListUpdateEvent?) {
updateFeedMap()
private fun onFeedListChanged(event: FlowEvent.FeedListEvent) {
// val feeds_ = realm.query(Feed::class,"id IN $0", event.feedIds).find()
// updateFeedMap(feeds_)
loadSubscriptions()
}
@ -535,12 +521,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
@OptIn(UnstableApi::class)
private inner class ListAdapter
: SelectableAdapter<ListAdapter.ViewHolder?>(activity as MainActivity), View.OnCreateContextMenuListener {
private abstract inner class SubscriptionsAdapter<T : RecyclerView.ViewHolder?> : SelectableAdapter<T>(activity as MainActivity), View.OnCreateContextMenuListener {
private var feedList: List<Feed>
private var selectedItem: Feed? = null
private var longPressedPosition: Int = 0 // used to init actionMode
protected var feedList: List<Feed>
var selectedItem: Feed? = null
protected var longPressedPosition: Int = 0 // used to init actionMode
val selectedItems: List<Any>
get() {
val items = ArrayList<Feed>()
@ -560,14 +545,49 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
fun getItem(position: Int): Any {
return feedList[position]
}
fun getSelectedItem(): Feed? {
return selectedItem
override fun getItemCount(): Int {
return feedList.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
override fun getItemId(position: Int): Long {
if (position >= feedList.size) return RecyclerView.NO_ID // Dummy views
return feedList[position].id
}
@OptIn(UnstableApi::class)
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
if (selectedItem == null) return
val mainActRef = (activity as MainActivity)
val inflater: MenuInflater = mainActRef.menuInflater
if (inActionMode()) {
// inflater.inflate(R.menu.multi_select_context_popup, menu)
// menu.findItem(R.id.multi_select).setVisible(true)
} else {
inflater.inflate(R.menu.nav_feed_context, menu)
// menu.findItem(R.id.multi_select).setVisible(true)
menu.setHeaderTitle(selectedItem?.title)
}
MenuItemUtils.setOnClickListeners(menu) { item: MenuItem ->
this@SubscriptionsFragment.onContextItemSelected(item)
}
}
fun onContextItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.multi_select) {
startSelectMode(longPressedPosition)
return true
}
return false
}
fun setItems(listItems: List<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)
return ViewHolder(itemView)
return ViewHolderExpanded(itemView)
}
@UnstableApi override fun onBindViewHolder(holder: ViewHolder, position: Int) {
@UnstableApi override fun onBindViewHolder(holder: ViewHolderExpanded, position: Int) {
val feed: Feed = feedList[position]
holder.bind(feed)
if (inActionMode()) {
@ -622,89 +642,149 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
}
}
override fun getItemCount(): Int {
return feedList.size
}
private inner class GridAdapter : SubscriptionsAdapter<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 {
if (position >= feedList.size) return RecyclerView.NO_ID // Dummy views
return feedList[position].id
}
@OptIn(UnstableApi::class)
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
if (selectedItem == null) return
val mainActRef = (activity as MainActivity)
val inflater: MenuInflater = mainActRef.menuInflater
@UnstableApi override fun onBindViewHolder(holder: ViewHolderBrief, position: Int) {
val feed: Feed = feedList[position]
holder.bind(feed)
if (inActionMode()) {
// inflater.inflate(R.menu.multi_select_context_popup, menu)
// menu.findItem(R.id.multi_select).setVisible(true)
holder.selectCheckbox.visibility = View.VISIBLE
holder.selectView.visibility = View.VISIBLE
holder.selectCheckbox.setChecked(isSelected(position))
holder.selectCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
setSelected(holder.bindingAdapterPosition, isChecked)
}
holder.coverImage.alpha = 0.6f
holder.count.visibility = View.GONE
} else {
inflater.inflate(R.menu.nav_feed_context, menu)
// menu.findItem(R.id.multi_select).setVisible(true)
menu.setHeaderTitle(selectedItem?.title)
holder.selectView.visibility = View.GONE
holder.coverImage.alpha = 1.0f
}
MenuItemUtils.setOnClickListeners(menu) { item: MenuItem ->
this@SubscriptionsFragment.onContextItemSelected(item)
holder.coverImage.setOnClickListener {
if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition))
else {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
(activity as MainActivity).loadChildFragment(fragment)
}
}
}
fun onContextItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.multi_select) {
holder.coverImage.setOnLongClickListener {
longPressedPosition = holder.bindingAdapterPosition
selectedItem = feed
startSelectMode(longPressedPosition)
return true
true
}
return false
}
fun setItems(listItems: List<Feed>) {
this.feedList = listItems
notifyDataSetChanged()
}
private inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = SubscriptionItemBinding.bind(itemView)
private val title = binding.titleLabel
private val producer = binding.producerLabel
val count: TextView = binding.countLabel
val coverImage: ImageView = binding.coverImage
val infoCard: LinearLayout = binding.infoCard
val selectView: FrameLayout = binding.selectContainer
val selectCheckbox: CheckBox = binding.selectCheckBox
private val card: CardView = binding.outerContainer
private val errorIcon: View = binding.errorIcon
fun bind(drawerItem: Feed) {
val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context, R.drawable.ic_checkbox_background)
selectView.background = drawable // Setting this in XML crashes API <= 21
title.text = drawerItem.title
producer.text = drawerItem.author
coverImage.contentDescription = drawerItem.title
coverImage.setImageDrawable(null)
val counter = drawerItem.episodes.size
count.text = NumberFormat.getInstance().format(counter.toLong()) + " episodes"
count.visibility = View.VISIBLE
val mainActRef = (activity as MainActivity)
val coverLoader = CoverLoader(mainActRef)
val feed: Feed = drawerItem
coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE
coverLoader.withCoverView(coverImage)
coverLoader.load()
val density: Float = mainActRef.resources.displayMetrics.density
card.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density))
val textHPadding = 20
val textVPadding = 5
title.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
producer.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
count.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
val textSize = 14
title.textSize = textSize.toFloat()
holder.itemView.setOnTouchListener { _: View?, e: MotionEvent ->
if (e.isFromSource(InputDevice.SOURCE_MOUSE) && e.buttonState == MotionEvent.BUTTON_SECONDARY) {
if (!inActionMode()) {
longPressedPosition = holder.bindingAdapterPosition
selectedItem = feed
}
}
false
}
holder.itemView.setOnClickListener {
if (inActionMode()) holder.selectCheckbox.setChecked(!isSelected(holder.bindingAdapterPosition))
else {
// val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
// mainActivityRef.get()?.loadChildFragment(fragment)
}
}
}
}
private inner class ViewHolderExpanded(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = SubscriptionItemBinding.bind(itemView)
val count: TextView = binding.countLabel
val coverImage: ImageView = binding.coverImage
val infoCard: LinearLayout = binding.infoCard
val selectView: FrameLayout = binding.selectContainer
val selectCheckbox: CheckBox = binding.selectCheckBox
private val errorIcon: View = binding.errorIcon
fun bind(drawerItem: Feed) {
val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context, R.drawable.ic_checkbox_background)
selectView.background = drawable // Setting this in XML crashes API <= 21
binding.titleLabel.text = drawerItem.title
binding.producerLabel.text = drawerItem.author
coverImage.contentDescription = drawerItem.title
coverImage.setImageDrawable(null)
val counter = drawerItem.episodes.size
count.text = NumberFormat.getInstance().format(counter.toLong()) + " episodes"
count.visibility = View.VISIBLE
val mainActRef = (activity as MainActivity)
val coverLoader = CoverLoader(mainActRef)
val feed: Feed = drawerItem
coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE
coverLoader.withCoverView(coverImage)
coverLoader.load()
val density: Float = mainActRef.resources.displayMetrics.density
binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density))
val textHPadding = 20
val textVPadding = 5
binding.titleLabel.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
binding.producerLabel.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
count.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
val textSize = 14
binding.titleLabel.textSize = textSize.toFloat()
}
}
private inner class ViewHolderBrief(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = SubscriptionItemBriefBinding.bind(itemView)
private val title = binding.titleLabel
val count: TextView = binding.countLabel
val coverImage: ImageView = binding.coverImage
val selectView: FrameLayout = binding.selectContainer
val selectCheckbox: CheckBox = binding.selectCheckBox
private val errorIcon: View = binding.errorIcon
fun bind(drawerItem: Feed) {
val drawable: Drawable? = AppCompatResources.getDrawable(selectView.context, R.drawable.ic_checkbox_background)
selectView.background = drawable // Setting this in XML crashes API <= 21
title.text = drawerItem.title
coverImage.contentDescription = drawerItem.title
coverImage.setImageDrawable(null)
val counter = drawerItem.episodes.size
count.text = NumberFormat.getInstance().format(counter.toLong())
count.visibility = View.VISIBLE
val mainActRef = (activity as MainActivity)
val coverLoader = CoverLoader(mainActRef)
val feed: Feed = drawerItem
coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE
coverLoader.withCoverView(coverImage)
coverLoader.load()
val density: Float = mainActRef.resources.displayMetrics.density
binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density))
val textHPadding = 20
val textVPadding = 5
title.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
count.setPadding(textHPadding, textVPadding, textHPadding, textVPadding)
val textSize = 14
title.textSize = textSize.toFloat()
}
}

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

View File

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

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.VideoPlayerActivityStarter
import ac.mdiq.podcini.util.Converter.getDurationStringLong
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.TimeSpeedConverter
import android.appwidget.AppWidgetManager
import android.content.ComponentName
@ -45,11 +46,12 @@ object WidgetUpdater {
* Update the widgets with the given parameters. Must be called in a background thread.
*/
fun updateWidget(context: Context, widgetState: WidgetState?) {
if (!isEnabled(context) || widgetState == null) return
if (!isEnabled() || widgetState == null) return
Logd(TAG, "in updateWidget")
val startMediaPlayer = if (widgetState.media != null && widgetState.media.getMediaType() === MediaType.VIDEO)
VideoPlayerActivityStarter(context).pendingIntent
else MainActivityStarter(context).withOpenPlayer().pendingIntent
val startMediaPlayer =
if (widgetState.media != null && widgetState.media.getMediaType() === MediaType.VIDEO) VideoPlayerActivityStarter(context).pendingIntent
else MainActivityStarter(context).withOpenPlayer().pendingIntent
val startPlaybackSpeedDialog = PlaybackSpeedActivityStarter(context).pendingIntent
val views = RemoteViews(context.packageName, R.layout.player_widget)
@ -61,26 +63,9 @@ object WidgetUpdater {
views.setOnClickPendingIntent(R.id.imgvCover, startMediaPlayer)
views.setOnClickPendingIntent(R.id.butPlaybackSpeed, startPlaybackSpeedDialog)
val radius = context.resources.getDimensionPixelSize(R.dimen.widget_inner_radius)
// val options = RequestOptions()
// .dontAnimate()
// .transform(FitCenter(), RoundedCorners(radius))
try {
val imgLoc = widgetState.media.getImageLocation()
val imgLoc1 = getFallbackImageLocation(widgetState.media)
// icon = Glide.with(context)
// .asBitmap()
// .load(imgLoc)
// .error(Glide.with(context)
// .asBitmap()
// .load(imgLoc1)
// .apply(options)
// .submit(iconSize, iconSize)[500, TimeUnit.MILLISECONDS])
// .apply(options)
// .submit(iconSize, iconSize)
// .get(500, TimeUnit.MILLISECONDS)
CoroutineScope(Dispatchers.IO).launch {
val request = ImageRequest.Builder(context)
.data(imgLoc)
@ -162,6 +147,7 @@ object WidgetUpdater {
val widgetIds = manager.getAppWidgetIds(playerWidget)
for (id in widgetIds) {
Logd(TAG, "updating widget $id")
val options = manager.getAppWidgetOptions(id)
// val prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE)
val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)

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.Episode
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.view.KeyEvent
import androidx.core.util.Consumer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
@ -26,7 +28,7 @@ import kotlin.math.max
sealed class FlowEvent {
val TAG = this::class.simpleName ?: "FlowEvent"
data class PlaybackPositionEvent(val position: Int, val duration: Int) : FlowEvent()
data class PlaybackPositionEvent(val media: Playable?, val position: Int, val duration: Int) : FlowEvent()
data class PlaybackServiceEvent(val action: Action) : FlowEvent() {
enum class Action { SERVICE_STARTED, SERVICE_SHUT_DOWN, }
@ -124,34 +126,25 @@ sealed class FlowEvent {
}
}
data class FeedListUpdateEvent(val feedIds: List<Long> = emptyList()) : FlowEvent() {
constructor(feed: Feed) : this(listOf(feed.id))
constructor(feedId: Long) : this(listOf(feedId))
constructor(feeds: List<Feed>, junk: String = "") : this(feeds.map { it.id })
data class FeedListEvent(val action: Action, val feedIds: List<Long> = emptyList()) : FlowEvent() {
enum class Action { ADDED, REMOVED, ERROR, UNKNOWN }
constructor(action: Action, feedId: Long) : this(action, listOf(feedId))
fun contains(feed: Feed): Boolean {
return feedIds.contains(feed.id)
}
}
data class FeedsSortedEvent(val dummy: Unit = Unit) : FlowEvent()
// data class SkipIntroEndingChangedEvent(val skipIntro: Int, val skipEnding: Int, val feedId: Long) : FlowEvent()
data class VolumeAdaptionChangedEvent(val volumeAdaptionSetting: VolumeAdaptionSetting, val feedId: Long) : FlowEvent()
// handled together in FeedPrefsChangeEvent
// data class VolumeAdaptionChangedEvent(val volumeAdaptionSetting: VolumeAdaptionSetting, val feedId: Long) : FlowEvent()
data class FeedPrefsChangeEvent(val feed: Feed) : FlowEvent()
// TODO: consider merging the two
data class SpeedChangedEvent(val newSpeed: Float) : FlowEvent()
data class FeedPrefsChangeEvent(val prefs: FeedPreferences) : FlowEvent()
data class EpisodesFilterOrSortEvent(val action: Action, val feed: Feed) : FlowEvent() {
enum class Action { FILTER_CHANGED, SORT_ORDER_CHANGED }
override fun toString(): String {
return ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("action", action)
.append("feedId", feed.id)
.toString()
}
}
data class DownloadLogEvent(val dummy: Unit = Unit) : FlowEvent()
@ -160,8 +153,6 @@ sealed class FlowEvent {
get() = map.keys
}
// data class NewEpisodeDownloadEvent(val url: String) : FlowEvent() {}
// TODO: need better handling at receving end
data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent()
@ -180,8 +171,6 @@ sealed class FlowEvent {
}
}
data class FeedsSortedEvent(val dummy: Unit = Unit) : FlowEvent()
data class FeedTagsChangedEvent(val dummy: Unit = Unit) : FlowEvent()
data class FeedUpdateRunningEvent(val isFeedUpdateRunning: Boolean) : FlowEvent()
@ -195,12 +184,9 @@ sealed class FlowEvent {
data class SyncServiceEvent(val messageResId: Int, val message: String = "") : FlowEvent()
data class DiscoveryDefaultUpdateEvent(val dummy: Unit = Unit) : FlowEvent()
data class DiscoveryCompletedEvent(val dummy: Unit = Unit) : FlowEvent()
}
object EventFlow {
val collectorCount = MutableStateFlow(0)
val events: MutableSharedFlow<FlowEvent> = MutableSharedFlow(replay = 0)
val stickyEvents: MutableSharedFlow<FlowEvent> = MutableSharedFlow(replay = 1)
val keyEvents: MutableSharedFlow<KeyEvent> = MutableSharedFlow(replay = 0)
@ -211,7 +197,7 @@ object EventFlow {
val caller = if (stackTrace.size > 3) stackTrace[3] else null
Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted: $event")
}
GlobalScope.launch(Dispatchers.Default) {
CoroutineScope(Dispatchers.Default).launch {
events.emit(event)
}
}
@ -222,7 +208,7 @@ object EventFlow {
val caller = if (stackTrace.size > 3) stackTrace[3] else null
Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted sticky: $event")
}
GlobalScope.launch(Dispatchers.Default) {
CoroutineScope(Dispatchers.Default).launch {
stickyEvents.emit(event)
}
}
@ -233,7 +219,7 @@ object EventFlow {
val caller = if (stackTrace.size > 3) stackTrace[3] else null
Logd("EventFlow", "${caller?.className}.${caller?.methodName} posted key: $event")
}
GlobalScope.launch(Dispatchers.Default) {
CoroutineScope(Dispatchers.Default).launch {
keyEvents.emit(event)
}
}

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
when (sortOrder) {
SortOrder.EPISODE_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? ->
itemTitle(f1).compareTo(itemTitle(f2))
}
SortOrder.EPISODE_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? ->
itemTitle(f2).compareTo(itemTitle(f1))
}
SortOrder.DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? ->
pubDate(f1).compareTo(pubDate(f2))
}
SortOrder.DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? ->
pubDate(f2).compareTo(pubDate(f1))
}
SortOrder.DURATION_SHORT_LONG -> comparator = Comparator { f1: Episode?, f2: Episode? ->
duration(f1).compareTo(duration(f2))
}
SortOrder.DURATION_LONG_SHORT -> comparator = Comparator { f1: Episode?, f2: Episode? ->
duration(f2).compareTo(duration(f1))
}
SortOrder.EPISODE_FILENAME_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? ->
itemLink(f1).compareTo(itemLink(f2))
}
SortOrder.EPISODE_FILENAME_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? ->
itemLink(f2).compareTo(itemLink(f1))
}
SortOrder.PLAYED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? ->
playDate(f1).compareTo(playDate(f2))
}
SortOrder.PLAYED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? ->
playDate(f2).compareTo(playDate(f1))
}
SortOrder.COMPLETED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? ->
completeDate(f1).compareTo(completeDate(f2))
}
SortOrder.COMPLETED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? ->
completeDate(f2).compareTo(completeDate(f1))
}
SortOrder.EPISODE_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemTitle(f1).compareTo(itemTitle(f2)) }
SortOrder.EPISODE_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemTitle(f2).compareTo(itemTitle(f1)) }
SortOrder.DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> pubDate(f1).compareTo(pubDate(f2)) }
SortOrder.DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> pubDate(f2).compareTo(pubDate(f1)) }
SortOrder.DURATION_SHORT_LONG -> comparator = Comparator { f1: Episode?, f2: Episode? -> duration(f1).compareTo(duration(f2)) }
SortOrder.DURATION_LONG_SHORT -> comparator = Comparator { f1: Episode?, f2: Episode? -> duration(f2).compareTo(duration(f1)) }
SortOrder.EPISODE_FILENAME_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemLink(f1).compareTo(itemLink(f2)) }
SortOrder.EPISODE_FILENAME_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> itemLink(f2).compareTo(itemLink(f1)) }
SortOrder.PLAYED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> playDate(f1).compareTo(playDate(f2)) }
SortOrder.PLAYED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> playDate(f2).compareTo(playDate(f1)) }
SortOrder.COMPLETED_DATE_OLD_NEW -> comparator = Comparator { f1: Episode?, f2: Episode? -> completeDate(f1).compareTo(completeDate(f2)) }
SortOrder.COMPLETED_DATE_NEW_OLD -> comparator = Comparator { f1: Episode?, f2: Episode? -> completeDate(f2).compareTo(completeDate(f1)) }
SortOrder.FEED_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? ->
feedTitle(f1).compareTo(feedTitle(f2))
}
SortOrder.FEED_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? ->
feedTitle(f2).compareTo(feedTitle(f1))
}
SortOrder.FEED_TITLE_A_Z -> comparator = Comparator { f1: Episode?, f2: Episode? -> feedTitle(f1).compareTo(feedTitle(f2)) }
SortOrder.FEED_TITLE_Z_A -> comparator = Comparator { f1: Episode?, f2: Episode? -> feedTitle(f2).compareTo(feedTitle(f1)) }
SortOrder.RANDOM -> permutor = object : Permutor<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> {
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> {
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))
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)) }
}
if (comparator != null) {
val comparator2: Comparator<Episode> = comparator
@ -87,7 +61,6 @@ object EpisodesPermutors {
return permutor!!
}
// Null-safe accessors
private fun pubDate(item: Episode?): Date {
return if (item == null) Date() else Date(item.pubDate)
}
@ -154,9 +127,9 @@ object EpisodesPermutors {
}
// Sort each individual list by PubDate (ascending/descending)
val itemComparator: Comparator<Episode> = if (ascending)
Comparator { f1: Episode, f2: Episode -> f1.pubDate?.compareTo(f2.pubDate)?:-1 }
else Comparator { f1: Episode, f2: Episode -> f2.pubDate?.compareTo(f1.pubDate)?:-1 }
val itemComparator: Comparator<Episode> =
if (ascending) Comparator { f1: Episode, f2: Episode -> f1.pubDate?.compareTo(f2.pubDate)?:-1 }
else Comparator { f1: Episode, f2: Episode -> f2.pubDate?.compareTo(f1.pubDate)?:-1 }
val feeds: MutableList<List<Episode>> = ArrayList()
for ((_, value) in map) {
@ -190,4 +163,18 @@ object EpisodesPermutors {
}
}
}
/**
* Interface for passing around list permutor method. This is used for cases where a simple comparator
* won't work (e.g. Random, Smart Shuffle, etc).
*
* @param <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