6.0.5 commit

This commit is contained in:
Xilin Jia 2024-07-07 11:13:43 +01:00
parent a00376bb45
commit 27f5f5e95a
70 changed files with 1645 additions and 1914 deletions

View File

@ -125,8 +125,8 @@ android {
buildConfig true
}
defaultConfig {
versionCode 3020204
versionName "6.0.4"
versionCode 3020205
versionName "6.0.5"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -6,7 +6,6 @@ import ac.mdiq.podcini.net.download.service.HttpDownloader
import ac.mdiq.podcini.net.download.serviceinterface.DownloadRequest
import ac.mdiq.podcini.preferences.UserPreferences.init
import ac.mdiq.podcini.util.Logd
import ac.test.podcini.service.download.FeedComponent
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import de.test.podcini.util.service.download.HTTPBin
@ -245,3 +244,48 @@ abstract class FeedFile(@JvmField var file_url: String? = null,
this.downloaded = downloaded
}
}
/**
* Represents every possible component of a feed
*
* @author daniel
*/
// only used in test
abstract class FeedComponent internal constructor() {
open var id: Long = 0
/**
* Update this FeedComponent's attributes with the attributes from another
* FeedComponent. This method should only update attributes which where read from
* the feed.
*/
fun updateFromOther(other: FeedComponent?) {}
/**
* Compare's this FeedComponent's attribute values with another FeedComponent's
* attribute values. This method will only compare attributes which were
* read from the feed.
*
* @return true if attribute values are different, false otherwise
*/
fun compareWithOther(other: FeedComponent?): Boolean {
return false
}
/**
* Should return a non-null, human-readable String so that the item can be
* identified by the user. Can be title, download-url, etc.
*/
abstract fun getHumanReadableIdentifier(): String?
override fun equals(o: Any?): Boolean {
if (this === o) return true
if (o !is FeedComponent) return false
return id == o.id
}
override fun hashCode(): Int {
return (id xor (id ushr 32)).toInt()
}
}

View File

@ -38,6 +38,7 @@ import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.apache.commons.io.FileUtils
import java.io.File
import java.io.IOException
@ -114,7 +115,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
.addTag(WORK_TAG)
.addTag(WORK_TAG_EPISODE_URL + item.media!!.downloadUrl)
if (UserPreferences.enqueueDownloadedEpisodes()) {
Queues.addToQueue(false, item)
runBlocking { Queues.addToQueueSync(false, item) }
workRequest.addTag(WORK_DATA_WAS_QUEUED)
}
workRequest.setInputData(Data.Builder().putLong(WORK_DATA_MEDIA_ID, item.media!!.id).build())
@ -388,10 +389,11 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
if (item != null) {
item.disableAutoDownload()
Logd(TAG, "persisting episode downloaded ${item.title} ${item.media?.fileUrl} ${item.media?.downloaded}")
// setFeedItem() signals (via EventBus) that the item has been updated,
// setFeedItem() signals that the item has been updated,
// so we do it after the enclosing media has been updated above,
// to ensure subscribers will get the updated EpisodeMedia as well
Episodes.persistEpisode(item)
// TODO: should use different event?
if (broadcastUnreadStateUpdate) EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(item))
}
} catch (e: InterruptedException) {

View File

@ -15,10 +15,9 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi
import ac.mdiq.podcini.net.utils.NetworkUtils.networkAvailable
import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia
import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.database.Feeds
import ac.mdiq.podcini.storage.database.LogsAndStats
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.model.DownloadResult
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting
@ -206,7 +205,7 @@ object FeedUpdateManager {
while (toUpdate.isNotEmpty()) {
if (isStopped) return
notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate))
val feed = unmanagedCopy(toUpdate[0])
val feed = unmanaged(toUpdate[0])
try {
Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}")
if (feed.isLocalFeed) LocalFeedUpdater.updateFeed(feed, applicationContext, null)

View File

@ -21,7 +21,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileSync
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl
import ac.mdiq.podcini.storage.database.Episodes.getEpisodes
import ac.mdiq.podcini.storage.database.Episodes.persistEpisodes
import ac.mdiq.podcini.storage.database.Feeds.deleteFeed
import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.getFeedListDownloadUrls
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
@ -44,11 +44,8 @@ import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.work.*
import androidx.work.Constraints.Builder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.apache.commons.lang3.StringUtils
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
@ -168,7 +165,10 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont
}
if (feedID != null) {
try {
deleteFeed(context, feedID)
runBlocking {
deleteFeedSync(context, feedID)
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feedID))
}
} catch (e: InterruptedException) {
e.printStackTrace()
} catch (e: ExecutionException) {
@ -185,7 +185,7 @@ open class SyncService(context: Context, params: WorkerParameters) : Worker(cont
EventFlow.stickyEvents.collectLatest { event ->
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.FeedUpdateRunningEvent -> if (!event.isFeedUpdateRunning) return@collectLatest
is FlowEvent.FeedUpdatingEvent -> if (!event.isRunning) return@collectLatest
else -> {}
}
}

View File

@ -9,6 +9,7 @@ import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.EXTRA_ALLOW_STREAM_THIS_TIME
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
@ -44,8 +45,12 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl
fun start() {
Logd("PlaybackServiceStarter", "starting PlaybackService")
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END))
curMedia = media
if (media is EpisodeMedia) curEpisode = media.episode
if (media is EpisodeMedia) {
curMedia = media
// curEpisode = if (media.episode != null) unmanaged(media.episode!!) else null
curEpisode = media.episode
// curMedia = curEpisode?.media
} else curMedia = media
if (PlaybackService.isRunning && !callEvenIfRunning) return
ContextCompat.startForegroundService(context, intent)

View File

@ -18,15 +18,15 @@ import kotlinx.coroutines.*
object InTheatre {
val TAG: String = InTheatre::class.simpleName ?: "Anonymous"
var curQueue: PlayQueue
var curQueue: PlayQueue // unmanaged
var curEpisode: Episode? = null
var curEpisode: Episode? = null // unmanged
set(value) {
field = value
if (curMedia != field?.media) curMedia = field?.media
}
var curMedia: Playable? = null
var curMedia: Playable? = null // unmanged if EpisodeMedia
set(value) {
field = value
if (field is EpisodeMedia) {
@ -35,7 +35,7 @@ object InTheatre {
}
}
var curState: CurrentState
var curState: CurrentState // unmanaged
init {
curQueue = PlayQueue()
@ -60,9 +60,7 @@ object InTheatre {
curQueue_.id = i.toLong()
curQueue_.name = "Queue $i"
}
realm.write {
copyToRealm(curQueue_)
}
upsert(curQueue_) {}
}
curQueue.update()
upsert(curQueue) {}
@ -75,9 +73,7 @@ object InTheatre {
Logd(TAG, "creating new curState")
curState_ = CurrentState()
curState = curState_
realm.write {
copyToRealm(curState_)
}
upsert(curState_) {}
}
loadPlayableFromPreferences()
}

View File

@ -238,8 +238,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
}
// stop playback of this episode
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
prevMedia = curMedia
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}

View File

@ -32,18 +32,20 @@ import ac.mdiq.podcini.preferences.UserPreferences.isPersistNotify
import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnBluetoothReconnect
import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnHeadsetReconnect
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.preferences.UserPreferences.shouldAutoDeleteItem
import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.preferences.UserPreferences.shouldFavoriteKeepEpisode
import ac.mdiq.podcini.preferences.UserPreferences.shouldSkipKeepEpisode
import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.database.Episodes.addToHistory
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Episodes.persistEpisode
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItemsOnFeed
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.CurrentState.Companion.NO_MEDIA_PLAYING
import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_OTHER
@ -292,7 +294,7 @@ class PlaybackService : MediaSessionService() {
// TODO: test
// return
}
val item = (playable as? EpisodeMedia)?.episode ?: currentitem
var item = (playable as? EpisodeMedia)?.episode ?: currentitem
val smartMarkAsPlayed = hasAlmostEnded(playable)
if (!ended && smartMarkAsPlayed) Logd(TAG, "smart mark as played")
@ -311,22 +313,21 @@ class PlaybackService : MediaSessionService() {
}
}
if (item != null) {
if (ended || smartMarkAsPlayed || autoSkipped || (skipped && !shouldSkipKeepEpisode())) {
Logd(TAG, "onPostPlayback ended: $ended smartMarkAsPlayed: $smartMarkAsPlayed autoSkipped: $autoSkipped skipped: $skipped")
// only mark the item as played if we're not keeping it anyways
markPlayed(Episode.PLAYED, ended || (skipped && smartMarkAsPlayed), item)
// don't know if it actually matters to not autodownload when smart mark as played is triggered
// removeFromQueue(this@PlaybackService, ended, item)
// Delete episode if enabled
val action = item.feed?.preferences?.currentAutoDelete
val shouldAutoDelete = (action == AutoDeleteAction.ALWAYS
|| (action == AutoDeleteAction.GLOBAL && item.feed != null && shouldAutoDeleteItemsOnFeed(item.feed!!)))
if (playable is EpisodeMedia && shouldAutoDelete && (!item.isFavorite || !shouldFavoriteKeepEpisode())) {
deleteMediaOfEpisode(this@PlaybackService, item)
Logd(TAG, "Episode Deleted")
runOnIOScope {
if (ended || smartMarkAsPlayed || autoSkipped || (skipped && !shouldSkipKeepEpisode())) {
Logd(TAG, "onPostPlayback ended: $ended smartMarkAsPlayed: $smartMarkAsPlayed autoSkipped: $autoSkipped skipped: $skipped")
// only mark the item as played if we're not keeping it anyways
item = setPlayStateSync(Episode.PLAYED, ended || (skipped && smartMarkAsPlayed), item!!)
val action = item?.feed?.preferences?.currentAutoDelete
val shouldAutoDelete = (action == AutoDeleteAction.ALWAYS ||
(action == AutoDeleteAction.GLOBAL && item?.feed != null && shouldAutoDeleteItem(item!!.feed!!)))
if (playable is EpisodeMedia && shouldAutoDelete && (item?.isFavorite != true || !shouldFavoriteKeepEpisode())) {
item = deleteMediaSync(this@PlaybackService, item!!)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item!!)
}
}
if (playable is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item!!)
}
if (playable is EpisodeMedia && (ended || skipped || playingNext)) addToHistory(item)
}
}
@ -425,9 +426,8 @@ class PlaybackService : MediaSessionService() {
fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) {
Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}")
if (playable == null) {
writeNoMediaPlaying()
} else {
if (playable == null) writeNoMediaPlaying()
else {
curState.curMediaType = playable.getPlayableType().toLong()
curState.curIsVideo = playable.getMediaType() == MediaType.VIDEO
if (playable is EpisodeMedia) {

View File

@ -24,12 +24,9 @@ object ThemeSwitcher {
val dynamic = UserPreferences.isThemeColorTinted
return when (readThemeValue(context)) {
UserPreferences.ThemePreference.DARK -> if (dynamic) R.style.Theme_Podcini_Dynamic_Dark_NoTitle else R.style.Theme_Podcini_Dark_NoTitle
UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_NoTitle
else R.style.Theme_Podcini_TrueBlack_NoTitle
UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle
else R.style.Theme_Podcini_Light_NoTitle
else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle
else R.style.Theme_Podcini_Light_NoTitle
UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_NoTitle else R.style.Theme_Podcini_TrueBlack_NoTitle
UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle else R.style.Theme_Podcini_Light_NoTitle
else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_NoTitle else R.style.Theme_Podcini_Light_NoTitle
}
}
@ -38,14 +35,10 @@ object ThemeSwitcher {
fun getTranslucentTheme(context: Context): Int {
val dynamic = UserPreferences.isThemeColorTinted
return when (readThemeValue(context)) {
UserPreferences.ThemePreference.DARK -> if (dynamic) R.style.Theme_Podcini_Dynamic_Dark_Translucent
else R.style.Theme_Podcini_Dark_Translucent
UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_Translucent
else R.style.Theme_Podcini_TrueBlack_Translucent
UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent
else R.style.Theme_Podcini_Light_Translucent
else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent
else R.style.Theme_Podcini_Light_Translucent
UserPreferences.ThemePreference.DARK -> if (dynamic) R.style.Theme_Podcini_Dynamic_Dark_Translucent else R.style.Theme_Podcini_Dark_Translucent
UserPreferences.ThemePreference.BLACK -> if (dynamic) R.style.Theme_Podcini_Dynamic_TrueBlack_Translucent else R.style.Theme_Podcini_TrueBlack_Translucent
UserPreferences.ThemePreference.LIGHT -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent else R.style.Theme_Podcini_Light_Translucent
else -> if (dynamic) R.style.Theme_Podcini_Dynamic_Light_Translucent else R.style.Theme_Podcini_Light_Translucent
}
}

View File

@ -1,5 +1,6 @@
package ac.mdiq.podcini.preferences
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.ProxyConfig
import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.storage.utils.MediaType
@ -66,6 +67,7 @@ object UserPreferences {
private const val PREF_HARDWARE_PREVIOUS_BUTTON: String = "prefHardwarePreviousButton"
const val PREF_FOLLOW_QUEUE: String = "prefFollowQueue"
const val PREF_SKIP_KEEPS_EPISODE: String = "prefSkipKeepsEpisode"
const val PREF_REMOVDE_FROM_QUEUE_MARKED_PLAYED: String = "prefRemoveFromQueueMarkedPlayed"
private const val PREF_FAVORITE_KEEPS_EPISODE = "prefFavoriteKeepsEpisode"
private const val PREF_AUTO_DELETE = "prefAutoDelete"
private const val PREF_AUTO_DELETE_LOCAL = "prefAutoDeleteLocal"
@ -139,18 +141,6 @@ object UserPreferences {
private lateinit var context: Context
lateinit var appPrefs: SharedPreferences
/**
* Sets up the UserPreferences class.
*
* @throws IllegalArgumentException if context is null
*/
fun init(context: Context) {
Logd(TAG, "Creating new instance of UserPreferences")
UserPreferences.context = context.applicationContext
appPrefs = PreferenceManager.getDefaultSharedPreferences(context)
createNoMediaFile()
}
var theme: ThemePreference
get() = when (appPrefs.getString(PREF_THEME, "system")) {
@ -203,30 +193,6 @@ object UserPreferences {
val isAutoBackupOPML: Boolean
get() = appPrefs.getBoolean(PREF_OPML_BACKUP, true)
/**
* Helper function to return whether the specified button should be shown on full
* notifications.
*
* @param buttonId Either NOTIFICATION_BUTTON_REWIND, NOTIFICATION_BUTTON_FAST_FORWARD,
* NOTIFICATION_BUTTON_SKIP, NOTIFICATION_BUTTON_PLAYBACK_SPEED
* or NOTIFICATION_BUTTON_NEXT_CHAPTER.
* @return `true` if button should be shown, `false` otherwise
*/
private fun showButtonOnFullNotification(buttonId: Int): Boolean {
return fullNotificationButtons.contains(buttonId)
}
fun showSkipOnFullNotification(): Boolean {
return showButtonOnFullNotification(NOTIFICATION_BUTTON_SKIP)
}
fun showNextChapterOnFullNotification(): Boolean {
return showButtonOnFullNotification(NOTIFICATION_BUTTON_NEXT_CHAPTER)
}
fun showPlaybackSpeedOnFullNotification(): Boolean {
return showButtonOnFullNotification(NOTIFICATION_BUTTON_PLAYBACK_SPEED)
}
val feedOrder: Int
get() {
@ -234,12 +200,6 @@ object UserPreferences {
return value!!.toInt()
}
fun setFeedOrder(selected: String?) {
appPrefs.edit()
.putString(PREF_DRAWER_FEED_ORDER, selected)
.apply()
}
val useGridLayout: Boolean
get() = appPrefs.getBoolean(PREF_FEED_GRID_LAYOUT, false)
@ -249,24 +209,6 @@ object UserPreferences {
val useEpisodeCoverSetting: Boolean
get() = appPrefs.getBoolean(PREF_USE_EPISODE_COVER, true)
/**
* @return `true` if we should show remaining time or the duration
*/
fun shouldShowRemainingTime(): Boolean {
return appPrefs.getBoolean(PREF_SHOW_TIME_LEFT, false)
}
/**
* Sets the preference for whether we show the remain time, if not show the duration. This will
* send out events so the current playing screen, queue and the episode list would refresh
*
* @return `true` if we should show remaining time or the duration
*/
fun setShowRemainTimeSetting(showRemain: Boolean?) {
appPrefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain!!).apply()
}
/**
* @return `true` if notifications are persistent, `false` otherwise
*/
@ -279,10 +221,6 @@ object UserPreferences {
val showDownloadReportRaw: Boolean
get() = appPrefs.getBoolean(PREF_SHOW_DOWNLOAD_REPORT, true)
fun enqueueDownloadedEpisodes(): Boolean {
return appPrefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true)
}
var enqueueLocation: EnqueueLocation
get() {
val valStr = appPrefs.getString(PREF_ENQUEUE_LOCATION, EnqueueLocation.BACK.name)
@ -323,14 +261,6 @@ object UserPreferences {
appPrefs.edit().putBoolean(PREF_FOLLOW_QUEUE, value).apply()
}
fun shouldSkipKeepEpisode(): Boolean {
return appPrefs.getBoolean(PREF_SKIP_KEEPS_EPISODE, true)
}
fun shouldFavoriteKeepEpisode(): Boolean {
return appPrefs.getBoolean(PREF_FAVORITE_KEEPS_EPISODE, true)
}
val isAutoDelete: Boolean
get() = appPrefs.getBoolean(PREF_AUTO_DELETE, false)
@ -340,14 +270,6 @@ object UserPreferences {
val smartMarkAsPlayedSecs: Int
get() = appPrefs.getString(PREF_SMART_MARK_AS_PLAYED_SECS, "30")!!.toInt()
fun shouldDeleteRemoveFromQueue(): Boolean {
return appPrefs.getBoolean(PREF_DELETE_REMOVES_FROM_QUEUE, false)
}
fun getPlaybackSpeed(mediaType: MediaType): Float {
return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed
}
private val audioPlaybackSpeed: Float
get() {
try {
@ -405,23 +327,12 @@ object UserPreferences {
appPrefs.edit().putString(PREF_PLAYBACK_SPEED_ARRAY, jsonArray.toString()).apply()
}
fun shouldPauseForFocusLoss(): Boolean {
return appPrefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true)
}
val updateInterval: Long
get() = appPrefs.getString(PREF_UPDATE_INTERVAL, "12")!!.toInt().toLong()
val isAutoUpdateDisabled: Boolean
get() = updateInterval == 0L
private fun isAllowMobileFor(type: String): Boolean {
val defaultValue = HashSet<String>()
defaultValue.add("images")
val allowed = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue)
return allowed!!.contains(type)
}
var isAllowMobileFeedRefresh: Boolean
get() = isAllowMobileFor("feed_refresh")
set(allow) {
@ -458,17 +369,6 @@ object UserPreferences {
setAllowMobileFor("images", allow)
}
private fun setAllowMobileFor(type: String, allow: Boolean) {
val defaultValue = HashSet<String>()
defaultValue.add("images")
val getValueStringSet = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue)
val allowed: MutableSet<String> = HashSet(getValueStringSet!!)
if (allow) allowed.add(type)
else allowed.remove(type)
appPrefs.edit().putStringSet(PREF_MOBILE_UPDATE, allowed).apply()
}
/**
* Returns the capacity of the episode cache. This method will return the
* negative integer EPISODE_CACHE_SIZE_UNLIMITED if the cache size is set to
@ -569,6 +469,203 @@ object UserPreferences {
appPrefs.edit().putBoolean(PREF_QUEUE_LOCKED, locked).apply()
}
/**
* Used for migration of the preference to system notification channels.
*/
val gpodnetNotificationsEnabledRaw: Boolean
get() = appPrefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true)
var episodeCleanupValue: Int
get() = appPrefs.getString(PREF_EPISODE_CLEANUP, "" + EPISODE_CLEANUP_NULL)!!.toInt()
set(episodeCleanupValue) {
appPrefs.edit().putString(PREF_EPISODE_CLEANUP, episodeCleanupValue.toString()).apply()
}
var defaultPage: String?
get() = appPrefs.getString(PREF_DEFAULT_PAGE, "SubscriptionsFragment")
set(defaultPage) {
appPrefs.edit().putString(PREF_DEFAULT_PAGE, defaultPage).apply()
}
var isStreamOverDownload: Boolean
get() = appPrefs.getBoolean(PREF_STREAM_OVER_DOWNLOAD, false)
set(stream) {
appPrefs.edit().putBoolean(PREF_STREAM_OVER_DOWNLOAD, stream).apply()
}
var isQueueKeepSorted: Boolean
/**
* Returns if the queue is in keep sorted mode.
* @see .queueKeepSortedOrder
*/
get() = appPrefs.getBoolean(PREF_QUEUE_KEEP_SORTED, false)
/**
* Enables/disables the keep sorted mode of the queue.
* @see .queueKeepSortedOrder
*/
set(keepSorted) {
appPrefs.edit().putBoolean(PREF_QUEUE_KEEP_SORTED, keepSorted).apply()
}
var queueKeepSortedOrder: SortOrder?
/**
* Returns the sort order for the queue keep sorted mode.
* Note: This value is stored independently from the keep sorted state.
* @see .isQueueKeepSorted
*/
get() {
val sortOrderStr = appPrefs.getString(PREF_QUEUE_KEEP_SORTED_ORDER, "use-default")
return SortOrder.parseWithDefault(sortOrderStr, SortOrder.DATE_NEW_OLD)
}
/**
* Sets the sort order for the queue keep sorted mode.
* @see .setQueueKeepSorted
*/
set(sortOrder) {
if (sortOrder == null) return
appPrefs.edit().putString(PREF_QUEUE_KEEP_SORTED_ORDER, sortOrder.name).apply()
}
// the sort order for the downloads.
var downloadsSortedOrder: SortOrder?
get() {
val sortOrderStr = appPrefs.getString(PREF_DOWNLOADS_SORTED_ORDER, "" + SortOrder.DATE_NEW_OLD.code)
return SortOrder.fromCodeString(sortOrderStr)
}
set(sortOrder) {
appPrefs.edit().putString(PREF_DOWNLOADS_SORTED_ORDER, "" + sortOrder!!.code).apply()
}
var allEpisodesSortOrder: SortOrder?
get() = SortOrder.fromCodeString(appPrefs.getString(PREF_SORT_ALL_EPISODES, "" + SortOrder.DATE_NEW_OLD.code))
set(s) {
appPrefs.edit().putString(PREF_SORT_ALL_EPISODES, "" + s!!.code).apply()
}
var prefFilterAllEpisodes: String
get() = appPrefs.getString(PREF_FILTER_ALL_EPISODES, "")?:""
set(filter) {
appPrefs.edit().putString(PREF_FILTER_ALL_EPISODES, filter).apply()
}
/**
* Sets up the UserPreferences class.
* @throws IllegalArgumentException if context is null
*/
fun init(context: Context) {
Logd(TAG, "Creating new instance of UserPreferences")
UserPreferences.context = context.applicationContext
appPrefs = PreferenceManager.getDefaultSharedPreferences(context)
createNoMediaFile()
}
/**
* Helper function to return whether the specified button should be shown on full
* notifications.
* @param buttonId Either NOTIFICATION_BUTTON_REWIND, NOTIFICATION_BUTTON_FAST_FORWARD,
* NOTIFICATION_BUTTON_SKIP, NOTIFICATION_BUTTON_PLAYBACK_SPEED
* or NOTIFICATION_BUTTON_NEXT_CHAPTER.
* @return `true` if button should be shown, `false` otherwise
*/
private fun showButtonOnFullNotification(buttonId: Int): Boolean {
return fullNotificationButtons.contains(buttonId)
}
@JvmStatic
fun shouldAutoDeleteItem(feed: Feed): Boolean {
if (!isAutoDelete) return false
return !feed.isLocalFeed || isAutoDeleteLocal
}
fun showSkipOnFullNotification(): Boolean {
return showButtonOnFullNotification(NOTIFICATION_BUTTON_SKIP)
}
fun showNextChapterOnFullNotification(): Boolean {
return showButtonOnFullNotification(NOTIFICATION_BUTTON_NEXT_CHAPTER)
}
fun showPlaybackSpeedOnFullNotification(): Boolean {
return showButtonOnFullNotification(NOTIFICATION_BUTTON_PLAYBACK_SPEED)
}
/**
* @return `true` if we should show remaining time or the duration
*/
fun shouldShowRemainingTime(): Boolean {
return appPrefs.getBoolean(PREF_SHOW_TIME_LEFT, false)
}
fun setFeedOrder(selected: String?) {
appPrefs.edit()
.putString(PREF_DRAWER_FEED_ORDER, selected)
.apply()
}
fun enqueueDownloadedEpisodes(): Boolean {
return appPrefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true)
}
/**
* Sets the preference for whether we show the remain time, if not show the duration. This will
* send out events so the current playing screen, queue and the episode list would refresh
* @return `true` if we should show remaining time or the duration
*/
fun setShowRemainTimeSetting(showRemain: Boolean?) {
appPrefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain!!).apply()
}
fun shouldSkipKeepEpisode(): Boolean {
return appPrefs.getBoolean(PREF_SKIP_KEEPS_EPISODE, true)
}
fun shouldRemoveFromQueuesMarkPlayed(): Boolean {
return appPrefs.getBoolean(PREF_REMOVDE_FROM_QUEUE_MARKED_PLAYED, true)
}
fun shouldFavoriteKeepEpisode(): Boolean {
return appPrefs.getBoolean(PREF_FAVORITE_KEEPS_EPISODE, true)
}
fun shouldDeleteRemoveFromQueue(): Boolean {
return appPrefs.getBoolean(PREF_DELETE_REMOVES_FROM_QUEUE, false)
}
fun getPlaybackSpeed(mediaType: MediaType): Float {
return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed
}
fun shouldPauseForFocusLoss(): Boolean {
return appPrefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true)
}
private fun isAllowMobileFor(type: String): Boolean {
val defaultValue = HashSet<String>()
defaultValue.add("images")
val allowed = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue)
return allowed!!.contains(type)
}
private fun setAllowMobileFor(type: String, allow: Boolean) {
val defaultValue = HashSet<String>()
defaultValue.add("images")
val getValueStringSet = appPrefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue)
val allowed: MutableSet<String> = HashSet(getValueStringSet!!)
if (allow) allowed.add(type)
else allowed.remove(type)
appPrefs.edit().putStringSet(PREF_MOBILE_UPDATE, allowed).apply()
}
fun backButtonOpensDrawer(): Boolean {
return appPrefs.getBoolean(PREF_BACK_OPENS_DRAWER, false)
}
fun timeRespectsSpeed(): Boolean {
return appPrefs.getBoolean(PREF_TIME_RESPECTS_SPEED, false)
}
fun setPlaybackSpeed(speed: Float) {
appPrefs.edit().putString(PREF_PLAYBACK_SPEED, speed.toString()).apply()
}
@ -580,18 +677,12 @@ object UserPreferences {
fun setAutodownloadSelectedNetworks(value: Array<String?>?) {
appPrefs.edit().putString(PREF_AUTODL_SELECTED_NETWORKS, value!!.joinToString()).apply()
}
fun gpodnetNotificationsEnabled(): Boolean {
if (Build.VERSION.SDK_INT >= 26) return true // System handles notification preferences
return appPrefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true)
}
/**
* Used for migration of the preference to system notification channels.
*/
val gpodnetNotificationsEnabledRaw: Boolean
get() = appPrefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true)
fun setGpodnetNotificationsEnabled() {
appPrefs.edit().putBoolean(PREF_GPODNET_NOTIFICATIONS, true).apply()
}
@ -614,16 +705,9 @@ object UserPreferences {
return mutableListOf(1.0f, 1.25f, 1.5f)
}
var episodeCleanupValue: Int
get() = appPrefs.getString(PREF_EPISODE_CLEANUP, "" + EPISODE_CLEANUP_NULL)!!.toInt()
set(episodeCleanupValue) {
appPrefs.edit().putString(PREF_EPISODE_CLEANUP, episodeCleanupValue.toString()).apply()
}
/**
* Return the folder where the app stores all of its data. This method will
* return the standard data folder if none has been set by the user.
*
* @param type The name of the folder inside the data folder. May be null
* when accessing the root of the data folder.
* @return The data folder that has been requested or null if the folder could not be created.
@ -680,83 +764,6 @@ object UserPreferences {
}
}
var defaultPage: String?
get() = appPrefs.getString(PREF_DEFAULT_PAGE, "SubscriptionsFragment")
set(defaultPage) {
appPrefs.edit().putString(PREF_DEFAULT_PAGE, defaultPage).apply()
}
fun backButtonOpensDrawer(): Boolean {
return appPrefs.getBoolean(PREF_BACK_OPENS_DRAWER, false)
}
fun timeRespectsSpeed(): Boolean {
return appPrefs.getBoolean(PREF_TIME_RESPECTS_SPEED, false)
}
var isStreamOverDownload: Boolean
get() = appPrefs.getBoolean(PREF_STREAM_OVER_DOWNLOAD, false)
set(stream) {
appPrefs.edit().putBoolean(PREF_STREAM_OVER_DOWNLOAD, stream).apply()
}
var isQueueKeepSorted: Boolean
/**
* Returns if the queue is in keep sorted mode.
*
* @see .queueKeepSortedOrder
*/
get() = appPrefs.getBoolean(PREF_QUEUE_KEEP_SORTED, false)
/**
* Enables/disables the keep sorted mode of the queue.
*
* @see .queueKeepSortedOrder
*/
set(keepSorted) {
appPrefs.edit().putBoolean(PREF_QUEUE_KEEP_SORTED, keepSorted).apply()
}
var queueKeepSortedOrder: SortOrder?
/**
* Returns the sort order for the queue keep sorted mode.
* Note: This value is stored independently from the keep sorted state.
* @see .isQueueKeepSorted
*/
get() {
val sortOrderStr = appPrefs.getString(PREF_QUEUE_KEEP_SORTED_ORDER, "use-default")
return SortOrder.parseWithDefault(sortOrderStr, SortOrder.DATE_NEW_OLD)
}
/**
* Sets the sort order for the queue keep sorted mode.
* @see .setQueueKeepSorted
*/
set(sortOrder) {
if (sortOrder == null) return
appPrefs.edit().putString(PREF_QUEUE_KEEP_SORTED_ORDER, sortOrder.name).apply()
}
// the sort order for the downloads.
var downloadsSortedOrder: SortOrder?
get() {
val sortOrderStr = appPrefs.getString(PREF_DOWNLOADS_SORTED_ORDER, "" + SortOrder.DATE_NEW_OLD.code)
return SortOrder.fromCodeString(sortOrderStr)
}
set(sortOrder) {
appPrefs.edit().putString(PREF_DOWNLOADS_SORTED_ORDER, "" + sortOrder!!.code).apply()
}
var allEpisodesSortOrder: SortOrder?
get() = SortOrder.fromCodeString(appPrefs.getString(PREF_SORT_ALL_EPISODES, "" + SortOrder.DATE_NEW_OLD.code))
set(s) {
appPrefs.edit().putString(PREF_SORT_ALL_EPISODES, "" + s!!.code).apply()
}
var prefFilterAllEpisodes: String
get() = appPrefs.getString(PREF_FILTER_ALL_EPISODES, "")?:""
set(filter) {
appPrefs.edit().putString(PREF_FILTER_ALL_EPISODES, filter).apply()
}
enum class ThemePreference {
LIGHT, DARK, BLACK, SYSTEM
}

View File

@ -146,10 +146,7 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
handlerFunc.accept(path)
}
recyclerView.adapter = adapter
if (adapter.itemCount != 0) {
dialog.show()
}
if (adapter.itemCount != 0) dialog.show()
}
}
@ -161,14 +158,24 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
private lateinit var etUsername: EditText
private lateinit var etPassword: EditText
private lateinit var txtvMessage: TextView
private var testSuccessful = false
private val port: Int
get() {
val port = etPort.text.toString()
if (port.isNotEmpty()) {
try {
return port.toInt()
} catch (e: NumberFormatException) {
// ignore
}
}
return 0
}
fun show(): Dialog {
val content = View.inflate(context, R.layout.proxy_settings, null)
val binding = ProxySettingsBinding.bind(content)
spType = binding.spType
dialog = MaterialAlertDialogBuilder(context)
.setTitle(R.string.pref_proxy_title)
.setView(content)
@ -187,7 +194,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
reinit()
dialog.dismiss()
}
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
etHost.text.clear()
etPort.text.clear()
@ -195,12 +201,10 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
etPassword.text.clear()
setProxyConfig()
}
val types: MutableList<String> = ArrayList()
types.add(Proxy.Type.DIRECT.name)
types.add(Proxy.Type.HTTP.name)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) types.add(Proxy.Type.SOCKS.name)
val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, types)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spType.setAdapter(adapter)
@ -208,19 +212,15 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
spType.setSelection(adapter.getPosition(proxyConfig.type.name))
etHost = binding.etHost
if (!proxyConfig.host.isNullOrEmpty()) etHost.setText(proxyConfig.host)
etHost.addTextChangedListener(requireTestOnChange)
etPort = binding.etPort
if (proxyConfig.port > 0) etPort.setText(proxyConfig.port.toString())
etPort.addTextChangedListener(requireTestOnChange)
etUsername = binding.etUsername
if (!proxyConfig.username.isNullOrEmpty()) etUsername.setText(proxyConfig.username)
etUsername.addTextChangedListener(requireTestOnChange)
etPassword = binding.etPassword
if (!proxyConfig.password.isNullOrEmpty()) etPassword.setText(proxyConfig.password)
etPassword.addTextChangedListener(requireTestOnChange)
if (proxyConfig.type == Proxy.Type.DIRECT) {
enableSettings(false)
@ -232,7 +232,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
enableSettings(position > 0)
setTestRequired(position > 0)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
enableSettings(false)
}
@ -241,52 +240,40 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
checkValidity()
return dialog
}
private fun setProxyConfig() {
val type = spType.selectedItem as String
val typeEnum = Proxy.Type.valueOf(type)
val host = etHost.text.toString()
val port = etPort.text.toString()
var username: String? = etUsername.text.toString()
if (username.isNullOrEmpty()) username = null
var password: String? = etPassword.text.toString()
if (password.isNullOrEmpty()) password = null
var portValue = 0
if (port.isNotEmpty()) portValue = port.toInt()
val config = ProxyConfig(typeEnum, host, portValue, username, password)
proxyConfig = config
PodciniHttpClient.setProxyConfig(config)
}
private val requireTestOnChange: TextWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) {
setTestRequired(true)
}
}
private fun enableSettings(enable: Boolean) {
etHost.isEnabled = enable
etPort.isEnabled = enable
etUsername.isEnabled = enable
etPassword.isEnabled = enable
}
private fun checkValidity(): Boolean {
var valid = true
if (spType.selectedItemPosition > 0) valid = checkHost()
valid = valid and checkPort()
return valid
}
private fun checkHost(): Boolean {
val host = etHost.text.toString()
if (host.isEmpty()) {
@ -299,7 +286,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
}
return true
}
private fun checkPort(): Boolean {
val port = port
if (port < 0 || port > 65535) {
@ -308,20 +294,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
}
return true
}
private val port: Int
get() {
val port = etPort.text.toString()
if (port.isNotEmpty()) {
try {
return port.toInt()
} catch (e: NumberFormatException) {
// ignore
}
}
return 0
}
private fun setTestRequired(required: Boolean) {
if (required) {
testSuccessful = false
@ -332,7 +304,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
}
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
}
private fun test() {
if (!checkValidity()) {
setTestRequired(true)
@ -345,7 +316,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
txtvMessage.setTextColor(textColorPrimary)
txtvMessage.text = "{faw_circle_o_notch spin} $checking"
txtvMessage.visibility = View.VISIBLE
val coroutineScope = CoroutineScope(Dispatchers.Main)
coroutineScope.launch(Dispatchers.IO) {
try {
@ -356,7 +326,6 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
val password = etPassword.text.toString()
var portValue = 8080
if (port.isNotEmpty()) portValue = port.toInt()
val address: SocketAddress = InetSocketAddress.createUnresolved(host, portValue)
val proxyType = Proxy.Type.valueOf(type.uppercase())
val builder: OkHttpClient.Builder = newBuilder()
@ -393,12 +362,10 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
setTestRequired(true)
}
}
}
}
class DataFolderAdapter(context: Context, selectionHandler: Consumer<String>) : RecyclerView.Adapter<DataFolderAdapter.ViewHolder?>() {
private class DataFolderAdapter(context: Context, selectionHandler: Consumer<String>) : RecyclerView.Adapter<DataFolderAdapter.ViewHolder?>() {
private val selectionHandler: Consumer<String>
private val currentPath: String?
private val entries: List<StoragePath>
@ -410,19 +377,16 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
this.selectionHandler = selectionHandler
this.freeSpaceString = context.getString(R.string.choose_data_directory_available_space)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val entryView = inflater.inflate(R.layout.choose_data_folder_dialog_entry, parent, false)
return ViewHolder(entryView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val storagePath = entries[position]
val context = holder.root.context
val freeSpace = Formatter.formatShortFileSize(context, storagePath.availableSpace)
val totalSpace = Formatter.formatShortFileSize(context, storagePath.totalSpace)
holder.path.text = storagePath.shortPath
holder.size.text = String.format(freeSpaceString, freeSpace, totalSpace)
holder.progressBar.progress = storagePath.usagePercentage
@ -431,19 +395,15 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
}
holder.root.setOnClickListener(selectListener)
holder.radioButton.setOnClickListener(selectListener)
if (storagePath.fullPath == currentPath) holder.radioButton.toggle()
}
override fun getItemCount(): Int {
return entries.size
}
private fun getCurrentPath(): String? {
val dataFolder = getDataFolder(null)
return dataFolder?.absolutePath
}
private fun getStorageEntries(context: Context): List<StoragePath> {
val mediaDirs = context.getExternalFilesDirs(null)
val entries: MutableList<StoragePath> = ArrayList(mediaDirs.size)
@ -454,12 +414,10 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
if (entries.isEmpty() && isWritable(context.filesDir)) entries.add(StoragePath(context.filesDir.absolutePath))
return entries
}
private fun isWritable(dir: File?): Boolean {
return dir != null && dir.exists() && dir.canRead() && dir.canWrite()
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = ChooseDataFolderDialogEntryBinding.bind(itemView)
val root: View = binding.root
val path: TextView = binding.path
@ -467,20 +425,16 @@ class DownloadsPreferencesFragment : PreferenceFragmentCompat(), OnSharedPrefere
val radioButton: RadioButton = binding.radioButton
val progressBar: ProgressBar = binding.usedSpace
}
internal class StoragePath(val fullPath: String) {
private class StoragePath(val fullPath: String) {
val shortPath: String
get() {
val prefixIndex = fullPath.indexOf("Android")
return if ((prefixIndex > 0)) fullPath.substring(0, prefixIndex) else fullPath
}
val availableSpace: Long
get() = getFreeSpaceAvailable(fullPath)
val totalSpace: Long
get() = getTotalSpaceAvailable(fullPath)
val usagePercentage: Int
get() = 100 - (100 * availableSpace / totalSpace.toFloat()).toInt()
}

View File

@ -34,6 +34,7 @@ import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.text.format.Formatter
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@ -66,22 +67,30 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
private val chooseOpmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseOpmlExportPathResult(result) }
private val chooseHtmlExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseHtmlExportPathResult(result) }
private val chooseFavoritesExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseFavoritesExportPathResult(result) }
private val chooseProgressExportPathLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.chooseProgressExportPathResult(result) }
private val restoreProgressLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restoreProgressResult(result) }
private val restoreDatabaseLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restoreDatabaseResult(result) }
private val backupDatabaseLauncher = registerForActivityResult<String, Uri>(BackupDatabase()) { uri: Uri? -> this.backupDatabaseResult(uri) }
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) {
uri: Uri? -> this.chooseOpmlImportPathResult(uri) }
private val restorePreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult -> this.restorePreferencesResult(result) }
private val backupPreferencesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
val data: Uri? = it.data?.data
@ -221,7 +230,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
private fun importDatabase() {
// setup the alert builder
val builder = MaterialAlertDialogBuilder(requireActivity())
builder.setTitle(R.string.database_import_label)
builder.setTitle(R.string.realm_database_import_label)
builder.setMessage(R.string.database_import_warning)
// add a button
@ -229,6 +238,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.setType("*/*")
intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream"))
intent.addCategory(Intent.CATEGORY_OPENABLE)
restoreDatabaseLauncher.launch(intent)
}
@ -270,7 +281,8 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
builder.setNegativeButton(R.string.no, null)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.setType("*/*")
intent.setType("application/octet-stream")
intent.addCategory(Intent.CATEGORY_OPENABLE)
restoreProgressLauncher.launch(intent)
}
// create and show the alert dialog
@ -304,44 +316,70 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
private fun restoreProgressResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data?.data == null) return
val uri = result.data!!.data!!
progressDialog!!.show()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri)
val reader = BufferedReader(InputStreamReader(inputStream))
EpisodeProgressReader.readDocument(reader)
reader.close()
uri?.let {
if (isJsonFile(uri)) {
progressDialog!!.show()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
val inputStream: InputStream? = requireContext().contentResolver.openInputStream(uri)
val reader = BufferedReader(InputStreamReader(inputStream))
EpisodeProgressReader.readDocument(reader)
reader.close()
}
withContext(Dispatchers.Main) {
showDatabaseImportSuccessDialog()
progressDialog!!.dismiss()
}
} catch (e: Throwable) {
showExportErrorDialog(e)
}
}
withContext(Dispatchers.Main) {
showDatabaseImportSuccessDialog()
progressDialog!!.dismiss()
}
} catch (e: Throwable) {
showExportErrorDialog(e)
} else {
val context = requireContext()
val message = context.getString(R.string.import_file_type_toast) + ".json"
showExportErrorDialog(Throwable(message))
}
}
}
private fun isJsonFile(uri: Uri): Boolean {
val fileName = uri.lastPathSegment ?: return false
return fileName.endsWith(".json", ignoreCase = true)
}
private fun restoreDatabaseResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data == null) return
val uri = result.data!!.data
progressDialog!!.show()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
DatabaseTransporter.importBackup(uri, requireContext())
uri?.let {
if (isRealmFile(uri)) {
progressDialog!!.show()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
DatabaseTransporter.importBackup(uri, requireContext())
}
withContext(Dispatchers.Main) {
showDatabaseImportSuccessDialog()
progressDialog!!.dismiss()
}
} catch (e: Throwable) {
showExportErrorDialog(e)
}
}
withContext(Dispatchers.Main) {
showDatabaseImportSuccessDialog()
progressDialog!!.dismiss()
}
} catch (e: Throwable) {
showExportErrorDialog(e)
} else {
val context = requireContext()
val message = context.getString(R.string.import_file_type_toast) + ".realm"
showExportErrorDialog(Throwable(message))
}
}
}
private fun isRealmFile(uri: Uri): Boolean {
val fileName = uri.lastPathSegment ?: return false
return fileName.endsWith(".realm", ignoreCase = true)
}
private fun restorePreferencesResult(result: ActivityResult) {
if (result.resultCode != RESULT_OK || result.data?.data == null) return
val uri = result.data!!.data!!
@ -459,7 +497,6 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
val success = output.delete()
Logd(TAG, "Overwriting previously exported file: $success")
}
var writer: OutputStreamWriter? = null
try {
writer = OutputStreamWriter(FileOutputStream(output), Charset.forName("UTF-8"))
@ -619,9 +656,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
val newDstSize = dst.size()
if (newDstSize != srcSize)
throw IOException(String.format("Unable to write entire database. Expected to write %s, but wrote %s.", Formatter.formatShortFileSize(context, srcSize), Formatter.formatShortFileSize(context, newDstSize)))
} else {
throw IOException("Can not access current database")
}
} else throw IOException("Can not access current database")
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
@ -659,33 +694,18 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
fun readDocument(reader: Reader) {
val jsonString = reader.readText()
val jsonArray = JSONArray(jsonString)
val remoteActions = mutableListOf<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)
upsertBlk(result.second) {}
}
// loadAdditionalFeedItemListData(updatedItems)
// need to do it the sync way
for (episode in updatedItems) upsertBlk(episode) {}
Logd(TAG, "Parsing finished.")
return
}
private fun processEpisodeAction(action: EpisodeAction): Pair<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
}
val feedItem = getEpisodeByGuidOrUrl(guid, action.episode?:"") ?: return null
if (feedItem.media == null) {
Logd(TAG, "Feed item has no media: $action")
return null
@ -763,7 +783,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
override fun writeDocument(feeds: List<Feed?>?, writer: Writer?, context: Context) {
Logd(TAG, "Starting to write document")
val templateStream = context!!.assets.open("html-export-template.html")
val templateStream = context.assets.open("html-export-template.html")
var template = IOUtils.toString(templateStream, UTF_8)
template = template.replace("\\{TITLE\\}".toRegex(), "Favorites")
val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
@ -840,12 +860,10 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
override fun writeDocument(feeds: List<Feed?>?, writer: Writer?, context: Context) {
Logd(TAG, "Starting to write document")
val templateStream = context!!.assets.open("html-export-template.html")
val templateStream = context.assets.open("html-export-template.html")
var template = IOUtils.toString(templateStream, "UTF-8")
template = template.replace("\\{TITLE\\}".toRegex(), "Subscriptions")
val templateParts = template.split("\\{FEEDS\\}".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
writer!!.append(templateParts[0])
for (feed in feeds!!) {
writer.append("<li><div><img src=\"")
@ -861,11 +879,9 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
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

@ -44,9 +44,8 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.playback_pref)
}
@OptIn(UnstableApi::class) private fun setupPlaybackScreen() {
val activity: Activity? = activity
@OptIn(UnstableApi::class)
private fun setupPlaybackScreen() {
findPreference<Preference>(PREF_PLAYBACK_SPEED_LAUNCHER)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
VariableSpeedDialog.newInstance(booleanArrayOf(false, false, true),2)?.show(childFragmentManager, null)
true
@ -84,7 +83,6 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
findPreference<Preference>(UserPreferences.PREF_UNPAUSE_ON_HEADSET_RECONNECT)!!.isVisible = false
findPreference<Preference>(UserPreferences.PREF_UNPAUSE_ON_BLUETOOTH_RECONNECT)!!.isVisible = false
}
buildEnqueueLocationPreference()
}
@ -101,7 +99,6 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
val pref = requirePreference<ListPreference>(UserPreferences.PREF_ENQUEUE_LOCATION)
pref.summary = res.getString(R.string.pref_enqueue_location_sum, options[pref.value])
pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any? ->
if (newValue !is String) return@OnPreferenceChangeListener false
pref.summary = res.getString(R.string.pref_enqueue_location_sum, options[newValue])
@ -111,8 +108,7 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
private fun <T : Preference?> requirePreference(key: CharSequence): T {
// Possibly put it to a common method in abstract base class
val result = findPreference<T>(key) ?: throw IllegalArgumentException("Preference with key '$key' is not found")
return result
return findPreference<T>(key) ?: throw IllegalArgumentException("Preference with key '$key' is not found")
}
private fun buildSmartMarkAsPlayedPreference() {
@ -140,12 +136,10 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
@UnstableApi
class EditFallbackSpeedDialog(activity: Activity) {
val TAG = this::class.simpleName ?: "Anonymous"
private val activityRef = WeakReference(activity)
fun show() {
val activity = activityRef.get() ?: return
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
binding.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
binding.editText.text = Editable.Factory.getInstance().newEditable(fallbackSpeed.toString())
@ -168,16 +162,13 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
@UnstableApi
class EditForwardSpeedDialog(activity: Activity) {
val TAG = this::class.simpleName ?: "Anonymous"
private val activityRef = WeakReference(activity)
fun show() {
val activity = activityRef.get() ?: return
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
binding.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
binding.editText.text = Editable.Factory.getInstance().newEditable(speedforwardSpeed.toString())
MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(R.string.edit_fast_forward_speed)
@ -192,7 +183,6 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
.setNegativeButton(R.string.cancel_label, null)
.show()
}
}
object VideoModeDialog {
@ -200,11 +190,9 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
val dialog = MaterialAlertDialogBuilder(context)
dialog.setTitle(context.getString(R.string.pref_playback_video_mode))
dialog.setNegativeButton(android.R.string.cancel) { d: DialogInterface, _: Int -> d.dismiss() }
val selected = videoPlayMode
val entryValues = listOf(*context.resources.getStringArray(R.array.video_mode_options_values))
val selectedIndex = entryValues.indexOf("" + selected)
val items = context.resources.getStringArray(R.array.video_mode_options)
dialog.setSingleChoiceItems(items, selectedIndex) { d: DialogInterface, which: Int ->
if (selectedIndex != which) setVideoMode(entryValues[which].toInt())

View File

@ -10,7 +10,8 @@ import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.ACTION_SHUTDOWN_PLAYBACK_SERVICE
import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueues
import ac.mdiq.podcini.preferences.UserPreferences.shouldRemoveFromQueuesMarkPlayed
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesSync
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
@ -94,21 +95,16 @@ object Episodes {
fun deleteMediaOfEpisode(context: Context, episode: Episode) : Job {
Logd(TAG, "deleteMediaOfEpisode called ${episode.title}")
return runOnIOScope {
val media = episode.media ?: return@runOnIOScope
val result = deleteMediaSync(context, episode)
if (media.downloadUrl.isNullOrEmpty()) {
episode.media = null
upsert(episode) {}
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode))
}
if (result && shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, episode)
if (episode.media == null) return@runOnIOScope
val episode_ = deleteMediaSync(context, episode)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, episode_)
}
}
@OptIn(UnstableApi::class)
private fun deleteMediaSync(context: Context, episode: Episode): Boolean {
fun deleteMediaSync(context: Context, episode: Episode): Episode {
Logd(TAG, "deleteMediaSync called")
val media = episode.media ?: return false
val media = episode.media ?: return episode
Logd(TAG, String.format(Locale.US, "Requested to delete EpisodeMedia [id=%d, title=%s, downloaded=%s", media.id, media.getEpisodeTitle(), media.downloaded))
var localDelete = false
val url = media.fileUrl
@ -118,10 +114,12 @@ object Episodes {
val documentFile = DocumentFile.fromSingleUri(context, Uri.parse(media.fileUrl))
if (documentFile == null || !documentFile.exists() || !documentFile.delete()) {
EventFlow.postEvent(FlowEvent.MessageEvent(context.getString(R.string.delete_local_failed)))
return false
return episode
}
upsertBlk(episode) {
it.media?.fileUrl = null
if (media.downloadUrl.isNullOrEmpty()) it.media = null
}
episode.media?.fileUrl = null
upsertBlk(episode) {}
localDelete = true
}
url != null -> {
@ -131,19 +129,20 @@ object Episodes {
Log.e(TAG, "delete media file failed: $url")
val evt = FlowEvent.MessageEvent(context.getString(R.string.delete_failed))
EventFlow.postEvent(evt)
return false
return episode
}
upsertBlk(episode) {
it.media?.downloaded = false
it.media?.fileUrl = null
it.media?.hasEmbeddedPicture = false
if (media.downloadUrl.isNullOrEmpty()) it.media = null
}
episode.media?.downloaded = false
episode.media?.fileUrl = null
episode.media?.hasEmbeddedPicture = false
upsertBlk(episode) {}
}
}
if (media.id == curState.curMediaId) {
writeNoMediaPlaying()
sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE)
val nm = NotificationManagerCompat.from(context)
nm.cancel(R.id.notification_playing)
}
@ -157,7 +156,7 @@ object Episodes {
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, action)
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode))
}
return true
return episode
}
/**
@ -166,44 +165,34 @@ object Episodes {
*/
fun deleteEpisodes(context: Context, episodes: List<Episode>) : Job {
return runOnIOScope {
deleteEpisodesSync(context, episodes)
}
}
/**
* Remove the listed episodes and their EpisodeMedia entries.
* Deleting media also removes the download log entries.
*/
@OptIn(UnstableApi::class)
internal fun deleteEpisodesSync(context: Context, episodes: List<Episode>) {
Logd(TAG, "deleteEpisodesSync called")
val removedFromQueue: MutableList<Episode> = ArrayList()
val queueItems = curQueue.episodes.toMutableList()
for (episode in episodes) {
if (queueItems.remove(episode)) removedFromQueue.add(episode)
if (episode.media != null) {
if (episode.media?.id == curState.curMediaId) {
// Applies to both downloaded and streamed media
writeNoMediaPlaying()
sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE)
}
if (episode.feed != null && !episode.feed!!.isLocalFeed) {
DownloadServiceInterface.get()?.cancel(context, episode.media!!)
if (episode.media!!.downloaded) deleteMediaSync(context, episode)
val removedFromQueue: MutableList<Episode> = ArrayList()
val queueItems = curQueue.episodes.toMutableList()
for (episode in episodes) {
if (queueItems.remove(episode)) removedFromQueue.add(episode)
if (episode.media != null) {
if (episode.media?.id == curState.curMediaId) {
// Applies to both downloaded and streamed media
writeNoMediaPlaying()
sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE)
}
if (episode.feed != null && !episode.feed!!.isLocalFeed) {
DownloadServiceInterface.get()?.cancel(context, episode.media!!)
if (episode.media!!.downloaded) deleteMediaSync(context, episode)
}
}
}
if (removedFromQueue.isNotEmpty()) removeFromAllQueuesSync(*removedFromQueue.toTypedArray())
for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode))
// we assume we also removed download log entries for the feed or its media files.
// especially important if download or refresh failed, as the user should not be able
// to retry these
EventFlow.postEvent(FlowEvent.DownloadLogEvent())
val backupManager = BackupManager(context)
backupManager.dataChanged()
}
if (removedFromQueue.isNotEmpty()) removeFromAllQueues(*removedFromQueue.toTypedArray())
for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode))
// we assume we also removed download log entries for the feed or its media files.
// especially important if download or refresh failed, as the user should not be able
// to retry these
EventFlow.postEvent(FlowEvent.DownloadLogEvent())
val backupManager = BackupManager(context)
backupManager.dataChanged()
}
fun persistEpisodes(episodes: List<Episode>) : Job {
@ -274,17 +263,24 @@ object Episodes {
* @param resetMediaPosition true if this method should also reset the position of the Episode's EpisodeMedia object.
*/
@OptIn(UnstableApi::class)
fun markPlayed(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job {
Logd(TAG, "markPlayed called")
fun setPlayState(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job {
Logd(TAG, "setPlayState called")
return runOnIOScope {
for (episode in episodes) {
val result = upsert(episode) {
it.playState = played
if (resetMediaPosition) it.media?.setPosition(0)
}
if (played == Episode.PLAYED) removeFromAllQueues(episode)
EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(result))
setPlayStateSync(played, resetMediaPosition, episode)
}
}
}
@OptIn(UnstableApi::class)
suspend fun setPlayStateSync(played: Int, resetMediaPosition: Boolean, episode: Episode) : Episode {
Logd(TAG, "setPlayStateSync called resetMediaPosition: $resetMediaPosition")
val result = upsert(episode) {
it.playState = played
if (resetMediaPosition) it.media?.setPosition(0)
}
if (played == Episode.PLAYED && shouldRemoveFromQueuesMarkPlayed()) removeFromAllQueuesSync(result)
EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(result))
return result
}
}

View File

@ -3,7 +3,6 @@ package ac.mdiq.podcini.storage.database
import ac.mdiq.podcini.net.download.DownloadError
import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes
import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet
@ -37,7 +36,8 @@ object Feeds {
private val tags: MutableList<String> = mutableListOf()
@Synchronized
fun getFeedList(): List<Feed> {
fun getFeedList(fromDB: Boolean = true): List<Feed> {
if (fromDB) return realm.query(Feed::class).find()
return feedMap.values.toList()
}
@ -67,7 +67,7 @@ object Feeds {
fun buildTags() {
val tagsSet = mutableSetOf<String>()
val feedsCopy = synchronized(feedMap) { feedMap.values.toList() }
val feedsCopy = getFeedList()
for (feed in feedsCopy) {
if (feed.preferences != null) tagsSet.addAll(feed.preferences!!.tags.filter { it != TAG_ROOT })
}
@ -94,7 +94,7 @@ object Feeds {
changes.insertions.isNotEmpty() -> {
for (i in changes.insertions) {
Logd(TAG, "monitorFeeds inserted feed: ${changes.list[i].title}")
updateFeedMap(listOf(changes.list[i]))
// updateFeedMap(listOf(changes.list[i]))
monitorFeed(changes.list[i])
}
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED))
@ -106,7 +106,8 @@ object Feeds {
// }
changes.deletions.isNotEmpty() -> {
Logd(TAG, "monitorFeeds feed deleted: ${changes.deletions.size}")
updateFeedMap(changes.list, true)
// updateFeedMap(changes.list, true)
buildTags()
}
}
}
@ -125,7 +126,7 @@ object Feeds {
when (changes) {
is UpdatedObject -> {
Logd(TAG, "monitorFeed UpdatedObject0 ${changes.obj.title} ${changes.changedFields.joinToString()}")
updateFeedMap(listOf(changes.obj))
// updateFeedMap(listOf(changes.obj))
if (changes.isFieldChanged("preferences")) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(changes.obj))
}
else -> {}
@ -138,13 +139,13 @@ object Feeds {
when (changes) {
is UpdatedObject -> {
Logd(TAG, "monitorFeed UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}")
updateFeedMap(listOf(changes.obj))
// updateFeedMap(listOf(changes.obj))
if (changes.isFieldChanged("preferences")) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(changes.obj))
}
is DeletedObject -> {
Logd(TAG, "monitorFeed DeletedObject ${feed.title}")
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id))
}
// is DeletedObject -> {
// Logd(TAG, "monitorFeed DeletedObject ${feed.title}")
// EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id))
// }
else -> {}
}
}
@ -154,15 +155,17 @@ object Feeds {
fun getFeedListDownloadUrls(): List<String> {
Logd(TAG, "getFeedListDownloadUrls called")
val result: MutableList<String> = mutableListOf()
for (f in feedMap.values) {
val feeds = getFeedList()
for (f in feeds) {
val url = f.downloadUrl
if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) result.add(url)
}
return result
}
fun getFeed(feedId: Long, copy: Boolean = false): Feed? {
val f = feedMap[feedId]
fun getFeed(feedId: Long, copy: Boolean = false, fromDB: Boolean = true): Feed? {
Logd(TAG, "getFeed called fromDB: $fromDB")
val f = if (fromDB) realm.query(Feed::class, "id == $feedId").first().find() else feedMap[feedId]
return if (f != null) {
if (copy) realm.copyFromRealm(f)
else f
@ -173,8 +176,9 @@ object Feeds {
Logd(TAG, "searchFeedByIdentifyingValueOrID called")
if (feed.id != 0L) return getFeed(feed.id, copy)
val feeds = getFeedList()
val feedId = feed.identifyingValue
for (f in feeds) {
if (f.identifyingValue == feed.identifyingValue) return if (copy) realm.copyFromRealm(f) else f
if (f.identifyingValue == feedId) return if (copy) realm.copyFromRealm(f) else f
}
return null
}
@ -229,7 +233,7 @@ object Feeds {
for (idx in newFeed.episodes.indices) {
val episode = newFeed.episodes[idx]
val possibleDuplicate = searchEpisodeGuessDuplicate(newFeed.episodes, episode)
val possibleDuplicate = EpisodeAssistant.searchEpisodeGuessDuplicate(newFeed.episodes, episode)
if (!newFeed.isLocalFeed && possibleDuplicate != null && episode !== possibleDuplicate) {
// Canonical episode is the first one returned (usually oldest)
addDownloadStatus(DownloadResult(savedFeed.id, episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
@ -237,17 +241,17 @@ object Feeds {
The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it.
Original episode:
${duplicateEpisodeDetails(episode)}
${EpisodeAssistant.duplicateEpisodeDetails(episode)}
Second episode that is also in the feed:
${duplicateEpisodeDetails(possibleDuplicate)}
${EpisodeAssistant.duplicateEpisodeDetails(possibleDuplicate)}
""".trimIndent()))
continue
}
var oldItem = searchEpisodeByIdentifyingValue(savedFeed.episodes, episode)
var oldItem = EpisodeAssistant.searchEpisodeByIdentifyingValue(savedFeed.episodes, episode)
if (!newFeed.isLocalFeed && oldItem == null) {
oldItem = searchEpisodeGuessDuplicate(savedFeed.episodes, episode)
oldItem = EpisodeAssistant.searchEpisodeGuessDuplicate(savedFeed.episodes, episode)
if (oldItem != null) {
Logd(TAG, "Repaired duplicate: $oldItem, $episode")
addDownloadStatus(DownloadResult(savedFeed.id,
@ -256,10 +260,10 @@ object Feeds {
The podcast host changed the ID of an existing episode instead of just updating the episode itself. Podcini still refreshed the feed and attempted to repair it.
Original episode:
${duplicateEpisodeDetails(oldItem)}
${EpisodeAssistant.duplicateEpisodeDetails(oldItem)}
Now the feed contains:
${duplicateEpisodeDetails(episode)}
${EpisodeAssistant.duplicateEpisodeDetails(episode)}
""".trimIndent()))
oldItem.identifier = episode.identifier
@ -300,7 +304,7 @@ object Feeds {
val it = savedFeed.episodes.toMutableList().iterator()
while (it.hasNext()) {
val feedItem = it.next()
if (searchEpisodeByIdentifyingValue(newFeed.episodes, feedItem) == null) {
if (EpisodeAssistant.searchEpisodeByIdentifyingValue(newFeed.episodes, feedItem) == null) {
unlistedItems.add(feedItem)
it.remove()
}
@ -334,43 +338,6 @@ object Feeds {
return resultFeed
}
/**
* Get an episode by its identifying value in the given list
*/
private fun searchEpisodeByIdentifyingValue(episodes: List<Episode>?, searchItem: Episode): Episode? {
if (episodes.isNullOrEmpty()) return null
for (episode in episodes) {
if (episode.identifyingValue == searchItem.identifyingValue) return episode
}
return null
}
/**
* Guess if one of the episodes could actually mean the searched episode, even if it uses another identifying value.
* This is to work around podcasters breaking their GUIDs.
*/
private fun searchEpisodeGuessDuplicate(episodes: List<Episode>?, searchItem: Episode): Episode? {
if (episodes.isNullOrEmpty()) return null
for (episode in episodes) {
if (EpisodeDuplicateGuesser.sameAndNotEmpty(episode.identifier, searchItem.identifier)) return episode
}
for (episode in episodes) {
if (EpisodeDuplicateGuesser.seemDuplicates(episode, searchItem)) return episode
}
return null
}
private fun duplicateEpisodeDetails(episode: Episode): String {
return ("""
Title: ${episode.title}
ID: ${episode.identifier}
""".trimIndent()
+ (if ((episode.media == null)) "" else """
URL: ${episode.media!!.downloadUrl}
""".trimIndent()))
}
fun persistFeedLastUpdateFailed(feed: Feed, lastUpdateFailed: Boolean) : Job {
Logd(TAG, "persistFeedLastUpdateFailed called")
return runOnIOScope {
@ -383,11 +350,10 @@ object Feeds {
fun updateFeedDownloadURL(original: String, updated: String) : Job {
Logd(TAG, "updateFeedDownloadURL(original: $original, updated: $updated)")
return runOnIOScope {
realm.write {
val feed = query(Feed::class).query("downloadUrl == $0", original).first().find()
if (feed != null) {
feed.downloadUrl = updated
// upsert(feed) {}
val feed = realm.query(Feed::class).query("downloadUrl == $0", original).first().find()
if (feed != null) {
upsert(feed) {
it.downloadUrl = updated
}
}
}
@ -434,17 +400,10 @@ object Feeds {
return runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) {
realm.write {
findLatest(feed_)?.let {
it.preferences = feed.preferences
// updateFeedMap(listOf(it))
}
upsert(feed_) {
it.preferences = feed.preferences
}
} else {
upsert(feed) {}
// updateFeedMap(listOf(feed))
}
// if (feed.preferences != null) EventFlow.postEvent(FlowEvent.FeedPrefsChangeEvent(feed.preferences!!))
} else upsert(feed) {}
}
}
@ -453,72 +412,39 @@ object Feeds {
* @param context A context that is used for opening a database connection.
* @param feedId ID of the Feed that should be deleted.
*/
fun deleteFeed(context: Context, feedId: Long, postEvent: Boolean = true) : Job {
suspend fun deleteFeedSync(context: Context, feedId: Long, postEvent: Boolean = true) {
Logd(TAG, "deleteFeed called")
return runOnIOScope {
val feed = getFeed(feedId)
if (feed != null) {
val eids = feed.episodes.map { it.id }
val feed = getFeed(feedId)
if (feed != null) {
val eids = feed.episodes.map { it.id }
// remove from queues
removeFromAllQueuesQuiet(eids)
removeFromAllQueuesQuiet(eids)
// remove media files
// deleteMediaFilesQuiet(context, feed.episodes)
realm.write {
val feed_ = query(Feed::class).query("id == $0", feedId).first().find()
if (feed_ != null) {
val episodes = feed_.episodes.toList()
if (episodes.isNotEmpty()) episodes.forEach { episode ->
val url = episode.media?.fileUrl
when {
// Local feed
url != null && url.startsWith("content://") -> DocumentFile.fromSingleUri(context, Uri.parse(url))?.delete()
url != null -> File(url).delete()
}
delete(episode)
}
val feedToDelete = findLatest(feed_)
if (feedToDelete != null) {
delete(feedToDelete)
feedMap.remove(feedId)
realm.write {
val feed_ = query(Feed::class).query("id == $0", feedId).first().find()
if (feed_ != null) {
val episodes = feed_.episodes.toList()
if (episodes.isNotEmpty()) episodes.forEach { episode ->
val url = episode.media?.fileUrl
when {
// Local feed
url != null && url.startsWith("content://") -> DocumentFile.fromSingleUri(context, Uri.parse(url))?.delete()
url != null -> File(url).delete()
}
delete(episode)
}
val feedToDelete = findLatest(feed_)
if (feedToDelete != null) {
delete(feedToDelete)
feedMap.remove(feedId)
}
}
if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!)
// if (postEvent) EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id))
}
if (!feed.isLocalFeed && feed.downloadUrl != null) SynchronizationQueueSink.enqueueFeedRemovedIfSyncActive(context, feed.downloadUrl!!)
// if (postEvent) EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feed.id))
}
}
// private fun deleteMediaFilesQuiet(context: Context, episodes: List<Episode>) {
// for (episode in episodes) {
// val media = episode.media ?: continue
// val url = media.fileUrl
// when {
// url != null && url.startsWith("content://") -> {
// // Local feed
// val documentFile = DocumentFile.fromSingleUri(context, Uri.parse(media.fileUrl))
// documentFile?.delete()
//// episode.media?.fileUrl = null
// }
// url != null -> {
// // delete downloaded media file
// val mediaFile = File(url)
// mediaFile.delete()
//// since deleting entire feed, these are not necessary
//// episode.media?.downloaded = false
//// episode.media?.fileUrl = null
//// episode.media?.hasEmbeddedPicture = false
// }
// }
// }
// }
@JvmStatic
fun shouldAutoDeleteItemsOnFeed(feed: Feed): Boolean {
if (!UserPreferences.isAutoDelete) return false
return !feed.isLocalFeed || UserPreferences.isAutoDeleteLocal
}
/**
* Compares the pubDate of two FeedItems for sorting in reverse order
*/
@ -528,6 +454,39 @@ object Feeds {
}
}
private object EpisodeAssistant {
fun searchEpisodeByIdentifyingValue(episodes: List<Episode>?, searchItem: Episode): Episode? {
if (episodes.isNullOrEmpty()) return null
for (episode in episodes) {
if (episode.identifyingValue == searchItem.identifyingValue) return episode
}
return null
}
/**
* Guess if one of the episodes could actually mean the searched episode, even if it uses another identifying value.
* This is to work around podcasters breaking their GUIDs.
*/
fun searchEpisodeGuessDuplicate(episodes: List<Episode>?, searchItem: Episode): Episode? {
if (episodes.isNullOrEmpty()) return null
for (episode in episodes) {
if (EpisodeDuplicateGuesser.sameAndNotEmpty(episode.identifier, searchItem.identifier)) return episode
}
for (episode in episodes) {
if (EpisodeDuplicateGuesser.seemDuplicates(episode, searchItem)) return episode
}
return null
}
fun duplicateEpisodeDetails(episode: Episode): String {
return ("""
Title: ${episode.title}
ID: ${episode.identifier}
""".trimIndent() + if (episode.media == null) "" else """
URL: ${episode.media!!.downloadUrl}
""".trimIndent())
}
}
/**
* Publishers sometimes mess up their feed by adding episodes twice or by changing the ID of existing episodes.
* This class tries to guess if publishers actually meant another episode,
@ -536,50 +495,39 @@ object Feeds {
object EpisodeDuplicateGuesser {
fun seemDuplicates(item1: Episode, item2: Episode): Boolean {
if (sameAndNotEmpty(item1.identifier, item2.identifier)) return true
val media1 = item1.media
val media2 = item2.media
if (media1 == null || media2 == null) return false
if (sameAndNotEmpty(media1.getStreamUrl(), media2.getStreamUrl())) return true
return (titlesLookSimilar(item1, item2) && datesLookSimilar(item1, item2) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2))
}
fun sameAndNotEmpty(string1: String?, string2: String?): Boolean {
if (string1.isNullOrEmpty() || string2.isNullOrEmpty()) return false
return string1 == string2
}
private fun datesLookSimilar(item1: Episode, item2: Episode): Boolean {
if (item1.getPubDate() == null || item2.getPubDate() == null) return false
val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) // MM/DD/YY
val dateOriginal = dateFormat.format(item2.getPubDate()!!)
val dateNew = dateFormat.format(item1.getPubDate()!!)
return dateOriginal == dateNew // Same date; time is ignored.
}
private fun durationsLookSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean {
return abs((media1.getDuration() - media2.getDuration()).toDouble()) < 10 * 60L * 1000L
}
private fun mimeTypeLooksSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean {
var mimeType1 = media1.mimeType
var mimeType2 = media2.mimeType
if (mimeType1 == null || mimeType2 == null) return true
if (mimeType1.contains("/") && mimeType2.contains("/")) {
mimeType1 = mimeType1.substring(0, mimeType1.indexOf("/"))
mimeType2 = mimeType2.substring(0, mimeType2.indexOf("/"))
}
return (mimeType1 == mimeType2)
}
private fun titlesLookSimilar(item1: Episode, item2: Episode): Boolean {
return sameAndNotEmpty(canonicalizeTitle(item1.title), canonicalizeTitle(item2.title))
}
private fun canonicalizeTitle(title: String?): String {
if (title == null) return ""
return title
@ -590,5 +538,4 @@ object Feeds {
.replace('—', '-')
}
}
}

View File

@ -3,13 +3,13 @@ package ac.mdiq.podcini.storage.database
import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import ac.mdiq.podcini.util.sorting.DownloadResultComparator
import kotlinx.coroutines.Job
import java.util.*
object LogsAndStats {
private val TAG: String = LogsAndStats::class.simpleName ?: "Anonymous"
@ -26,9 +26,7 @@ object LogsAndStats {
return runOnIOScope {
if (status != null) {
if (status.id == 0L) status.setId()
realm.write {
copyToRealm(status)
}
upsert(status) {}
EventFlow.postEvent(FlowEvent.DownloadLogEvent())
}
}
@ -45,30 +43,36 @@ object LogsAndStats {
val groupdMedias = medias.groupBy { it.episode?.feedId ?: 0L }
val result = StatisticsResult()
result.oldestDate = Long.MAX_VALUE
for (fid in groupdMedias.keys) {
for ((fid, feedMedias) in groupdMedias) {
val feed = getFeed(fid, false) ?: continue
val episodes = feed.episodes.size.toLong()
val numEpisodes = feed.episodes.size.toLong()
var feedPlayedTime = 0L
var feedTotalTime = 0L
var episodesStarted = 0L
var totalDownloadSize = 0L
var episodesDownloadCount = 0L
for (m in groupdMedias[fid]!!) {
for (m in feedMedias) {
if (m.lastPlayedTime > 0 && m.lastPlayedTime < result.oldestDate) result.oldestDate = m.lastPlayedTime
feedTotalTime += m.duration
if (m.lastPlayedTime in timeFilterFrom..<timeFilterTo) feedPlayedTime += m.playedDuration
if (includeMarkedAsPlayed) {
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || m.episode?.playState == Episode.PLAYED || m.position > 0)
episodesStarted += 1
} else {
if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1
if (m.lastPlayedTime in timeFilterFrom..<timeFilterTo) {
if (includeMarkedAsPlayed) {
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || m.episode?.playState == Episode.PLAYED || m.position > 0) {
episodesStarted += 1
feedPlayedTime += m.duration
}
} else {
feedPlayedTime += m.playedDuration
if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1
}
}
if (m.downloaded) {
episodesDownloadCount += 1
totalDownloadSize += m.size
}
result.feedTime.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, episodes, episodesStarted, totalDownloadSize, episodesDownloadCount))
}
feedPlayedTime /= 1000
feedTotalTime /= 1000
result.statsItems.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, numEpisodes, episodesStarted, totalDownloadSize, episodesDownloadCount))
}
return result
}

View File

@ -8,7 +8,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation
import ac.mdiq.podcini.preferences.UserPreferences.isQueueKeepSorted
import ac.mdiq.podcini.preferences.UserPreferences.queueKeepSortedOrder
import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert
@ -48,7 +48,7 @@ object Queues {
* @param markAsUnplayed true if the episodes should be marked as unplayed when enqueueing
* @param episodes the Episode objects that should be added to the queue.
*/
@UnstableApi @JvmStatic
@UnstableApi @JvmStatic @Synchronized
fun addToQueue(markAsUnplayed: Boolean, vararg episodes: Episode) : Job {
Logd(TAG, "addToQueue( ... ) called")
return runOnIOScope {
@ -59,7 +59,6 @@ object Queues {
val events: MutableList<FlowEvent.QueueEvent> = ArrayList()
val updatedItems: MutableList<Episode> = ArrayList()
val positionCalculator = EnqueuePositionCalculator(enqueueLocation)
// val currentlyPlaying = loadPlayableFromPreferences()
val currentlyPlaying = curMedia
var insertPosition = positionCalculator.calcPosition(curQueue.episodes, currentlyPlaying)
@ -67,9 +66,7 @@ object Queues {
val items_ = episodes.toList()
for (episode in items_) {
if (curQueue.episodeIds.contains(episode.id)) continue
// episode.isInAnyQueue = true
if (episode.isNew) markPlayed(Episode.UNPLAYED, false, episode)
upsert(episode) {}
events.add(FlowEvent.QueueEvent.added(episode, insertPosition))
curQueue.episodeIds.add(insertPosition, episode.id)
updatedItems.add(episode)
@ -89,12 +86,33 @@ object Queues {
EventFlow.postEvent(event)
}
// EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(updatedItems))
if (markAsUnplayed && markAsUnplayeds.size > 0) markPlayed(Episode.UNPLAYED, false, *markAsUnplayeds.toTypedArray())
if (markAsUnplayed && markAsUnplayeds.size > 0) setPlayState(Episode.UNPLAYED, false, *markAsUnplayeds.toTypedArray())
// if (performAutoDownload) autodownloadEpisodeMedia(context)
}
}
}
suspend fun addToQueueSync(markAsUnplayed: Boolean, episode: Episode) {
Logd(TAG, "addToQueueSync( ... ) called")
val currentlyPlaying = curMedia
val positionCalculator = EnqueuePositionCalculator(enqueueLocation)
var insertPosition = positionCalculator.calcPosition(curQueue.episodes, currentlyPlaying)
if (curQueue.episodeIds.contains(episode.id)) return
curQueue.episodeIds.add(insertPosition, episode.id)
curQueue.episodes.add(insertPosition, episode)
insertPosition++
curQueue.update()
upsert(curQueue) {}
if (markAsUnplayed && episode.isNew) setPlayState(Episode.UNPLAYED, false, episode)
EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, insertPosition))
// if (performAutoDownload) autodownloadEpisodeMedia(context)
}
/**
* Sorts the queue depending on the configured sort order.
* If the queue is not in keep sorted mode, nothing happens.
@ -142,7 +160,7 @@ object Queues {
}
@OptIn(UnstableApi::class)
fun removeFromAllQueues(vararg episodes: Episode) {
fun removeFromAllQueuesSync(vararg episodes: Episode) {
Logd(TAG, "removeFromAllQueues called ")
val queues = realm.query(PlayQueue::class).find()
for (q in queues) {
@ -163,7 +181,6 @@ object Queues {
val queue = queue_ ?: curQueue
val events: MutableList<FlowEvent.QueueEvent> = ArrayList()
val updatedItems: MutableList<Episode> = ArrayList()
val pos: MutableList<Int> = mutableListOf()
val qItems = queue.episodes.toMutableList()
for (i in qItems.indices) {
@ -171,7 +188,6 @@ object Queues {
if (episodes.contains(episode)) {
Logd(TAG, "removing from queue: ${episode.id} ${episode.title}")
pos.add(i)
updatedItems.add(episode)
if (queue.id == curQueue.id) events.add(FlowEvent.QueueEvent.removed(episode))
}
}
@ -260,53 +276,47 @@ object Queues {
class EnqueuePositionCalculator(private val enqueueLocation: EnqueueLocation) {
/**
* Determine the position (0-based) that the item(s) should be inserted to the named queue.
* @param curQueue the queue to which the item is to be inserted
* @param queueItems the queue to which the item is to be inserted
* @param currentPlaying the currently playing media
*/
fun calcPosition(curQueue: List<Episode>, currentPlaying: Playable?): Int {
fun calcPosition(queueItems: List<Episode>, currentPlaying: Playable?): Int {
when (enqueueLocation) {
EnqueueLocation.BACK -> return curQueue.size
EnqueueLocation.BACK -> return queueItems.size
EnqueueLocation.FRONT -> // Return not necessarily 0, so that when a list of items are downloaded and enqueued
// in succession of calls (e.g., users manually tapping download one by one),
// the items enqueued are kept the same order.
// Simply returning 0 will reverse the order.
return getPositionOfFirstNonDownloadingItem(0, curQueue)
return getPositionOfFirstNonDownloadingItem(0, queueItems)
EnqueueLocation.AFTER_CURRENTLY_PLAYING -> {
val currentlyPlayingPosition = getCurrentlyPlayingPosition(curQueue, currentPlaying)
return getPositionOfFirstNonDownloadingItem(currentlyPlayingPosition + 1, curQueue)
val currentlyPlayingPosition = getCurrentlyPlayingPosition(queueItems, currentPlaying)
return getPositionOfFirstNonDownloadingItem(currentlyPlayingPosition + 1, queueItems)
}
EnqueueLocation.RANDOM -> {
val random = Random()
return random.nextInt(curQueue.size + 1)
return random.nextInt(queueItems.size + 1)
}
else -> throw AssertionError("calcPosition() : unrecognized enqueueLocation option: $enqueueLocation")
}
}
private fun getPositionOfFirstNonDownloadingItem(startPosition: Int, curQueue: List<Episode>): Int {
val curQueueSize = curQueue.size
private fun getPositionOfFirstNonDownloadingItem(startPosition: Int, queueItems: List<Episode>): Int {
val curQueueSize = queueItems.size
for (i in startPosition until curQueueSize) {
if (!isItemAtPositionDownloading(i, curQueue)) return i
if (!isItemAtPositionDownloading(i, queueItems)) return i
}
return curQueueSize
}
private fun isItemAtPositionDownloading(position: Int, curQueue: List<Episode>): Boolean {
val curItem = try { curQueue[position] } catch (e: IndexOutOfBoundsException) { null }
private fun isItemAtPositionDownloading(position: Int, queueItems: List<Episode>): Boolean {
val curItem = try { queueItems[position] } catch (e: IndexOutOfBoundsException) { null }
if (curItem?.media?.downloadUrl == null) return false
return curItem.media != null && DownloadServiceInterface.get()?.isDownloadingEpisode(curItem.media!!.downloadUrl!!)?:false
}
companion object {
private fun getCurrentlyPlayingPosition(curQueue: List<Episode>, currentPlaying: Playable?): Int {
if (currentPlaying !is EpisodeMedia) return -1
val curPlayingItemId = currentPlaying.episode!!.id
for (i in curQueue.indices) {
if (curPlayingItemId == curQueue[i].id) return i
}
return -1
private fun getCurrentlyPlayingPosition(queueItems: List<Episode>, currentPlaying: Playable?): Int {
if (currentPlaying !is EpisodeMedia) return -1
val curPlayingItemId = currentPlaying.episode!!.id
for (i in queueItems.indices) {
if (curPlayingItemId == queueItems[i].id) return i
}
return -1
}
}
}

View File

@ -41,7 +41,7 @@ object RealmDB {
realm = Realm.open(config)
}
fun <T : RealmObject> unmanagedCopy(entity: T) : T {
fun <T : RealmObject> unmanaged(entity: T) : T {
if (BuildConfig.DEBUG) {
val stackTrace = Thread.currentThread().stackTrace
val caller = if (stackTrace.size > 3) stackTrace[3] else null
@ -70,19 +70,21 @@ object RealmDB {
suspend fun <T : RealmObject> update(entity: T, block: MutableRealm.(T) -> Unit) : T {
return realm.write {
findLatest(entity)?.let {
val result: T = findLatest(entity)?.let {
block(it)
}
entity
it
} ?: entity
result
}
}
suspend fun <T : EmbeddedRealmObject> update(entity: T, block: MutableRealm.(T) -> Unit) : T {
return realm.write {
findLatest(entity)?.let {
val result: T = findLatest(entity)?.let {
block(it)
}
entity
it
} ?: entity
result
}
}
@ -121,21 +123,24 @@ object RealmDB {
Logd(TAG, "${caller?.className}.${caller?.methodName} upsertBlk: ${entity.javaClass.simpleName}")
}
return realm.writeBlocking {
var result: T = entity
if (entity.isManaged()) {
findLatest(entity)?.let {
result = findLatest(entity)?.let {
block(it)
}
it
} ?: entity
} else {
try {
copyToRealm(entity, UpdatePolicy.ALL).let {
result = copyToRealm(entity, UpdatePolicy.ALL).let {
block(it)
it
}
} catch (e: Exception) {
Log.e(TAG, "copyToRealm error: ${e.message}")
showStackTrace()
}
}
entity
result
}
}

View File

@ -30,10 +30,6 @@ class Chapter : EmbeddedRealmObject {
this.imageUrl = imageUrl
}
fun getHumanReadableIdentifier(): String? {
return title
}
override fun toString(): String {
return "ID3Chapter [title=$title, start=$start, url=$link]"
}

View File

@ -16,7 +16,6 @@ import java.util.*
/**
* Episode within a feed.
*
*/
class Episode : RealmObject {
@ -52,7 +51,7 @@ class Episode : RealmObject {
@Ignore
var feed: Feed? = null
get() {
if (field == null && feedId != null) field = getFeed(feedId!!)
if (field == null && feedId != null) field = getFeed(feedId!!, fromDB = true)
return field
}
@ -64,14 +63,6 @@ class Episode : RealmObject {
var paymentLink: String? = null
/**
* Is true if the database contains any chapters that belong to this item. This attribute is only
* written once by DBReader on initialization.
* The FeedItem might still have a non-null chapters value. In this case, the list of chapters
* has not been saved in the database yet.
*/
// private var hasChapters: Boolean
/**
* Returns the image of this item, as specified in the feed.
* To load the image that can be displayed to the user, use [.getImageLocation],
@ -132,7 +123,6 @@ class Episode : RealmObject {
constructor() {
this.playState = UNPLAYED
// this.hasChapters = false
}
/**
@ -143,11 +133,10 @@ class Episode : RealmObject {
this.title = title
this.identifier = itemIdentifier
this.link = link
this.pubDate = if (pubDate != null) pubDate.time else 0
this.pubDate = pubDate?.time ?: 0
this.playState = state
if (feed != null) this.feedId = feed.id
this.feed = feed
// this.hasChapters = false
}
fun updateFromOther(other: Episode) {
@ -218,7 +207,6 @@ class Episode : RealmObject {
*/
fun setDescriptionIfLonger(newDescription: String?) {
if (newDescription.isNullOrEmpty()) return
when {
this.description == null -> this.description = newDescription
description!!.length < newDescription.length -> this.description = newDescription
@ -227,25 +215,12 @@ class Episode : RealmObject {
fun setTranscriptIfLonger(newTranscript: String?) {
if (newTranscript.isNullOrEmpty()) return
when {
this.transcript == null -> this.transcript = newTranscript
transcript!!.length < newTranscript.length -> this.transcript = newTranscript
}
}
// enum class State {
// UNREAD, IN_PROGRESS, READ, PLAYING
// }
fun getHumanReadableIdentifier(): String? {
return title
}
// fun hasChapters(): Boolean {
// return chapters.isNotEmpty()
// }
/**
* Get the link for the feed item for the purpose of Share. It fallbacks to
* use the feed's link if the named feed item has no link.

View File

@ -1,10 +1,9 @@
package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.net.feed.parser.media.id3.ChapterReader
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat
import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.showStackTrace
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
@ -214,8 +213,9 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
}
fun hasEmbeddedPicture(): Boolean {
// TODO: checkEmbeddedPicture needs to update current copy
if (hasEmbeddedPicture == null) checkEmbeddedPicture()
return hasEmbeddedPicture!!
return hasEmbeddedPicture ?: false
}
override fun writeToParcel(dest: Parcel, flags: Int) {
@ -340,6 +340,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
hasEmbeddedPicture = false
}
}
upsertBlk(episode!!) {}
}
override fun equals(o: Any?): Boolean {

View File

@ -66,7 +66,7 @@ interface Playable : Parcelable, Serializable {
* if last played time is unknown.
*/
/**
* @param lastPlayedTimestamp timestamp in ms
* @param lastPlayedTime timestamp in ms
*/
fun getLastPlayedTime(): Long
@ -137,7 +137,7 @@ interface Playable : Parcelable, Serializable {
fun setDuration(newDuration: Int)
fun setLastPlayedTime(lastPlayedTimestamp: Long)
fun setLastPlayedTime(lastPlayedTime: Long)
/**
* Returns the location of the image or null if no image is available.

View File

@ -156,8 +156,8 @@ class RemoteMedia : Playable {
duration = newDuration
}
override fun setLastPlayedTime(lastPlayedTimestamp: Long) {
lastPlayedTime = lastPlayedTimestamp
override fun setLastPlayedTime(lastPlayedTime: Long) {
this.lastPlayedTime = lastPlayedTime
}
override fun onPlaybackStart() {

View File

@ -2,9 +2,10 @@ package ac.mdiq.podcini.storage.model
import java.util.ArrayList
class StatisticsItem(val feed: Feed, val time: Long,
val timePlayed: Long, // Respects speed, listening twice, ...
val episodes: Long, // Number of episodes.
class StatisticsItem(val feed: Feed,
val time: Long, // total time, in seconds
val timePlayed: Long, // in seconds, Respects speed, listening twice, ...
val numEpisodes: Long, // Number of episodes.
val episodesStarted: Long, // Episodes that are actually played.
val totalDownloadSize: Long, // Simply sums up the size of download podcasts.
val episodesDownloadCount: Long // Stores the number of episodes downloaded.
@ -17,6 +18,6 @@ class MonthlyStatisticsItem {
}
class StatisticsResult {
var feedTime: MutableList<StatisticsItem> = ArrayList()
var statsItems: MutableList<StatisticsItem> = ArrayList()
var oldestDate: Long = System.currentTimeMillis()
}

View File

@ -9,7 +9,7 @@ import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Queues
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.ui.utils.LocalDeleteModal
@ -28,11 +28,11 @@ class EpisodeMultiSelectHandler(private val activity: MainActivity, private val
R.id.add_to_queue_batch -> queueChecked(items)
R.id.remove_from_queue_batch -> removeFromQueueChecked(items)
R.id.mark_read_batch -> {
markPlayed(Episode.PLAYED, false, *items.toTypedArray())
setPlayState(Episode.PLAYED, false, *items.toTypedArray())
showMessage(R.plurals.marked_read_batch_label, items.size)
}
R.id.mark_unread_batch -> {
markPlayed(Episode.UNPLAYED, false, *items.toTypedArray())
setPlayState(Episode.UNPLAYED, false, *items.toTypedArray())
showMessage(R.plurals.marked_unread_batch_label, items.size)
}
R.id.download_batch -> downloadChecked(items)

View File

@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.actions.actionbutton
import android.content.Context
import android.view.View
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.model.Episode
import androidx.media3.common.util.UnstableApi
@ -15,7 +15,7 @@ class MarkAsPlayedActionButton(item: Episode) : EpisodeActionButton(item) {
return R.drawable.ic_check
}
@UnstableApi override fun onClick(context: Context) {
if (!item.isPlayed()) markPlayed(Episode.PLAYED, true, item)
if (!item.isPlayed()) setPlayState(Episode.PLAYED, true, item)
}
override val visibility: Int

View File

@ -7,35 +7,31 @@ import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.ACTION_SHUTDOWN_PLAYBACK_SERVICE
import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItemsOnFeed
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.ui.dialog.ShareDialog
import ac.mdiq.podcini.ui.utils.LocalDeleteModal
import ac.mdiq.podcini.util.*
import android.os.Handler
import ac.mdiq.podcini.util.IntentUtils
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.ShareUtils
import android.view.KeyEvent
import android.view.Menu
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.ceil
/**
@ -147,7 +143,7 @@ object EpisodeMenuHandler {
}
R.id.mark_read_item -> {
selectedItem.setPlayed(true)
markPlayed(Episode.PLAYED, true, selectedItem)
setPlayState(Episode.PLAYED, true, selectedItem)
if (selectedItem.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) {
val media: EpisodeMedia? = selectedItem.media
// not all items have media, Gpodder only cares about those that do
@ -164,7 +160,7 @@ object EpisodeMenuHandler {
}
R.id.mark_unread_item -> {
selectedItem.setPlayed(false)
markPlayed(Episode.UNPLAYED, false, selectedItem)
setPlayState(Episode.UNPLAYED, false, selectedItem)
if (selectedItem.feed?.isLocalFeed != true && selectedItem.media != null) {
val actionNew: EpisodeAction = EpisodeAction.Builder(selectedItem, EpisodeAction.NEW)
.currentTimestamp()
@ -182,7 +178,7 @@ object EpisodeMenuHandler {
writeNoMediaPlaying()
IntentUtils.sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE)
}
markPlayed(Episode.UNPLAYED, true, selectedItem)
setPlayState(Episode.UNPLAYED, true, selectedItem)
}
R.id.visit_website_item -> {
val url = selectedItem.getLinkWithFallback()
@ -200,46 +196,4 @@ object EpisodeMenuHandler {
// Refresh menu state
return true
}
/**
* Remove new flag with additional UI logic to allow undo with Snackbar.
* Undo is useful for Remove new flag, given there is no UI to undo it otherwise
* ,i.e., there is (context) menu item for add new flag
*/
@JvmStatic
fun markReadWithUndo(fragment: Fragment, item: Episode?, playState: Int, showSnackbar: Boolean) {
if (item == null) return
Logd(TAG, "markReadWithUndo( ${item.id} )")
// we're marking it as unplayed since the user didn't actually play it
// but they don't want it considered 'NEW' anymore
markPlayed(playState, false, item)
val h = Handler(fragment.requireContext().mainLooper)
val r = Runnable {
val media: EpisodeMedia? = item.media
val shouldAutoDelete: Boolean = if (item.feed == null) false else shouldAutoDeleteItemsOnFeed(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) deleteMediaOfEpisode(fragment.requireContext(), item)
}
val playStateStringRes: Int = when (playState) {
Episode.UNPLAYED -> if (item.playState == Episode.NEW) R.string.removed_inbox_label //was new
else R.string.marked_as_unplayed_label //was played
Episode.PLAYED -> R.string.marked_as_played_label
else -> if (item.playState == Episode.NEW) R.string.removed_inbox_label
else R.string.marked_as_unplayed_label
}
val duration: Int = Snackbar.LENGTH_LONG
if (showSnackbar) {
(fragment.activity as MainActivity).showSnackbarAbovePlayer(
playStateStringRes, duration)
.setAction(fragment.getString(R.string.undo)) {
markPlayed(item.playState, false, item)
// don't forget to cancel the thing that's going to remove the media
h.removeCallbacks(r)
}
}
h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toInt().toLong())
}
}

View File

@ -2,7 +2,7 @@ package ac.mdiq.podcini.ui.actions.swipeactions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.markPlayed
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert
@ -61,17 +61,12 @@ class RemoveFromQueueSwipeAction : SwipeAction {
fun addToQueueAt(episode: Episode, index: Int) : Job {
return runOnIOScope {
if (curQueue.episodeIds.contains(episode.id)) return@runOnIOScope
// episode.queueId = curQueue.id
// episode.isInCurQueue = true
if (episode.isNew) markPlayed(Episode.UNPLAYED, false, episode)
upsert(episode) {}
if (episode.isNew) setPlayState(Episode.UNPLAYED, false, episode)
curQueue.update()
curQueue.episodeIds.add(index, episode.id)
curQueue.episodes.add(index, episode)
upsert(curQueue) {}
EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, index))
// EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(episode))
// if (performAutoDownload) autodownloadEpisodeMedia(context)
}
}

View File

@ -3,9 +3,23 @@ package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler.markReadWithUndo
import ac.mdiq.podcini.preferences.UserPreferences.shouldAutoDeleteItem
import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.util.Logd
import android.os.Handler
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.math.ceil
class TogglePlaybackStateSwipeAction : SwipeAction {
override fun getId(): String {
@ -26,7 +40,50 @@ class TogglePlaybackStateSwipeAction : SwipeAction {
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val newState = if (item.playState == Episode.UNPLAYED) Episode.PLAYED else Episode.UNPLAYED
markReadWithUndo(fragment, item, newState, willRemove(filter, item))
Logd("TogglePlaybackStateSwipeAction", "performAction( ${item.id} )")
// we're marking it as unplayed since the user didn't actually play it
// but they don't want it considered 'NEW' anymore
var item = runBlocking { setPlayStateSync(newState, false, item) }
val h = Handler(fragment.requireContext().mainLooper)
val r = Runnable {
val media: EpisodeMedia? = item.media
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
item = deleteMediaSync(fragment.requireContext(), item)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item) }
}
val playStateStringRes: Int = when (newState) {
Episode.UNPLAYED -> if (item.playState == Episode.NEW) R.string.removed_inbox_label //was new
else R.string.marked_as_unplayed_label //was played
Episode.PLAYED -> R.string.marked_as_played_label
else -> if (item.playState == Episode.NEW) R.string.removed_inbox_label
else R.string.marked_as_unplayed_label
}
val duration: Int = Snackbar.LENGTH_LONG
if (willRemove(filter, item)) {
(fragment.activity as MainActivity).showSnackbarAbovePlayer(
playStateStringRes, duration)
.setAction(fragment.getString(R.string.undo)) {
setPlayState(item.playState, false, item)
// don't forget to cancel the thing that's going to remove the media
h.removeCallbacks(r)
}
}
h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toLong())
}
private fun delayedExecution(item: Episode, fragment: Fragment, duration: Float) = runBlocking {
delay(ceil((duration * 1.05f).toDouble()).toLong())
val media: EpisodeMedia? = item.media
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
// deleteMediaOfEpisode(fragment.requireContext(), item)
var item = deleteMediaSync(fragment.requireContext(), item)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, null, item) }
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {

View File

@ -20,7 +20,6 @@ import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.receiver.PlayerWidget
import ac.mdiq.podcini.storage.database.Feeds.monitorFeeds
import ac.mdiq.podcini.storage.database.Feeds.updateFeedMap
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
@ -130,7 +129,7 @@ class MainActivity : CastEnabledActivity() {
NavDrawerFragment.getSharedPrefs(this@MainActivity)
SwipeActions.getSharedPrefs(this@MainActivity)
QueueFragment.getSharedPrefs(this@MainActivity)
updateFeedMap()
// updateFeedMap()
monitorFeeds()
// InTheatre.apply { }
PlayerDetailsFragment.getSharedPrefs(this@MainActivity)
@ -223,7 +222,7 @@ class MainActivity : CastEnabledActivity() {
}
}
}
EventFlow.postStickyEvent(FlowEvent.FeedUpdateRunningEvent(isRefreshingFeeds))
EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(isRefreshingFeeds))
}
observeDownloads()
}

View File

@ -1,8 +1,7 @@
package ac.mdiq.podcini.ui.adapter
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
@ -12,6 +11,7 @@ import ac.mdiq.podcini.ui.utils.ThemeUtils
import ac.mdiq.podcini.ui.view.viewholder.EpisodeViewHolder
import android.R.color
import android.app.Activity
import android.os.Bundle
import android.view.*
import androidx.media3.common.util.UnstableApi
@ -88,8 +88,10 @@ open class EpisodesAdapter(mainActivity: MainActivity)
beforeBindViewHolder(holder, pos)
val item: Episode = unmanagedCopy(episodes[pos])
val item: Episode = unmanaged(episodes[pos])
// val item: Episode = episodes[pos]
if (feed != null) item.feed = feed
else item.feed = episodes[pos].feed
holder.bind(item)
// holder.infoCard.setOnCreateContextMenuListener(this)
@ -128,6 +130,17 @@ open class EpisodesAdapter(mainActivity: MainActivity)
holder.hideSeparatorIfNecessary()
}
override fun onBindViewHolder(holder: EpisodeViewHolder, pos: Int, payloads: MutableList<Any>) {
if (payloads.isEmpty()) onBindViewHolder(holder, pos)
else {
val payload = payloads[0]
when {
payload is String && payload == "foo" -> onBindViewHolder(holder, pos)
payload is Bundle && !payload.getString("PositionUpdate").isNullOrEmpty() -> holder.updatePlaybackPositionNew(unmanaged(episodes[pos]))
}
}
}
protected open fun beforeBindViewHolder(holder: EpisodeViewHolder, pos: Int) {}
protected open fun afterBindViewHolder(holder: EpisodeViewHolder, pos: Int) {}

View File

@ -16,7 +16,7 @@ abstract class SelectableAdapter<T : RecyclerView.ViewHolder?>(private val activ
private val selectedIds = HashSet<Long>()
private var onSelectModeListener: OnSelectModeListener? = null
var shouldSelectLazyLoadedItems: Boolean = false
private var totalNumberOfItems = COUNT_AUTOMATICALLY
internal var totalNumberOfItems = COUNT_AUTOMATICALLY
fun startSelectMode(pos: Int) {
if (inActionMode()) endSelectMode()

View File

@ -2,7 +2,7 @@ package ac.mdiq.podcini.ui.dialog
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Feed
import android.app.Activity
@ -29,7 +29,7 @@ class CustomFeedNameDialog(activity: Activity, private var feed: Feed) {
.setView(binding.root)
.setTitle(R.string.rename_feed_label)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
feed = unmanagedCopy(feed)
feed = unmanaged(feed)
val newTitle = binding.editText.text.toString()
feed.customTitle = newTitle
upsertBlk(feed) {}

View File

@ -32,10 +32,8 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() {
val newFilterValues: MutableSet<String> = HashSet()
for (i in 0 until rows.childCount) {
if (rows.getChildAt(i) !is MaterialButtonToggleGroup) continue
val group = rows.getChildAt(i) as MaterialButtonToggleGroup
if (group.checkedButtonId == View.NO_ID) continue
val tag = group.findViewById<View>(group.checkedButtonId).tag as? String ?: continue
newFilterValues.add(tag)
}
@ -51,21 +49,24 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() {
//add filter rows
for (item in FeedItemFilterGroup.entries) {
// Logd("EpisodeFilterDialog", "FeedItemFilterGroup: ${item.values[0].filterId} ${item.values[1].filterId}")
val rowBinding = FilterDialogRowBinding.inflate(inflater)
rowBinding.root.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, _: Int, _: Boolean ->
onFilterChanged(newFilterValues)
}
rowBinding.filterButton1.setText(item.values[0].displayName)
rowBinding.filterButton1.tag = item.values[0].filterId
buttonMap[item.values[0].filterId] = rowBinding.filterButton1
rowBinding.filterButton2.setText(item.values[1].displayName)
rowBinding.filterButton2.tag = item.values[1].filterId
buttonMap[item.values[1].filterId] = rowBinding.filterButton2
rowBinding.filterButton1.maxLines = 3
rowBinding.filterButton1.isSingleLine = false
rowBinding.filterButton2.maxLines = 3
rowBinding.filterButton2.isSingleLine = false
rows.addView(rowBinding.root, rows.childCount - 1)
val rBinding = FilterDialogRowBinding.inflate(inflater)
// rowBinding.root.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, _: Int, _: Boolean ->
// onFilterChanged(newFilterValues)
// }
rBinding.filterButton1.setOnClickListener { onFilterChanged(newFilterValues) }
rBinding.filterButton2.setOnClickListener { onFilterChanged(newFilterValues) }
rBinding.filterButton1.setText(item.values[0].displayName)
rBinding.filterButton1.tag = item.values[0].filterId
buttonMap[item.values[0].filterId] = rBinding.filterButton1
rBinding.filterButton2.setText(item.values[1].displayName)
rBinding.filterButton2.tag = item.values[1].filterId
buttonMap[item.values[1].filterId] = rBinding.filterButton2
rBinding.filterButton1.maxLines = 3
rBinding.filterButton1.isSingleLine = false
rBinding.filterButton2.maxLines = 3
rBinding.filterButton2.isSingleLine = false
rows.addView(rBinding.root, rows.childCount - 1)
}
binding.confirmFiltermenu.setOnClickListener { dismiss() }
@ -121,7 +122,6 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() {
QUEUED(ItemProperties(R.string.queued_label, EpisodeFilter.QUEUED), ItemProperties(R.string.not_queued_label, EpisodeFilter.NOT_QUEUED)),
DOWNLOADED(ItemProperties(R.string.hide_downloaded_episodes_label, EpisodeFilter.DOWNLOADED), ItemProperties(R.string.hide_not_downloaded_episodes_label, EpisodeFilter.NOT_DOWNLOADED));
// this.values = values as Array<ItemProperties>
@JvmField
val values: Array<ItemProperties> = arrayOf(*values)

View File

@ -41,7 +41,7 @@ open class EpisodeSortDialog : BottomSheetDialogFragment() {
onAddItem(R.string.episode_title, SortOrder.EPISODE_TITLE_A_Z, SortOrder.EPISODE_TITLE_Z_A, true)
onAddItem(R.string.feed_title, SortOrder.FEED_TITLE_A_Z, SortOrder.FEED_TITLE_Z_A, true)
onAddItem(R.string.duration, SortOrder.DURATION_SHORT_LONG, SortOrder.DURATION_LONG_SHORT, true)
onAddItem(R.string.date, SortOrder.DATE_OLD_NEW, SortOrder.DATE_NEW_OLD, false)
onAddItem(R.string.publish_date, SortOrder.DATE_OLD_NEW, SortOrder.DATE_NEW_OLD, false)
onAddItem(R.string.last_played_date, SortOrder.PLAYED_DATE_OLD_NEW, SortOrder.PLAYED_DATE_NEW_OLD, false)
onAddItem(R.string.completed_date, SortOrder.COMPLETED_DATE_OLD_NEW, SortOrder.COMPLETED_DATE_NEW_OLD, false)
onAddItem(R.string.size, SortOrder.SIZE_SMALL_LARGE, SortOrder.SIZE_LARGE_SMALL, false)

View File

@ -1,7 +1,7 @@
package ac.mdiq.podcini.ui.dialog
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Feeds.deleteFeed
import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
@ -12,8 +12,10 @@ import android.content.DialogInterface
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.*
import java.lang.Runnable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object RemoveFeedDialog {
private val TAG: String = RemoveFeedDialog::class.simpleName ?: "Anonymous"
@ -46,9 +48,9 @@ object RemoveFeedDialog {
try {
withContext(Dispatchers.IO) {
for (feed in feeds) {
deleteFeed(context, feed.id, false)
deleteFeedSync(context, feed.id, false)
}
// EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds))
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds.map { it.id }))
}
withContext(Dispatchers.Main) {
Logd(TAG, "Feed(s) deleted")

View File

@ -5,13 +5,11 @@ import ac.mdiq.podcini.databinding.EditTagsDialogBinding
import ac.mdiq.podcini.storage.database.Feeds.buildTags
import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.ui.adapter.SimpleChipAdapter
import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
@ -108,7 +106,7 @@ class TagSettingsDialog : DialogFragment() {
// displayedTags.add(FeedPreferences.TAG_ROOT)
// }
for (feed_ in feedList) {
val feed = unmanagedCopy(feed_)
val feed = unmanaged(feed_)
if (feed.preferences != null) {
feed.preferences!!.tags.removeAll(commonTags)
feed.preferences!!.tags.addAll(displayedTags)

View File

@ -13,7 +13,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.preferences.UserPreferences.playbackSpeedArray
import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.MediaType
import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration
@ -229,7 +229,7 @@ import java.util.*
if (episode != null) {
var feed = episode.feed
if (feed != null) {
feed = unmanagedCopy(feed)
feed = unmanaged(feed)
val feedPrefs = feed.preferences
if (feedPrefs != null) {
feedPrefs.playSpeed = speed

View File

@ -43,7 +43,6 @@ import kotlin.math.min
toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label)
updateToolbar()
updateFilterUi()
txtvInformation.setOnClickListener {
AllEpisodesFilterDialog.newInstance(getFilter()).show(childFragmentManager, null)
}
@ -62,6 +61,7 @@ import kotlin.math.min
override fun loadData(): List<Episode> {
allEpisodes = getEpisodes(0, Int.MAX_VALUE, getFilter(), allEpisodesSortOrder, false)
Logd(TAG, "loadData ${allEpisodes.size}")
if (allEpisodes.isEmpty()) return listOf()
return allEpisodes.subList(0, min(allEpisodes.size-1, page * EPISODES_PER_PAGE))
}
@ -123,18 +123,19 @@ import kotlin.math.min
private fun onFilterChanged(event: FlowEvent.AllEpisodesFilterEvent) {
prefFilterAllEpisodes = StringUtils.join(event.filterValues, ",")
updateFilterUi()
page = 1
loadItems()
}
private fun updateFilterUi() {
override fun updateToolbar() {
swipeActions.setFilter(getFilter())
if (getFilter().values.isNotEmpty()) {
txtvInformation.visibility = View.VISIBLE
txtvInformation.text = "${adapter.totalNumberOfItems} episodes - filtered"
emptyView.setMessage(R.string.no_all_episodes_filtered_label)
} else {
txtvInformation.visibility = View.GONE
txtvInformation.visibility = View.VISIBLE
txtvInformation.text = "${adapter.totalNumberOfItems} episodes"
emptyView.setMessage(R.string.no_all_episodes_label)
}
toolbar.menu?.findItem(R.id.action_favorites)?.setIcon(if (getFilter().showIsFavorite) R.drawable.ic_star else R.drawable.ic_star_border)

View File

@ -23,7 +23,6 @@ import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.model.Chapter
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Playable

View File

@ -4,7 +4,9 @@ import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.BaseEpisodesListFragmentBinding
import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
@ -17,7 +19,6 @@ import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.utils.EmptyViewHandler
import ac.mdiq.podcini.ui.view.EpisodesRecyclerView
import ac.mdiq.podcini.ui.utils.LiftOnScrollListener
import ac.mdiq.podcini.ui.view.viewholder.EpisodeViewHolder
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
@ -63,10 +64,10 @@ import kotlinx.coroutines.flow.collectLatest
lateinit var swipeRefreshLayout: SwipeRefreshLayout
lateinit var swipeActions: SwipeActions
private lateinit var progressBar: ProgressBar
lateinit var listAdapter: EpisodesAdapter
lateinit var adapter: EpisodesAdapter
protected lateinit var txtvInformation: TextView
private var currentPlaying: EpisodeViewHolder? = null
private var curIndex = -1
@JvmField
var episodes: MutableList<Episode> = ArrayList()
@ -119,7 +120,7 @@ import kotlinx.coroutines.flow.collectLatest
emptyView.setIcon(R.drawable.ic_feed)
emptyView.setTitle(R.string.no_all_episodes_head_label)
emptyView.setMessage(R.string.no_all_episodes_label)
emptyView.updateAdapter(listAdapter)
emptyView.updateAdapter(adapter)
emptyView.hide()
val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root)
@ -131,7 +132,7 @@ import kotlinx.coroutines.flow.collectLatest
return false
}
override fun onToggleChanged(open: Boolean) {
if (open && listAdapter.selectedCount == 0) {
if (open && adapter.selectedCount == 0) {
(activity as MainActivity).showSnackbarAbovePlayer(R.string.no_items_selected, Snackbar.LENGTH_SHORT)
speedDialView.close()
}
@ -139,7 +140,7 @@ import kotlinx.coroutines.flow.collectLatest
})
speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem ->
var confirmationString = 0
if (listAdapter.selectedItems.size >= 25 || listAdapter.shouldSelectLazyLoadedItems()) {
if (adapter.selectedItems.size >= 25 || adapter.shouldSelectLazyLoadedItems()) {
// Should ask for confirmation
when (actionItem.id) {
R.id.mark_read_batch -> confirmationString = R.string.multi_select_mark_played_confirmation
@ -160,7 +161,7 @@ import kotlinx.coroutines.flow.collectLatest
}
open fun createListAdaptor() {
listAdapter = object : EpisodesAdapter(activity as MainActivity) {
adapter = object : EpisodesAdapter(activity as MainActivity) {
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
super.onCreateContextMenu(menu, v, menuInfo)
// if (!inActionMode()) {
@ -171,8 +172,8 @@ import kotlinx.coroutines.flow.collectLatest
}
}
}
listAdapter.setOnSelectModeListener(this)
recyclerView.adapter = listAdapter
adapter.setOnSelectModeListener(this)
recyclerView.adapter = adapter
}
override fun onStart() {
@ -221,13 +222,13 @@ import kotlinx.coroutines.flow.collectLatest
// The method is called on all fragments in a ViewPager, so this needs to be ignored in invisible ones.
// Apparently, none of the visibility check method works reliably on its own, so we just use all.
!userVisibleHint || !isVisible || !isMenuVisible -> return false
listAdapter.longPressedItem == null -> {
adapter.longPressedItem == null -> {
Logd(TAG, "Selected item or listAdapter was null, ignoring selection")
return super.onContextItemSelected(item)
}
listAdapter.onContextItemSelected(item) -> return true
adapter.onContextItemSelected(item) -> return true
else -> {
val selectedItem: Episode = listAdapter.longPressedItem ?: return false
val selectedItem: Episode = adapter.longPressedItem ?: return false
return EpisodeMenuHandler.onMenuItemClicked(this, item.itemId, selectedItem)
}
}
@ -238,8 +239,8 @@ import kotlinx.coroutines.flow.collectLatest
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
handler.handleAction(listAdapter.selectedItems.filterIsInstance<Episode>())
if (listAdapter.shouldSelectLazyLoadedItems()) {
handler.handleAction(adapter.selectedItems.filterIsInstance<Episode>())
if (adapter.shouldSelectLazyLoadedItems()) {
var applyPage = page + 1
var nextPage: List<Episode>
do {
@ -249,7 +250,7 @@ import kotlinx.coroutines.flow.collectLatest
} while (nextPage.size == EPISODES_PER_PAGE)
}
withContext(Dispatchers.Main) {
listAdapter.endSelectMode()
adapter.endSelectMode()
}
}
} catch (e: Throwable) {
@ -290,12 +291,12 @@ import kotlinx.coroutines.flow.collectLatest
}
withContext(Dispatchers.Main) {
// listAdapter.setDummyViews(0)
listAdapter.updateItems(episodes)
if (listAdapter.shouldSelectLazyLoadedItems()) listAdapter.setSelected(episodes.size - data.size, episodes.size, true)
adapter.updateItems(episodes)
if (adapter.shouldSelectLazyLoadedItems()) adapter.setSelected(episodes.size - data.size, episodes.size, true)
}
} catch (e: Throwable) {
// listAdapter.setDummyViews(0)
listAdapter.updateItems(emptyList())
adapter.updateItems(emptyList())
Log.e(TAG, Log.getStackTraceString(e))
} finally {
withContext(Dispatchers.Main) { recyclerView.post { isLoadingMore = false } }
@ -306,7 +307,7 @@ import kotlinx.coroutines.flow.collectLatest
override fun onDestroyView() {
super.onDestroyView()
_binding = null
listAdapter.endSelectMode()
adapter.endSelectMode()
}
override fun onStartSelectMode() {
@ -326,25 +327,21 @@ import kotlinx.coroutines.flow.collectLatest
episodes.removeAt(pos)
if (getFilter().matches(item)) {
episodes.add(pos, item)
listAdapter.notifyItemChangedCompat(pos)
} else listAdapter.notifyItemRemoved(pos)
adapter.notifyItemChangedCompat(pos)
} else adapter.notifyItemRemoved(pos)
}
}
}
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}")
if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until listAdapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder
if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) {
currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event)
break
}
}
val item = (event.media as? EpisodeMedia)?.episode ?: return
val pos = if (curIndex in 0..<episodes.size && event.media.getIdentifier() == episodes[curIndex].media?.getIdentifier() && isCurMedia(episodes[curIndex].media))
curIndex else EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes[pos] = item
curIndex = pos
adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") })
}
}
@ -352,7 +349,7 @@ import kotlinx.coroutines.flow.collectLatest
if (!isAdded || !isVisible || !isMenuVisible) return
when (event.keyCode) {
KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0)
KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(listAdapter.itemCount)
KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(adapter.itemCount)
else -> {}
}
}
@ -360,7 +357,7 @@ import kotlinx.coroutines.flow.collectLatest
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
for (downloadUrl in event.urls) {
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl)
if (pos >= 0) listAdapter.notifyItemChangedCompat(pos)
if (pos >= 0) adapter.notifyItemChangedCompat(pos)
}
}
@ -393,7 +390,7 @@ import kotlinx.coroutines.flow.collectLatest
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event)
is FlowEvent.FeedUpdateRunningEvent -> onFeedUpdateRunningEvent(event)
is FlowEvent.FeedUpdatingEvent -> onFeedUpdateRunningEvent(event)
else -> {}
}
}
@ -424,14 +421,14 @@ import kotlinx.coroutines.flow.collectLatest
hasMoreItems = !(page == 1 && episodes.size < EPISODES_PER_PAGE)
progressBar.visibility = View.GONE
// listAdapter.setDummyViews(0)
listAdapter.updateItems(episodes)
listAdapter.setTotalNumberOfItems(data.second)
adapter.updateItems(episodes)
adapter.setTotalNumberOfItems(data.second)
if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName())
updateToolbar()
}
} catch (e: Throwable) {
// listAdapter.setDummyViews(0)
listAdapter.updateItems(emptyList())
adapter.updateItems(emptyList())
Log.e(TAG, Log.getStackTraceString(e))
}
}
@ -452,8 +449,8 @@ import kotlinx.coroutines.flow.collectLatest
protected open fun updateToolbar() {}
private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) {
swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning
private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdatingEvent) {
swipeRefreshLayout.isRefreshing = event.isRunning
}
override fun onSaveInstanceState(outState: Bundle) {

View File

@ -5,6 +5,7 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.databinding.SimpleListFragmentBinding
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Episodes.getEpisodes
import ac.mdiq.podcini.storage.database.RealmDB.realm
@ -69,7 +70,7 @@ import java.util.*
private lateinit var emptyView: EmptyViewHandler
private var displayUpArrow = false
private var currentPlaying: EpisodeViewHolder? = null
private var curIndex = -1
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SimpleListFragmentBinding.inflate(inflater)
@ -239,8 +240,7 @@ import java.util.*
val item = event.episode
val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
episodes.add(pos, item)
episodes[pos] = item
adapter.notifyItemChangedCompat(pos)
}
}
@ -250,8 +250,7 @@ import java.util.*
val item = event.episode
val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
episodes.add(pos, item)
episodes[pos] = item
adapter.notifyItemChangedCompat(pos)
}
}
@ -302,18 +301,14 @@ import java.util.*
}
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onPlaybackPositionEvent called with ${event.TAG}")
if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Logd(TAG, "onPlaybackPositionEvent ${event.TAG} search list")
for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder
if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) {
currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event)
break
}
}
val item = (event.media as? EpisodeMedia)?.episode ?: return
val pos = if (curIndex in 0..<episodes.size && event.media.getIdentifier() == episodes[curIndex].media?.getIdentifier() && isCurMedia(episodes[curIndex].media))
curIndex else EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes[pos] = item
curIndex = pos
adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") })
}
refreshInfoBar()
}

View File

@ -61,7 +61,7 @@ class EpisodeHomeFragment : Fragment() {
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED)
if (!currentItem?.link.isNullOrEmpty()) showContent()
if (!episode?.link.isNullOrEmpty()) showContent()
else {
Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show()
parentFragmentManager.popBackStack()
@ -88,24 +88,24 @@ class EpisodeHomeFragment : Fragment() {
@OptIn(UnstableApi::class) private fun showReaderContent() {
runOnIOScope {
if (!currentItem?.link.isNullOrEmpty()) {
if (!episode?.link.isNullOrEmpty()) {
if (cleanedNotes == null) {
if (currentItem?.transcript == null) {
val url = currentItem!!.link!!
if (episode?.transcript == null) {
val url = episode!!.link!!
val htmlSource = fetchHtmlSource(url)
val article = Readability4JExtended(currentItem?.link!!, htmlSource).parse()
val article = Readability4JExtended(episode?.link!!, htmlSource).parse()
readerText = article.textContent
// Log.d(TAG, "readability4J: ${article.textContent}")
readerhtml = article.contentWithDocumentsCharsetOrUtf8
} else {
readerhtml = currentItem!!.transcript
readerhtml = episode!!.transcript
readerText = HtmlCompat.fromHtml(readerhtml!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
}
if (!readerhtml.isNullOrEmpty()) {
val shownotesCleaner = ShownotesCleaner(requireContext())
cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0)
currentItem!!.setTranscriptIfLonger(readerhtml)
persistEpisode(currentItem)
episode!!.setTranscriptIfLonger(readerhtml)
persistEpisode(episode)
}
}
}
@ -133,12 +133,12 @@ class EpisodeHomeFragment : Fragment() {
if (tts == null) {
tts = TextToSpeech(context) { status: Int ->
if (status == TextToSpeech.SUCCESS) {
if (currentItem?.feed?.language != null) {
val result = tts?.setLanguage(Locale(currentItem!!.feed!!.language!!))
if (episode?.feed?.language != null) {
val result = tts?.setLanguage(Locale(episode!!.feed!!.language!!))
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Log.w(TAG, "TTS language not supported ${currentItem?.feed?.language}")
Log.w(TAG, "TTS language not supported ${episode?.feed?.language}")
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${currentItem?.feed?.language}", Toast.LENGTH_LONG).show()
Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${episode?.feed?.language}", Toast.LENGTH_LONG).show()
}
}
}
@ -154,10 +154,10 @@ class EpisodeHomeFragment : Fragment() {
}
private fun showWebContent() {
if (!currentItem?.link.isNullOrEmpty()) {
if (!episode?.link.isNullOrEmpty()) {
binding.webView.settings.javaScriptEnabled = jsEnabled
Logd(TAG, "currentItem!!.link ${currentItem!!.link}")
binding.webView.loadUrl(currentItem!!.link!!)
Logd(TAG, "currentItem!!.link ${episode!!.link}")
binding.webView.loadUrl(episode!!.link!!)
binding.readerView.visibility = View.GONE
binding.webView.visibility = View.VISIBLE
} else Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show()
@ -207,7 +207,7 @@ class EpisodeHomeFragment : Fragment() {
if (!ttsPlaying) {
ttsPlaying = true
if (!readerText.isNullOrEmpty()) {
ttsSpeed = currentItem?.feed?.preferences?.playSpeed ?: 1.0f
ttsSpeed = episode?.feed?.preferences?.playSpeed ?: 1.0f
tts?.setSpeechRate(ttsSpeed)
while (startIndex < readerText!!.length) {
val endIndex = minOf(startIndex + MAX_CHUNK_LENGTH, readerText!!.length)
@ -237,7 +237,7 @@ class EpisodeHomeFragment : Fragment() {
return true
}
else -> {
return currentItem != null
return episode != null
}
}
}
@ -268,7 +268,7 @@ class EpisodeHomeFragment : Fragment() {
}
@UnstableApi private fun updateAppearance() {
if (currentItem == null) {
if (episode == null) {
Logd(TAG, "updateAppearance currentItem is null")
return
}
@ -281,12 +281,12 @@ class EpisodeHomeFragment : Fragment() {
private val TAG: String = EpisodeHomeFragment::class.simpleName ?: "Anonymous"
private const val MAX_CHUNK_LENGTH = 2000
var currentItem: Episode? = null
var episode: Episode? = null // unmanged
fun newInstance(item: Episode): EpisodeHomeFragment {
val fragment = EpisodeHomeFragment()
Logd(TAG, "item.itemIdentifier ${item.identifier}")
if (item.identifier != currentItem?.identifier) currentItem = item
if (item.identifier != episode?.identifier) episode = item
return fragment
}
}

View File

@ -11,7 +11,7 @@ import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
@ -74,7 +74,7 @@ import kotlin.math.max
private var homeFragment: EpisodeHomeFragment? = null
private var itemLoaded = false
private var episode: Episode? = null
private var episode: Episode? = null // unmanaged
private var webviewData: String? = null
private lateinit var shownotesCleaner: ShownotesCleaner
@ -409,7 +409,8 @@ import kotlin.math.max
private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) {
if (episode?.id == event.episode.id) {
episode = unmanagedCopy(event.episode)
episode = unmanaged(event.episode)
// episode = event.episode
prepareMenu()
}
}
@ -420,7 +421,8 @@ import kotlin.math.max
while (i < size) {
val item_ = event.episodes[i]
if (item_.id == episode?.id) {
episode = unmanagedCopy(item_)
episode = unmanaged(item_)
// episode = item_
prepareMenu()
break
}
@ -469,7 +471,7 @@ import kotlin.math.max
}
fun setItem(item_: Episode) {
episode = unmanagedCopy(item_)
episode = unmanaged(item_)
}
companion object {

View File

@ -5,15 +5,18 @@ import ac.mdiq.podcini.databinding.FeedItemListFragmentBinding
import ac.mdiq.podcini.databinding.MoreContentListFooterBinding
import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.podcini.storage.database.LogsAndStats.getFeedDownloadLog
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.DownloadResult
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
@ -79,14 +82,14 @@ import java.util.concurrent.Semaphore
private lateinit var swipeActions: SwipeActions
private lateinit var nextPageLoader: MoreContentListFooterUtil
private var currentPlaying: EpisodeViewHolder? = null
private var displayUpArrow = false
private var headerCreated = false
private var feedID: Long = 0
private var feed: Feed? = null
private var episodes: MutableList<Episode> = mutableListOf()
private var curIndex = -1
private var enableFilter: Boolean = true
private val ioScope = CoroutineScope(Dispatchers.IO)
@ -271,7 +274,7 @@ import java.util.concurrent.Semaphore
Thread {
try {
if (feed != null) {
val feed_ = unmanagedCopy(feed!!)
val feed_ = unmanaged(feed!!)
feed_.nextPageLink = feed_.downloadUrl
feed_.pageNr = 0
upsertBlk(feed_) {}
@ -318,10 +321,7 @@ import java.util.concurrent.Semaphore
@UnstableApi override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
val activity: MainActivity = activity as MainActivity
if (feed != null) {
// val ids: LongArray = FeedItemUtil.getIds(feed!!.items)
activity.loadChildFragment(EpisodeInfoFragment.newInstance(episodes[position]))
}
if (feed != null) activity.loadChildFragment(EpisodeInfoFragment.newInstance(episodes[position]))
}
private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) {
@ -375,8 +375,7 @@ import java.util.concurrent.Semaphore
val item = event.episode
val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
episodes.add(pos, item)
episodes[pos] = item
adapter.notifyItemChangedCompat(pos)
// episodes[pos].playState = item.playState
// adapter.notifyItemChangedCompat(pos)
@ -387,11 +386,8 @@ import java.util.concurrent.Semaphore
val item = event.episode
val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
episodes.add(pos, item)
episodes[pos] = item
adapter.notifyItemChangedCompat(pos)
// episodes[pos].isFavorite = item.isFavorite
// adapter.notifyItemChangedCompat(pos)
}
}
@ -409,18 +405,14 @@ import java.util.concurrent.Semaphore
}
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}")
if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = binding.recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder
if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) {
currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event)
break
}
}
val item = (event.media as? EpisodeMedia)?.episode ?: return
val pos = if (curIndex in 0..<episodes.size && event.media.getIdentifier() == episodes[curIndex].media?.getIdentifier() && isCurMedia(episodes[curIndex].media))
curIndex else EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes[pos] = item
curIndex = pos
adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") })
}
}
@ -459,7 +451,7 @@ import java.util.concurrent.Semaphore
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event)
is FlowEvent.FeedUpdateRunningEvent -> onFeedUpdateRunningEvent(event)
is FlowEvent.FeedUpdatingEvent -> onFeedUpdateRunningEvent(event)
else -> {}
}
}
@ -492,10 +484,10 @@ import java.util.concurrent.Semaphore
swipeActions.attachTo(binding.recyclerView)
}
private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdateRunningEvent) {
nextPageLoader.setLoadingState(event.isFeedUpdateRunning)
if (!event.isFeedUpdateRunning) nextPageLoader.root.visibility = View.GONE
binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning
private fun onFeedUpdateRunningEvent(event: FlowEvent.FeedUpdatingEvent) {
nextPageLoader.setLoadingState(event.isRunning)
if (!event.isRunning) nextPageLoader.root.visibility = View.GONE
binding.swipeRefresh.isRefreshing = event.isRunning
}
private fun refreshSwipeTelltale() {
@ -611,7 +603,7 @@ import java.util.concurrent.Semaphore
lifecycleScope.launch {
try {
feed = withContext(Dispatchers.IO) {
val feed_ = getFeed(feedID)
val feed_ = getFeed(feedID, fromDB = true)
if (feed_ != null) {
episodes.clear()
if (enableFilter && !feed_.preferences?.filterString.isNullOrEmpty()) {
@ -648,8 +640,8 @@ import java.util.concurrent.Semaphore
swipeActions.setFilter(feed?.episodeFilter)
refreshHeaderView()
binding.progressBar.visibility = View.GONE
adapter.setDummyViews(0)
if (feed != null && episodes.isNotEmpty()) {
// adapter.setDummyViews(0)
if (feed != null) {
adapter.updateItems(episodes, feed)
binding.header.counts.text = episodes.size.toString()
}
@ -658,7 +650,7 @@ import java.util.concurrent.Semaphore
} catch (e: Throwable) {
feed = null
refreshHeaderView()
adapter.setDummyViews(0)
// adapter.setDummyViews(0)
adapter.updateItems(emptyList())
updateToolbar()
Log.e(TAG, Log.getStackTraceString(e))
@ -694,10 +686,8 @@ import java.util.concurrent.Semaphore
runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) {
realm.write {
findLatest(feed_)?.let {
it.preferences?.filterString = newFilterValues.joinToString()
}
upsert(feed_) {
it.preferences?.filterString = newFilterValues.joinToString()
}
}
}
@ -725,10 +715,8 @@ import java.util.concurrent.Semaphore
runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed.id}").first().find()
if (feed_ != null) {
realm.write {
findLatest(feed_)?.let {
it.sortOrder = sortOrder
}
upsert(feed_) {
it.sortOrder = sortOrder
}
}
}

View File

@ -8,7 +8,7 @@ import ac.mdiq.podcini.databinding.PlaybackSpeedFeedSettingDialogBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.RealmDB.unmanagedCopy
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction
@ -19,8 +19,6 @@ import ac.mdiq.podcini.ui.dialog.AuthenticationDialog
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog
import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
@ -514,7 +512,7 @@ class FeedSettingsFragment : Fragment() {
}
fun setFeed(feed_: Feed) {
feed = unmanagedCopy(feed_)
feed = unmanaged(feed_)
}
companion object {

View File

@ -5,7 +5,6 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils
import ac.mdiq.podcini.ui.activity.MainActivity
@ -58,7 +57,7 @@ import kotlin.math.min
}
override fun createListAdaptor() {
listAdapter = object : EpisodesAdapter(activity as MainActivity) {
adapter = object : EpisodesAdapter(activity as MainActivity) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
return object: EpisodeViewHolder(mainActivityRef.get()!!, parent) {
override fun setPubDate(item: Episode) {
@ -73,8 +72,8 @@ import kotlin.math.min
MenuItemUtils.setOnClickListeners(menu) { item: MenuItem -> this@HistoryFragment.onContextItemSelected(item) }
}
}
listAdapter.setOnSelectModeListener(this)
recyclerView.adapter = listAdapter
adapter.setOnSelectModeListener(this)
recyclerView.adapter = adapter
}
override fun onStart() {
@ -124,6 +123,18 @@ import kotlin.math.min
toolbar.menu.findItem(R.id.episodes_sort).setVisible(episodes.isNotEmpty())
toolbar.menu.findItem(R.id.filter_items).setVisible(episodes.isNotEmpty())
toolbar.menu.findItem(R.id.clear_history_item).setVisible(episodes.isNotEmpty())
swipeActions.setFilter(getFilter())
if (getFilter().values.isNotEmpty()) {
txtvInformation.visibility = View.VISIBLE
txtvInformation.text = "${adapter.totalNumberOfItems} episodes - filtered"
emptyView.setMessage(R.string.no_all_episodes_filtered_label)
} else {
txtvInformation.visibility = View.VISIBLE
txtvInformation.text = "${adapter.totalNumberOfItems} episodes"
emptyView.setMessage(R.string.no_all_episodes_label)
}
toolbar.menu?.findItem(R.id.action_favorites)?.setIcon(if (getFilter().showIsFavorite) R.drawable.ic_star else R.drawable.ic_star_border)
}
private var eventSink: Job? = null

View File

@ -6,6 +6,7 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.databinding.QueueFragmentBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Queues.clearQueue
@ -15,6 +16,7 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.SortOrder
@ -83,7 +85,7 @@ import java.util.*
private var queueItems: MutableList<Episode> = mutableListOf()
private var adapter: QueueRecyclerAdapter? = null
private var currentPlaying: EpisodeViewHolder? = null
private var curIndex = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -220,7 +222,7 @@ import java.util.*
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event)
is FlowEvent.FeedUpdateRunningEvent -> swipeRefreshLayout.isRefreshing = event.isFeedUpdateRunning
is FlowEvent.FeedUpdatingEvent -> swipeRefreshLayout.isRefreshing = event.isRunning
else -> {}
}
}
@ -327,20 +329,14 @@ import java.util.*
}
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}")
if (adapter != null) {
if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia) currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Logd(TAG, "onPlaybackPositionEvent() ${event.TAG} search list")
for (i in 0 until adapter!!.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder
if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) {
currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event)
break
}
}
}
val item = (event.media as? EpisodeMedia)?.episode ?: return
val pos = if (curIndex in 0..<queueItems.size && event.media.getIdentifier() == queueItems[curIndex].media?.getIdentifier() && isCurMedia(queueItems[curIndex].media))
curIndex else EpisodeUtil.indexOfItemWithId(queueItems, item.id)
if (pos >= 0) {
queueItems[pos] = item
curIndex = pos
adapter?.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") })
}
}
@ -546,7 +542,7 @@ import java.util.*
queueItems.clear()
queueItems.addAll(curQueue.episodes)
binding.progressBar.visibility = View.GONE
adapter?.setDummyViews(0)
// adapter?.setDummyViews(0)
adapter?.updateItems(queueItems)
if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG)
refreshInfoBar()

View File

@ -2,7 +2,6 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.utils.EpisodeFilter
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
@ -33,7 +32,7 @@ import kotlin.math.min
toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label)
updateToolbar()
listAdapter.setOnSelectModeListener(null)
adapter.setOnSelectModeListener(null)
return root
}

View File

@ -6,8 +6,10 @@ import ac.mdiq.podcini.databinding.HorizontalFeedItemBinding
import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.databinding.SearchFragmentBinding
import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
@ -76,7 +78,8 @@ import java.lang.ref.WeakReference
private lateinit var automaticSearchDebouncer: Handler
private var results: MutableList<Episode> = mutableListOf()
private var currentPlaying: EpisodeViewHolder? = null
private var curIndex = -1
private var lastQueryChange: Long = 0
private var isOtherViewInFoucus = false
@ -254,8 +257,8 @@ import java.lang.ref.WeakReference
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent -> search()
is FlowEvent.EpisodeEvent -> onEventMainThread(event)
is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event)
is FlowEvent.EpisodeEvent -> onEpisodeEvent(event)
is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(event)
else -> {}
}
}
@ -264,14 +267,14 @@ import java.lang.ref.WeakReference
EventFlow.stickyEvents.collectLatest { event ->
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.EpisodeDownloadEvent -> onEventMainThread(event)
is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event)
else -> {}
}
}
}
}
fun onEventMainThread(event: FlowEvent.EpisodeEvent) {
private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) {
// Logd(TAG, "onEventMainThread() called with ${event.TAG}")
var i = 0
val size: Int = event.episodes.size
@ -287,29 +290,26 @@ import java.lang.ref.WeakReference
}
}
fun onEventMainThread(event: FlowEvent.EpisodeDownloadEvent) {
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
for (downloadUrl in event.urls) {
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, downloadUrl)
if (pos >= 0) adapter.notifyItemChangedCompat(pos)
}
}
fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) {
if (currentPlaying != null && event.media?.getIdentifier() == currentPlaying!!.episode?.media?.getIdentifier() && currentPlaying!!.isCurMedia)
currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Logd(TAG, "onEventMainThread() ${event.TAG} search list")
for (i in 0 until adapter.itemCount) {
val holder: EpisodeViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeViewHolder
if (holder != null && event.media?.getIdentifier() == holder.episode?.media?.getIdentifier()) {
currentPlaying = holder
holder.notifyPlaybackPositionUpdated(event)
break
}
}
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
val item = (event.media as? EpisodeMedia)?.episode ?: return
val pos = if (curIndex in 0..<results.size && event.media.getIdentifier() == results[curIndex].media?.getIdentifier() && isCurMedia(results[curIndex].media))
curIndex else EpisodeUtil.indexOfItemWithId(results, item.id)
if (pos >= 0) {
results[pos] = item
curIndex = pos
adapter.notifyItemChanged(pos, Bundle().apply { putString("PositionUpdate", "PlaybackPositionEvent") })
}
}
@UnstableApi private fun searchWithProgressBar() {
progressBar.visibility = View.VISIBLE
emptyViewHandler.hide()

View File

@ -56,6 +56,7 @@ import io.realm.kotlin.query.Sort
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.NumberFormat
@ -69,8 +70,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private var _binding: FragmentSubscriptionsBinding? = null
private val binding get() = _binding!!
private lateinit var subscriptionRecycler: RecyclerView
private lateinit var listAdapter: SubscriptionsAdapter<*>
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: SubscriptionsAdapter<*>
private lateinit var emptyView: EmptyViewHandler
private lateinit var toolbar: MaterialToolbar
private lateinit var speedDialView: SpeedDialView
@ -100,8 +101,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
toolbar = binding.toolbar
toolbar.setOnMenuItemClickListener(this)
toolbar.setOnLongClickListener {
subscriptionRecycler.scrollToPosition(5)
subscriptionRecycler.post { subscriptionRecycler.smoothScrollToPosition(0) }
recyclerView.scrollToPosition(5)
recyclerView.post { recyclerView.smoothScrollToPosition(0) }
false
}
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
@ -115,10 +116,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
toolbar.title = displayedFolder
}
subscriptionRecycler = binding.subscriptionsGrid
subscriptionRecycler.addItemDecoration(GridDividerItemDecorator())
registerForContextMenu(subscriptionRecycler)
subscriptionRecycler.addOnScrollListener(LiftOnScrollListener(binding.appbar))
recyclerView = binding.subscriptionsGrid
recyclerView.addItemDecoration(GridDividerItemDecorator())
registerForContextMenu(recyclerView)
recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar))
initAdapter()
setupEmptyView()
@ -144,7 +145,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
val resultList = feedListFiltered.filter {
it.title?.lowercase(Locale.getDefault())?.contains(text)?:false || it.author?.lowercase(Locale.getDefault())?.contains(text)?:false
}
listAdapter.setItems(resultList)
adapter.setItems(resultList)
true
} else false
}
@ -172,7 +173,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
override fun onToggleChanged(isOpen: Boolean) {}
})
speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem ->
FeedMultiSelectActionHandler(activity as MainActivity, listAdapter.selectedItems.filterIsInstance<Feed>()).handleAction(actionItem.id)
FeedMultiSelectActionHandler(activity as MainActivity, adapter.selectedItems.filterIsInstance<Feed>()).handleAction(actionItem.id)
true
}
loadSubscriptions()
@ -184,13 +185,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
useGrid = useGridLayout
var spanCount = 1
if (useGrid!!) {
listAdapter = GridAdapter()
adapter = GridAdapter()
spanCount = 3
} else listAdapter = ListAdapter()
subscriptionRecycler.layoutManager = GridLayoutManager(context, spanCount, RecyclerView.VERTICAL, false)
listAdapter.setOnSelectModeListener(this)
subscriptionRecycler.adapter = listAdapter
listAdapter.setItems(feedListFiltered)
} else adapter = ListAdapter()
recyclerView.layoutManager = GridLayoutManager(context, spanCount, RecyclerView.VERTICAL, false)
adapter.setOnSelectModeListener(this)
recyclerView.adapter = adapter
adapter.setItems(feedListFiltered)
}
}
@ -202,8 +203,9 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
override fun onStop() {
Logd(TAG, "onStop()")
super.onStop()
listAdapter.endSelectMode()
adapter.endSelectMode()
cancelFlowEvents()
}
@ -232,7 +234,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
}
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
listAdapter.setItems(feedListFiltered)
adapter.setItems(feedListFiltered)
}
private fun resetTags() {
@ -255,8 +257,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.FeedListEvent -> loadSubscriptions()
is FlowEvent.EpisodePlayedEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions()
is FlowEvent.FeedListEvent, is FlowEvent.FeedsSortedEvent -> loadSubscriptions()
is FlowEvent.EpisodePlayedEvent -> loadSubscriptions()
is FlowEvent.FeedTagsChangedEvent -> loadSubscriptions()
else -> {}
}
@ -266,7 +268,12 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
EventFlow.stickyEvents.collectLatest { event ->
Logd(TAG, "Received sticky event: ${event.TAG}")
when (event) {
is FlowEvent.FeedUpdateRunningEvent -> binding.swipeRefresh.isRefreshing = event.isFeedUpdateRunning
is FlowEvent.FeedUpdatingEvent -> {
Logd(TAG, "FeedUpdateRunningEvent: ${event.isRunning}")
binding.swipeRefresh.isRefreshing = event.isRunning
if (!event.isRunning && event.id != prevFeedUpdatingEvent?.id) loadSubscriptions()
prevFeedUpdatingEvent = event
}
else -> {}
}
}
@ -289,7 +296,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
emptyView.setIcon(R.drawable.ic_subscriptions)
emptyView.setTitle(R.string.no_subscriptions_head_label)
emptyView.setMessage(R.string.no_subscriptions_label)
emptyView.attachToRecyclerView(subscriptionRecycler)
emptyView.attachToRecyclerView(recyclerView)
}
@OptIn(UnstableApi::class) private fun loadSubscriptions() {
@ -303,10 +310,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
withContext(Dispatchers.Main) {
// We have fewer items. This can result in items being selected that are no longer visible.
if ( feedListFiltered.size > feedList.size) listAdapter.endSelectMode()
if ( feedListFiltered.size > feedList.size) adapter.endSelectMode()
filterOnTag()
binding.progressBar.visibility = View.GONE
listAdapter.setItems(feedListFiltered)
adapter.setItems(feedListFiltered)
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
emptyView.updateVisibility()
}
@ -410,11 +417,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
override fun onContextItemSelected(item: MenuItem): Boolean {
val feed: Feed = listAdapter.selectedItem ?: return false
val feed: Feed = adapter.selectedItem ?: return false
val itemId = item.itemId
if (itemId == R.id.multi_select) {
speedDialView.visibility = View.VISIBLE
return listAdapter.onContextItemSelected(item)
return adapter.onContextItemSelected(item)
}
// TODO: this appears not called
return FeedMenuHandler.onMenuItemClicked(this, item.itemId, feed) { this.loadSubscriptions() }
@ -423,14 +430,14 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
override fun onEndSelectMode() {
speedDialView.close()
speedDialView.visibility = View.GONE
listAdapter.setItems(feedListFiltered)
adapter.setItems(feedListFiltered)
}
override fun onStartSelectMode() {
speedDialView.visibility = View.VISIBLE
val feedsOnly: MutableList<Feed> = ArrayList<Feed>(feedListFiltered)
// feedsOnly.addAll(feedListFiltered)
listAdapter.setItems(feedsOnly)
adapter.setItems(feedsOnly)
}
@UnstableApi
@ -856,6 +863,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private const val KEY_UP_ARROW = "up_arrow"
private const val ARGUMENT_FOLDER = "folder"
private var prevFeedUpdatingEvent: FlowEvent.FeedUpdatingEvent? = null
fun newInstance(folderTitle: String?): SubscriptionsFragment {
val fragment = SubscriptionsFragment()
val args = Bundle()

View File

@ -1,127 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.StatisticsFragmentBinding
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
import ac.mdiq.podcini.storage.model.StatisticsItem
import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.text.format.Formatter
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
/**
* Displays the 'download statistics' screen
*/
class DownloadStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var downloadStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: DownloadStatisticsListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
downloadStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = DownloadStatisticsListAdapter(requireContext(), this)
downloadStatisticsList.layoutManager = LinearLayoutManager(context)
downloadStatisticsList.adapter = listAdapter
refreshDownloadStatistics()
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(false)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun refreshDownloadStatistics() {
progressBar.visibility = View.VISIBLE
downloadStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(false, 0, Long.MAX_VALUE)
data.feedTime.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.totalDownloadSize.compareTo(item1.totalDownloadSize)
}
data
}
listAdapter.update(statisticsData.feedTime)
progressBar.visibility = View.GONE
downloadStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
/**
* Adapter for the download statistics list.
*/
class DownloadStatisticsListAdapter(context: Context, private val fragment: Fragment) : StatisticsListAdapter(context!!) {
override val headerCaption: String
get() = context.getString(R.string.total_size_downloaded_podcasts)
override val headerValue: String
get() = Formatter.formatShortFileSize(context, pieChartData!!.sum.toLong())
override fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData {
val dataValues = FloatArray(statisticsData!!.size)
for (i in statisticsData.indices) {
val item = statisticsData[i]
dataValues[i] = item.totalDownloadSize.toFloat()
}
return PieChartData(dataValues)
}
@SuppressLint("SetTextI18n")
override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) {
holder!!.value.text = (Formatter.formatShortFileSize(context, item!!.totalDownloadSize)
+ ""
+ String.format(Locale.getDefault(), "%d%s",
item.episodesDownloadCount, context.getString(R.string.episodes_suffix)))
holder.itemView.setOnClickListener { v: View? ->
val yourDialogFragment = FeedStatisticsDialogFragment.newInstance(
item.feed.id, item.feed.title)
yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment")
}
}
}
companion object {
private val TAG: String = DownloadStatisticsFragment::class.simpleName ?: "Anonymous"
}
}

View File

@ -1,47 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
class FeedStatisticsDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = MaterialAlertDialogBuilder(requireContext())
dialog.setPositiveButton(android.R.string.ok, null)
dialog.setNeutralButton(R.string.open_podcast) { dialogInterface: DialogInterface?, i: Int ->
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
MainActivityStarter(requireContext()).withOpenFeed(feedId).withAddToBackStack().start()
}
dialog.setTitle(requireArguments().getString(EXTRA_FEED_TITLE))
dialog.setView(R.layout.feed_statistics_dialog)
return dialog.create()
}
override fun onStart() {
super.onStart()
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
childFragmentManager.beginTransaction().replace(R.id.statisticsContainer,
FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment")
.commitAllowingStateLoss()
}
companion object {
private const val EXTRA_FEED_ID = "ac.mdiq.podcini.extra.feedId"
private const val EXTRA_FEED_TITLE = "ac.mdiq.podcini.extra.feedTitle"
fun newInstance(feedId: Long, feedTitle: String?): FeedStatisticsDialogFragment {
val fragment = FeedStatisticsDialogFragment()
val arguments = Bundle()
arguments.putLong(EXTRA_FEED_ID, feedId)
arguments.putString(EXTRA_FEED_TITLE, feedTitle)
fragment.arguments = arguments
return fragment
}
}
}

View File

@ -29,9 +29,7 @@ class FeedStatisticsFragment : Fragment() {
if (!requireArguments().getBoolean(EXTRA_DETAILED)) {
for (i in 0 until binding.root.childCount) {
val child = binding.root.getChildAt(i)
if ("detailed" == child.tag) {
child.visibility = View.GONE
}
if ("detailed" == child.tag) child.visibility = View.GONE
}
}
@ -44,10 +42,10 @@ class FeedStatisticsFragment : Fragment() {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(true, 0, Long.MAX_VALUE)
data.feedTime.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.timePlayed.compareTo(item1.timePlayed)
}
for (statisticsItem in data.feedTime) {
for (statisticsItem in data.statsItems) {
if (statisticsItem.feed.id == feedId) return@withContext statisticsItem
}
null
@ -60,7 +58,7 @@ class FeedStatisticsFragment : Fragment() {
}
private fun showStats(s: StatisticsItem?) {
binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s!!.episodesStarted, s.episodes)
binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s!!.episodesStarted, s.numEpisodes)
binding.timePlayedLabel.text = shortLocalizedDuration(requireContext(), s.timePlayed)
binding.totalDurationLabel.text = shortLocalizedDuration(requireContext(), s.time)
binding.onDeviceLabel.text = String.format(Locale.getDefault(), "%d", s.episodesDownloadCount)

View File

@ -1,43 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import android.view.MenuItem
import androidx.fragment.app.Fragment
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.appbar.MaterialToolbar
/**
* Fragment with a ViewPager where the displayed items influence the top toolbar's menu.
* All items share the same general menu items and are just allowed to show/hide them.
*/
abstract class PagedToolbarFragment : Fragment() {
private var toolbar: MaterialToolbar? = null
private var viewPager: ViewPager2? = null
/**
* Invalidate the toolbar menu if the current child fragment is visible.
* @param child The fragment to invalidate
*/
fun invalidateOptionsMenuIfActive(child: Fragment) {
val visibleChild = childFragmentManager.findFragmentByTag("f" + viewPager!!.currentItem)
if (visibleChild === child) visibleChild.onPrepareOptionsMenu(toolbar!!.menu)
}
protected fun setupPagedToolbar(toolbar: MaterialToolbar, viewPager: ViewPager2) {
this.toolbar = toolbar
this.viewPager = viewPager
toolbar.setOnMenuItemClickListener { item: MenuItem? ->
if (this.onOptionsItemSelected(item!!)) return@setOnMenuItemClickListener true
val child = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem)
if (child != null) return@setOnMenuItemClickListener child.onOptionsItemSelected(item)
false
}
viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val child = childFragmentManager.findFragmentByTag("f$position")
child?.onPrepareOptionsMenu(toolbar.menu)
}
})
}
}

View File

@ -2,42 +2,60 @@ package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.PagerFragmentBinding
import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.update
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.DatesFilterDialog
import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData
import ac.mdiq.podcini.util.Converter.shortLocalizedDuration
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.text.format.DateFormat
import android.text.format.Formatter
import android.util.Log
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.*
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.OptIn
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import coil.load
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
/**
* Displays the 'statistics' screen
*/
class StatisticsFragment : PagedToolbarFragment() {
class StatisticsFragment : Fragment() {
private lateinit var tabLayout: TabLayout
private lateinit var viewPager: ViewPager2
@ -57,10 +75,10 @@ class StatisticsFragment : PagedToolbarFragment() {
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
(activity as MainActivity).setupToolbarToggle(toolbar, false)
viewPager.adapter = StatisticsPagerAdapter(this)
viewPager.adapter = PagerAdapter(this)
// Give the TabLayout the ViewPager
tabLayout = binding.slidingTabs
super.setupPagedToolbar(toolbar, viewPager)
setupPagedToolbar(toolbar, viewPager)
TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int ->
when (position) {
@ -87,11 +105,36 @@ class StatisticsFragment : PagedToolbarFragment() {
return super.onOptionsItemSelected(item)
}
/**
* Invalidate the toolbar menu if the current child fragment is visible.
* @param child The fragment to invalidate
*/
fun invalidateOptionsMenuIfActive(child: Fragment) {
val visibleChild = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem)
if (visibleChild === child) visibleChild.onPrepareOptionsMenu(toolbar.menu)
}
private fun setupPagedToolbar(toolbar: MaterialToolbar, viewPager: ViewPager2) {
this.toolbar = toolbar
this.viewPager = viewPager
toolbar.setOnMenuItemClickListener { item: MenuItem? ->
if (this.onOptionsItemSelected(item!!)) return@setOnMenuItemClickListener true
val child = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem)
if (child != null) return@setOnMenuItemClickListener child.onOptionsItemSelected(item)
false
}
viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val child = childFragmentManager.findFragmentByTag("f$position")
child?.onPrepareOptionsMenu(toolbar.menu)
}
})
}
@UnstableApi private fun confirmResetStatistics() {
val conDialog: ConfirmationDialog = object : ConfirmationDialog(
requireContext(),
R.string.statistics_reset_data,
R.string.statistics_reset_data_msg) {
val conDialog: ConfirmationDialog = object : ConfirmationDialog(requireContext(),
R.string.statistics_reset_data, R.string.statistics_reset_data_msg) {
override fun onConfirmButtonPressed(dialog: DialogInterface) {
dialog.dismiss()
doResetStatistics()
@ -130,7 +173,7 @@ class StatisticsFragment : PagedToolbarFragment() {
}
}
class StatisticsPagerAdapter internal constructor(fragment: Fragment) : FragmentStateAdapter(fragment) {
private class PagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun createFragment(position: Int): Fragment {
return when (position) {
POS_SUBSCRIPTIONS -> SubscriptionStatisticsFragment()
@ -139,12 +182,554 @@ class StatisticsFragment : PagedToolbarFragment() {
else -> DownloadStatisticsFragment()
}
}
override fun getItemCount(): Int {
return TOTAL_COUNT
}
}
/**
* Displays the 'playback statistics' screen
*/
class SubscriptionStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private var statisticsResult: StatisticsResult? = null
private lateinit var feedStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: ListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
feedStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = ListAdapter(this)
feedStatisticsList.layoutManager = LinearLayoutManager(context)
feedStatisticsList.adapter = listAdapter
refreshStatistics()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> refreshStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(true)
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_filter) {
if (statisticsResult != null) {
val dialog = object: DatesFilterDialog(requireContext(), statisticsResult!!.oldestDate) {
override fun initParams() {
prefs = StatisticsFragment.prefs
includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
}
override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed)
.putLong(PREF_FILTER_FROM, timeFilterFrom)
.putLong(PREF_FILTER_TO, timeFilterTo)
.apply()
EventFlow.postEvent(FlowEvent.StatisticsEvent())
}
}
dialog.show()
}
return true
}
return super.onOptionsItemSelected(item)
}
private fun refreshStatistics() {
progressBar.visibility = View.VISIBLE
feedStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
val timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
val timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.timePlayed.compareTo(item1.timePlayed)
}
data
}
statisticsResult = statisticsData
// When "from" is "today", set it to today
listAdapter.setTimeFilter(includeMarkedAsPlayed,
max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(),
min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong())
listAdapter.update(statisticsData.statsItems)
progressBar.visibility = View.GONE
feedStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private class ListAdapter(private val fragment: Fragment) : StatisticsListAdapter(fragment.requireContext()) {
private var timeFilterFrom: Long = 0
private var timeFilterTo = Long.MAX_VALUE
private var includeMarkedAsPlayed = false
override val headerCaption: String
get() {
if (includeMarkedAsPlayed) return context.getString(R.string.statistics_counting_total)
val skeleton = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMM yyyy")
val dateFormat = SimpleDateFormat(skeleton, Locale.getDefault())
val dateFrom = dateFormat.format(Date(timeFilterFrom))
// FilterTo is first day of next month => Subtract one day
val dateTo = dateFormat.format(Date(timeFilterTo - 24L * 3600000L))
return context.getString(R.string.statistics_counting_range, dateFrom, dateTo)
}
override val headerValue: String
get() = shortLocalizedDuration(context, pieChartData!!.sum.toLong())
fun setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) {
this.includeMarkedAsPlayed = includeMarkedAsPlayed
this.timeFilterFrom = timeFilterFrom
this.timeFilterTo = timeFilterTo
}
override fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData {
val dataValues = FloatArray(statisticsData!!.size)
for (i in statisticsData.indices) {
val item = statisticsData[i]
dataValues[i] = item.timePlayed.toFloat()
}
return PieChartData(dataValues)
}
override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) {
val time = item!!.timePlayed
holder!!.value.text = shortLocalizedDuration(context, time)
holder.itemView.setOnClickListener {
val yourDialogFragment = StatisticsDialogFragment.newInstance(item.feed.id, item.feed.title)
yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment")
}
}
}
}
class YearsStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var yearStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: ListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
yearStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = ListAdapter(requireContext())
yearStatisticsList.layoutManager = LinearLayoutManager(context)
yearStatisticsList.adapter = listAdapter
refreshStatistics()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> refreshStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun refreshStatistics() {
progressBar.visibility = View.VISIBLE
yearStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val result: List<MonthlyStatisticsItem> = withContext(Dispatchers.IO) {
getMonthlyTimeStatistics()
}
listAdapter.update(result)
progressBar.visibility = View.GONE
yearStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
// This also runs on the Main thread
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private fun getMonthlyTimeStatistics(): List<MonthlyStatisticsItem> {
Logd(TAG, "getMonthlyTimeStatistics called")
val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
val months: MutableList<MonthlyStatisticsItem> = ArrayList()
val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0").find()
val groupdMedias = medias.groupBy {
val calendar = Calendar.getInstance()
calendar.timeInMillis = it.lastPlayedTime
"${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}"
}
val orderedGroupedItems = groupdMedias.toList().sortedBy {
val (key, _) = it
val year = key.substringBefore("-").toInt()
val month = key.substringAfter("-").toInt()
year * 12 + month
}.toMap()
for (key in orderedGroupedItems.keys) {
val medias_ = orderedGroupedItems[key] ?: continue
val mItem = MonthlyStatisticsItem()
mItem.year = key.substringBefore("-").toInt()
mItem.month = key.substringAfter("-").toInt()
var dur = 0L
for (m in medias_) {
if (m.playedDuration > 0) dur += m.playedDuration
else {
// progress import does not include playedDuration
if (includeMarkedAsPlayed) {
if (m.playbackCompletionTime > 0 || m.episode?.playState == Episode.PLAYED)
dur += m.duration
else if (m.position > 0) dur += m.position
} else dur += m.position
}
}
mItem.timePlayed = dur
months.add(mItem)
}
return months
}
/**
* Adapter for the yearly playback statistics list.
*/
private class ListAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val statisticsData: MutableList<MonthlyStatisticsItem> = ArrayList()
private val yearlyAggregate: MutableList<MonthlyStatisticsItem?> = ArrayList()
override fun getItemCount(): Int {
return yearlyAggregate.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position == 0) TYPE_HEADER else TYPE_FEED
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context)
if (viewType == TYPE_HEADER) return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_barchart, parent, false))
return StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false))
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == TYPE_HEADER) {
val holder = h as HeaderHolder
holder.barChart.setData(statisticsData)
} else {
val holder = h as StatisticsHolder
val statsItem = yearlyAggregate[position - 1]
holder.year.text = String.format(Locale.getDefault(), "%d ", statsItem!!.year)
holder.hours.text = String.format(Locale.getDefault(),
"%.1f ",
statsItem.timePlayed / 3600000.0f) + context.getString(R.string.time_hours)
}
}
@SuppressLint("NotifyDataSetChanged")
fun update(statistics: List<MonthlyStatisticsItem>) {
var lastYear = if (statistics.isNotEmpty()) statistics[0].year else 0
var lastDataPoint = if (statistics.isNotEmpty()) (statistics[0].month - 1) + lastYear * 12 else 0
var yearSum: Long = 0
yearlyAggregate.clear()
statisticsData.clear()
for (statistic in statistics) {
if (statistic.year != lastYear) {
val yearAggregate = MonthlyStatisticsItem()
yearAggregate.year = lastYear
yearAggregate.timePlayed = yearSum
yearlyAggregate.add(yearAggregate)
yearSum = 0
lastYear = statistic.year
}
yearSum += statistic.timePlayed
while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) {
lastDataPoint++
val item = MonthlyStatisticsItem()
item.year = lastDataPoint / 12
item.month = lastDataPoint % 12 + 1
statisticsData.add(item) // Compensate for months without playback
}
statisticsData.add(statistic)
lastDataPoint = (statistic.month - 1) + statistic.year * 12
}
val yearAggregate = MonthlyStatisticsItem()
yearAggregate.year = lastYear
yearAggregate.timePlayed = yearSum
yearlyAggregate.add(yearAggregate)
yearlyAggregate.reverse()
notifyDataSetChanged()
}
private class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemBarchartBinding.bind(itemView)
var barChart: BarChartView = binding.barChart
}
private class StatisticsHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsYearListitemBinding.bind(itemView)
var year: TextView = binding.yearLabel
var hours: TextView = binding.hoursLabel
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_FEED = 1
}
}
}
class DownloadStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var downloadStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: ListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
downloadStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = ListAdapter(requireContext(), this)
downloadStatisticsList.layoutManager = LinearLayoutManager(context)
downloadStatisticsList.adapter = listAdapter
refreshDownloadStatistics()
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(false)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun refreshDownloadStatistics() {
progressBar.visibility = View.VISIBLE
downloadStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(false, 0, Long.MAX_VALUE)
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.totalDownloadSize.compareTo(item1.totalDownloadSize)
}
data
}
listAdapter.update(statisticsData.statsItems)
progressBar.visibility = View.GONE
downloadStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private class ListAdapter(context: Context, private val fragment: Fragment) : StatisticsListAdapter(context) {
override val headerCaption: String
get() = context.getString(R.string.total_size_downloaded_podcasts)
override val headerValue: String
get() = Formatter.formatShortFileSize(context, pieChartData!!.sum.toLong())
override fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData {
val dataValues = FloatArray(statisticsData!!.size)
for (i in statisticsData.indices) {
val item = statisticsData[i]
dataValues[i] = item.totalDownloadSize.toFloat()
}
return PieChartData(dataValues)
}
@SuppressLint("SetTextI18n")
override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) {
holder!!.value.text = ("${Formatter.formatShortFileSize(context, item!!.totalDownloadSize)}"
+ String.format(Locale.getDefault(), "%d%s", item.episodesDownloadCount, context.getString(R.string.episodes_suffix)))
holder.itemView.setOnClickListener {
val yourDialogFragment = StatisticsDialogFragment.newInstance(item.feed.id, item.feed.title)
yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment")
}
}
}
}
class StatisticsDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = MaterialAlertDialogBuilder(requireContext())
dialog.setPositiveButton(android.R.string.ok, null)
dialog.setNeutralButton(R.string.open_podcast) { _: DialogInterface?, _: Int ->
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
MainActivityStarter(requireContext()).withOpenFeed(feedId).withAddToBackStack().start()
}
dialog.setTitle(requireArguments().getString(EXTRA_FEED_TITLE))
dialog.setView(R.layout.feed_statistics_dialog)
return dialog.create()
}
override fun onStart() {
super.onStart()
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
childFragmentManager.beginTransaction().replace(R.id.statisticsContainer,
FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment")
.commitAllowingStateLoss()
}
companion object {
private const val EXTRA_FEED_ID = "ac.mdiq.podcini.extra.feedId"
private const val EXTRA_FEED_TITLE = "ac.mdiq.podcini.extra.feedTitle"
fun newInstance(feedId: Long, feedTitle: String?): StatisticsDialogFragment {
val fragment = StatisticsDialogFragment()
val arguments = Bundle()
arguments.putLong(EXTRA_FEED_ID, feedId)
arguments.putString(EXTRA_FEED_TITLE, feedTitle)
fragment.arguments = arguments
return fragment
}
}
}
/**
* Parent Adapter for the playback and download statistics list.
*/
private abstract class StatisticsListAdapter protected constructor(@JvmField protected val context: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var statisticsData: List<StatisticsItem>? = null
@JvmField
protected var pieChartData: PieChartData? = null
protected abstract val headerCaption: String?
protected abstract val headerValue: String?
override fun getItemCount(): Int {
return statisticsData!!.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position == 0) TYPE_HEADER else TYPE_FEED
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context)
if (viewType == TYPE_HEADER) return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_total, parent, false))
return StatisticsHolder(inflater.inflate(R.layout.statistics_listitem, parent, false))
}
override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == TYPE_HEADER) {
val holder = h as HeaderHolder
holder.pieChart.setData(pieChartData)
holder.totalTime.text = headerValue
holder.totalText.text = headerCaption
} else {
val holder = h as StatisticsHolder
val statsItem = statisticsData!![position - 1]
holder.image.load(statsItem.feed.imageUrl) {
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)
}
holder.title.text = statsItem.feed.title
holder.chip.setTextColor(pieChartData!!.getColorOfItem(position - 1))
onBindFeedViewHolder(holder, statsItem)
}
}
@SuppressLint("NotifyDataSetChanged")
fun update(statistics: List<StatisticsItem>?) {
statisticsData = statistics
pieChartData = generateChartData(statistics)
notifyDataSetChanged()
}
class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemTotalBinding.bind(itemView)
var totalTime: TextView = binding.totalTime
var pieChart: PieChartView = binding.pieChart
var totalText: TextView = binding.totalDescription
}
class StatisticsHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemBinding.bind(itemView)
var image: ImageView = binding.imgvCover
var title: TextView = binding.txtvTitle
@JvmField
var value: TextView = binding.txtvValue
var chip: TextView = binding.chip
}
protected abstract fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData?
protected abstract fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?)
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_FEED = 1
}
}
companion object {
val TAG = StatisticsFragment::class.simpleName ?: "Anonymous"
@ -163,6 +748,5 @@ class StatisticsFragment : PagedToolbarFragment() {
fun getSharedPrefs(context: Context) {
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
}
}

View File

@ -1,98 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.StatisticsListitemBinding
import ac.mdiq.podcini.databinding.StatisticsListitemTotalBinding
import ac.mdiq.podcini.storage.model.StatisticsItem
import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import coil.load
/**
* Parent Adapter for the playback and download statistics list.
*/
abstract class StatisticsListAdapter protected constructor(@JvmField protected val context: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var statisticsData: List<StatisticsItem>? = null
@JvmField
protected var pieChartData: PieChartData? = null
override fun getItemCount(): Int {
return statisticsData!!.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position == 0) TYPE_HEADER else TYPE_FEED
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context)
if (viewType == TYPE_HEADER) {
return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_total, parent, false))
}
return StatisticsHolder(inflater.inflate(R.layout.statistics_listitem, parent, false))
}
override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == TYPE_HEADER) {
val holder = h as HeaderHolder
holder.pieChart.setData(pieChartData)
holder.totalTime.text = headerValue
holder.totalText.text = headerCaption
} else {
val holder = h as StatisticsHolder
val statsItem = statisticsData!![position - 1]
holder.image.load(statsItem.feed.imageUrl) {
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)
}
holder.title.text = statsItem.feed.title
holder.chip.setTextColor(pieChartData!!.getColorOfItem(position - 1))
onBindFeedViewHolder(holder, statsItem)
}
}
@SuppressLint("NotifyDataSetChanged")
fun update(statistics: List<StatisticsItem>?) {
statisticsData = statistics
pieChartData = generateChartData(statistics)
notifyDataSetChanged()
}
internal class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemTotalBinding.bind(itemView)
var totalTime: TextView = binding.totalTime
var pieChart: PieChartView = binding.pieChart
var totalText: TextView = binding.totalDescription
}
class StatisticsHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemBinding.bind(itemView)
var image: ImageView = binding.imgvCover
var title: TextView = binding.txtvTitle
@JvmField
var value: TextView = binding.txtvValue
var chip: TextView = binding.chip
}
protected abstract val headerCaption: String?
protected abstract val headerValue: String?
protected abstract fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData?
protected abstract fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?)
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_FEED = 1
}
}

View File

@ -1,219 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.StatisticsFragmentBinding
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
import ac.mdiq.podcini.storage.model.StatisticsResult
import ac.mdiq.podcini.storage.model.StatisticsItem
import ac.mdiq.podcini.ui.dialog.DatesFilterDialog
import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData
import ac.mdiq.podcini.ui.statistics.StatisticsFragment.Companion.prefs
import ac.mdiq.podcini.ui.statistics.FeedStatisticsDialogFragment.Companion.newInstance
import ac.mdiq.podcini.util.Converter.shortLocalizedDuration
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.os.Bundle
import android.text.format.DateFormat
import android.util.Log
import android.view.*
import android.widget.ProgressBar
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
/**
* Displays the 'playback statistics' screen
*/
class SubscriptionStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private var statisticsResult: StatisticsResult? = null
private lateinit var feedStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: PlaybackStatisticsListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
feedStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = PlaybackStatisticsListAdapter(this)
feedStatisticsList.setLayoutManager(LinearLayoutManager(context))
feedStatisticsList.setAdapter(listAdapter)
refreshStatistics()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> refreshStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(true)
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_filter) {
if (statisticsResult != null) {
val dialog = object: DatesFilterDialog(requireContext(), statisticsResult!!.oldestDate) {
override fun initParams() {
prefs = StatisticsFragment.prefs
includeMarkedAsPlayed = prefs!!.getBoolean(StatisticsFragment.PREF_INCLUDE_MARKED_PLAYED, false)
timeFilterFrom = prefs!!.getLong(StatisticsFragment.PREF_FILTER_FROM, 0)
timeFilterTo = prefs!!.getLong(StatisticsFragment.PREF_FILTER_TO, Long.MAX_VALUE)
}
override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) {
prefs!!.edit()
.putBoolean(StatisticsFragment.PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed)
.putLong(StatisticsFragment.PREF_FILTER_FROM, timeFilterFrom)
.putLong(StatisticsFragment.PREF_FILTER_TO, timeFilterTo)
.apply()
EventFlow.postEvent(FlowEvent.StatisticsEvent())
}
}
dialog.show()
}
return true
}
return super.onOptionsItemSelected(item)
}
private fun refreshStatistics() {
progressBar.visibility = View.VISIBLE
feedStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
val includeMarkedAsPlayed = prefs!!.getBoolean(StatisticsFragment.PREF_INCLUDE_MARKED_PLAYED, false)
val timeFilterFrom = prefs!!.getLong(StatisticsFragment.PREF_FILTER_FROM, 0)
val timeFilterTo = prefs!!.getLong(StatisticsFragment.PREF_FILTER_TO, Long.MAX_VALUE)
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)
data.feedTime.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.timePlayed.compareTo(item1.timePlayed)
}
data
}
statisticsResult = statisticsData
// When "from" is "today", set it to today
listAdapter.setTimeFilter(includeMarkedAsPlayed,
max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(),
min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong())
listAdapter.update(statisticsData.feedTime)
progressBar.visibility = View.GONE
feedStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
// This also runs on the Main thread
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
/**
* Adapter for the playback statistics list.
*/
class PlaybackStatisticsListAdapter(private val fragment: Fragment) : StatisticsListAdapter(
fragment.requireContext()) {
private var timeFilterFrom: Long = 0
private var timeFilterTo = Long.MAX_VALUE
private var includeMarkedAsPlayed = false
fun setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) {
this.includeMarkedAsPlayed = includeMarkedAsPlayed
this.timeFilterFrom = timeFilterFrom
this.timeFilterTo = timeFilterTo
}
override val headerCaption: String
get() {
if (includeMarkedAsPlayed) {
return context.getString(R.string.statistics_counting_total)
}
val skeleton = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMM yyyy")
val dateFormat = SimpleDateFormat(skeleton, Locale.getDefault())
val dateFrom = dateFormat.format(Date(timeFilterFrom))
// FilterTo is first day of next month => Subtract one day
val dateTo = dateFormat.format(Date(timeFilterTo - 24L * 3600000L))
return context.getString(R.string.statistics_counting_range, dateFrom, dateTo)
}
override val headerValue: String
get() = shortLocalizedDuration(context, pieChartData!!.sum.toLong())
override fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData {
val dataValues = FloatArray(statisticsData!!.size)
for (i in statisticsData.indices) {
val item = statisticsData[i]
dataValues[i] = item.timePlayed.toFloat()
}
return PieChartData(dataValues)
}
override fun onBindFeedViewHolder(holder: StatisticsHolder?, statsItem: StatisticsItem?) {
val time = statsItem!!.timePlayed
holder!!.value.text = shortLocalizedDuration(context, time)
holder.itemView.setOnClickListener { v: View? ->
val yourDialogFragment = newInstance(
statsItem.feed.id, statsItem.feed.title)
yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment")
}
}
}
companion object {
private val TAG: String = SubscriptionStatisticsFragment::class.simpleName ?: "Anonymous"
}
}

View File

@ -1,245 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.StatisticsFragmentBinding
import ac.mdiq.podcini.databinding.StatisticsListitemBarchartBinding
import ac.mdiq.podcini.databinding.StatisticsYearListitemBinding
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.MonthlyStatisticsItem
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.event.EventFlow
import ac.mdiq.podcini.util.event.FlowEvent
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
/**
* Displays the yearly statistics screen
*/
class YearsStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var yearStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: YearStatisticsListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
yearStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = YearStatisticsListAdapter(requireContext())
yearStatisticsList.layoutManager = LinearLayoutManager(context)
yearStatisticsList.adapter = listAdapter
refreshStatistics()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> refreshStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun refreshStatistics() {
progressBar.visibility = View.VISIBLE
yearStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val result: List<MonthlyStatisticsItem> = withContext(Dispatchers.IO) {
getMonthlyTimeStatistics()
}
listAdapter.update(result)
progressBar.visibility = View.GONE
yearStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
// This also runs on the Main thread
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private fun getMonthlyTimeStatistics(): List<MonthlyStatisticsItem> {
Logd(TAG, "getMonthlyTimeStatistics called")
val months: MutableList<MonthlyStatisticsItem> = ArrayList()
val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0 AND playedDuration > 0").find()
val groupdMedias = medias.groupBy {
val calendar = Calendar.getInstance()
calendar.timeInMillis = it.lastPlayedTime
"${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}"
}
val orderedGroupedItems = groupdMedias.toList().sortedBy {
val (key, _) = it
val year = key.substringBefore("-").toInt()
val month = key.substringAfter("-").toInt()
year * 12 + month
}.toMap()
for (key in orderedGroupedItems.keys) {
val v = orderedGroupedItems[key] ?: continue
val episode = MonthlyStatisticsItem()
episode.year = key.substringBefore("-").toInt()
episode.month = key.substringAfter("-").toInt()
var dur = 0L
for (m in v) {
dur += m.playedDuration
}
episode.timePlayed = dur
months.add(episode)
}
return months
}
/**
* Adapter for the yearly playback statistics list.
*/
class YearStatisticsListAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val statisticsData: MutableList<MonthlyStatisticsItem> = ArrayList()
private val yearlyAggregate: MutableList<MonthlyStatisticsItem?> = ArrayList()
override fun getItemCount(): Int {
return yearlyAggregate.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position == 0) TYPE_HEADER else TYPE_FEED
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context)
if (viewType == TYPE_HEADER) {
return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_barchart, parent, false))
}
return StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false))
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == TYPE_HEADER) {
val holder = h as HeaderHolder
holder.barChart.setData(statisticsData)
} else {
val holder = h as StatisticsHolder
val statsItem = yearlyAggregate[position - 1]
holder.year.text = String.format(Locale.getDefault(), "%d ", statsItem!!.year)
holder.hours.text = String.format(Locale.getDefault(),
"%.1f ",
statsItem.timePlayed / 3600000.0f) + context.getString(R.string.time_hours)
}
}
@SuppressLint("NotifyDataSetChanged")
fun update(statistics: List<MonthlyStatisticsItem>) {
var lastYear = if (statistics.isNotEmpty()) statistics[0].year else 0
var lastDataPoint = if (statistics.isNotEmpty()) (statistics[0].month - 1) + lastYear * 12 else 0
var yearSum: Long = 0
yearlyAggregate.clear()
statisticsData.clear()
for (statistic in statistics) {
if (statistic.year != lastYear) {
val yearAggregate = MonthlyStatisticsItem()
yearAggregate.year = lastYear
yearAggregate.timePlayed = yearSum
yearlyAggregate.add(yearAggregate)
yearSum = 0
lastYear = statistic.year
}
yearSum += statistic.timePlayed
while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) {
lastDataPoint++
val item = MonthlyStatisticsItem()
item.year = lastDataPoint / 12
item.month = lastDataPoint % 12 + 1
statisticsData.add(item) // Compensate for months without playback
}
statisticsData.add(statistic)
lastDataPoint = (statistic.month - 1) + statistic.year * 12
}
val yearAggregate = MonthlyStatisticsItem()
yearAggregate.year = lastYear
yearAggregate.timePlayed = yearSum
yearlyAggregate.add(yearAggregate)
yearlyAggregate.reverse()
notifyDataSetChanged()
}
internal class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemBarchartBinding.bind(itemView)
var barChart: BarChartView = binding.barChart
}
internal class StatisticsHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsYearListitemBinding.bind(itemView)
var year: TextView = binding.yearLabel
var hours: TextView = binding.hoursLabel
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_FEED = 1
}
}
companion object {
private val TAG: String = YearsStatisticsFragment::class.simpleName ?: "Anonymous"
}
}

View File

@ -255,28 +255,21 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro
}
}
private fun updateDuration(event: FlowEvent.PlaybackPositionEvent) {
val media = this.episode?.media
if (media != null) {
media.setPosition(event.position)
media.setDuration(event.duration)
}
val currentPosition: Int = event.position
val timeDuration: Int = event.duration
fun updatePlaybackPositionNew(item: Episode) {
Logd(TAG, "updatePlaybackPositionNew called")
this.episode = item
val currentPosition = item.media?.position ?: 0
val timeDuration = item.media?.duration ?: 0
progressBar.progress = (100.0 * currentPosition / timeDuration).toInt()
position.text = Converter.getDurationStringLong(currentPosition)
val remainingTime = max((timeDuration - currentPosition).toDouble(), 0.0).toInt()
// Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition));
if (currentPosition == Playable.INVALID_TIME || timeDuration == Playable.INVALID_TIME) {
Log.w(TAG, "Could not react to position observer update because of invalid time")
return
}
if (UserPreferences.shouldShowRemainingTime()) duration.text = (if (remainingTime > 0) "-" else "") + Converter.getDurationStringLong(remainingTime)
else duration.text = Converter.getDurationStringLong(timeDuration)
}
fun notifyPlaybackPositionUpdated(event: FlowEvent.PlaybackPositionEvent) {
progressBar.progress = (100.0 * event.position / event.duration).toInt()
position.text = Converter.getDurationStringLong(event.position)
updateDuration(event)
duration.visibility = View.VISIBLE // Even if the duration was previously unknown, it is now known
}

View File

@ -75,11 +75,7 @@ object Converter {
*/
@JvmStatic
fun getDurationStringLocalized(context: Context, duration: Long): String {
return getDurationStringLocalized(context.resources, duration)
}
@JvmStatic
fun getDurationStringLocalized(resources: Resources, duration: Long): String {
val resources = context.resources
var result = ""
var h = (duration / HOURS_MIL).toInt()
val d = h / 24
@ -110,6 +106,6 @@ object Converter {
@JvmStatic
fun shortLocalizedDuration(context: Context, time: Long): String {
val hours = time.toFloat() / 3600f
return String.format(Locale.getDefault(), "%.1f ", hours) + context.getString(R.string.time_hours)
return String.format(Locale.getDefault(), "%.2f ", hours) + context.getString(R.string.time_hours)
}
}

View File

@ -4,22 +4,16 @@ import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.net.download.DownloadStatus
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.utils.SortOrder
import ac.mdiq.podcini.storage.utils.VolumeAdaptionSetting
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.view.KeyEvent
import androidx.core.util.Consumer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.apache.commons.lang3.builder.ToStringBuilder
import org.apache.commons.lang3.builder.ToStringStyle
import java.util.*
import kotlin.math.abs
@ -27,6 +21,7 @@ import kotlin.math.max
sealed class FlowEvent {
val TAG = this::class.simpleName ?: "FlowEvent"
val id: Long = Date().time
data class PlaybackPositionEvent(val media: Playable?, val position: Int, val duration: Int) : FlowEvent()
@ -173,7 +168,7 @@ sealed class FlowEvent {
data class FeedTagsChangedEvent(val dummy: Unit = Unit) : FlowEvent()
data class FeedUpdateRunningEvent(val isFeedUpdateRunning: Boolean) : FlowEvent()
data class FeedUpdatingEvent(val isRunning: Boolean) : FlowEvent()
data class MessageEvent(val message: String, val action: Consumer<Context>? = null, val actionText: String? = null) : FlowEvent()

View File

@ -1,46 +0,0 @@
package ac.test.podcini.service.download
/**
* Represents every possible component of a feed
*
* @author daniel
*/
// only used in test
abstract class FeedComponent internal constructor() {
open var id: Long = 0
/**
* Update this FeedComponent's attributes with the attributes from another
* FeedComponent. This method should only update attributes which where read from
* the feed.
*/
fun updateFromOther(other: FeedComponent?) {}
/**
* Compare's this FeedComponent's attribute values with another FeedComponent's
* attribute values. This method will only compare attributes which were
* read from the feed.
*
* @return true if attribute values are different, false otherwise
*/
fun compareWithOther(other: FeedComponent?): Boolean {
return false
}
/**
* Should return a non-null, human-readable String so that the item can be
* identified by the user. Can be title, download-url, etc.
*/
abstract fun getHumanReadableIdentifier(): String?
override fun equals(o: Any?): Boolean {
if (this === o) return true
if (o !is FeedComponent) return false
return id == o.id
}
override fun hashCode(): Int {
return (id xor (id ushr 32)).toInt()
}
}

View File

@ -225,7 +225,7 @@
<string name="delete_label">Delete</string>
<string name="delete_failed">Unable to delete file. Rebooting the device could help.</string>
<string name="delete_local_failed">Unable to delete file. Try re-connecting the local folder from the podcast info screen.</string>
<string name="delete_episode_label">Delete episode</string>
<string name="delete_episode_label">Delete episode media</string>
<plurals name="deleted_multi_episode_batch_label">
<item quantity="one">1 downloaded episode deleted.</item>
<item quantity="other">%d downloaded episodes deleted.</item>
@ -355,6 +355,7 @@
<string name="move_to_bottom_label">Move to bottom</string>
<string name="sort">Sort</string>
<string name="keep_sorted">Keep sorted</string>
<string name="publish_date">Publish date</string>
<string name="date">Date</string>
<string name="last_played_date">Played date</string>
<string name="completed_date">Completed date</string>
@ -425,6 +426,8 @@
<string name="pref_auto_local_delete_title">Auto delete from local folders</string>
<string name="pref_auto_local_delete_sum">Include local folders in Auto delete functionality</string>
<string name="pref_auto_local_delete_dialog_body">Note that for local folders this will remove episodes from Podcini and delete their media files from your device storage. They cannot be downloaded again through Podcini. Enable auto delete\?</string>
<string name="pref_mark_played_removes_from_queue_title">Mark played removes from queue</string>
<string name="pref_mark_played_removes_from_queue_sum">Removes the episodes from all queues when they are mark episodes as played </string>
<string name="pref_smart_mark_as_played_sum">Mark episodes as played even if less than a certain amount of seconds of playing time is still left</string>
<string name="pref_smart_mark_as_played_title">Smart mark as played</string>
<string name="pref_skip_keeps_episodes_sum">Keep episodes when they are skipped</string>
@ -619,7 +622,7 @@
<string name="progress_import_label">Episodes progress import</string>
<string name="progress_export_summary">Transfer Podcini episodes history to Podcini on another device</string>
<string name="progress_import_summary">Import Podcini episodes history from another device</string>
<string name="progress_import_warning">Importing episodes progress will replace all of your current playing history. You should export your current progress as a backup. Do you want to replace\?</string>
<string name="progress_import_warning">Importing episodes progress will replace all of your current playing history. You should export your current progress as a backup. Do you want to replace\? If yes, in the next screen, choose the desired .json file. The process can take a couple minutes depending on size. Once completed, a popup of either success or failure will be shown.</string>
<string name="opml_export_label">OPML export</string>
<string name="html_export_label">HTML export</string>
<string name="preferences_export_label">Preferences export</string>
@ -627,7 +630,9 @@
<string name="preferences_import_warning">Importing preferences will replace all of your current preferences. If confirmed, choose a previously exported directory with name containing \"Podcini-Prefs\"</string>
<string name="database_export_label">Database export</string>
<string name="database_import_label">Database import</string>
<string name="database_import_warning">Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace\?</string>
<string name="realm_database_import_label">Realm database import</string>
<string name="database_import_warning">Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace\? If yes, in the next screen, choose a file with extension .realm</string>
<string name="import_file_type_toast">Only accepting file extension: </string>
<string name="please_wait">Please wait&#8230;</string>
<string name="export_error_label">Export error</string>
<string name="export_success_title">Export successful</string>

View File

@ -39,7 +39,7 @@
android:summary="@string/pref_favorite_keeps_episodes_sum"
android:title="@string/pref_favorite_keeps_episodes_title"/>
<SwitchPreferenceCompat
android:defaultValue="false"
android:defaultValue="true"
android:enabled="true"
android:key="prefDeleteRemovesFromQueue"
android:summary="@string/pref_delete_removes_from_queue_sum"

View File

@ -117,5 +117,11 @@
android:key="prefSkipKeepsEpisode"
android:summary="@string/pref_skip_keeps_episodes_sum"
android:title="@string/pref_skip_keeps_episodes_title"/>
<SwitchPreferenceCompat
android:defaultValue="true"
android:enabled="true"
android:key="prefRemoveFromQueueMarkedPlayed"
android:summary="@string/pref_mark_played_removes_from_queue_sum"
android:title="@string/pref_mark_played_removes_from_queue_title"/>
</PreferenceCategory>
</PreferenceScreen>

View File

@ -2,13 +2,10 @@ package ac.mdiq.podcini.storage
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterfaceTestStub
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.enqueueLocation
import ac.mdiq.podcini.preferences.UserPreferences.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.addToHistory
import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaOfEpisode
@ -16,11 +13,14 @@ import ac.mdiq.podcini.storage.database.Episodes.getEpisode
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeMedia
import ac.mdiq.podcini.storage.database.Episodes.persistEpisode
import ac.mdiq.podcini.storage.database.Episodes.persistEpisodeMedia
import ac.mdiq.podcini.storage.database.Feeds.deleteFeed
import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.clearQueue
import ac.mdiq.podcini.storage.database.Queues.moveInQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ApplicationCallbacks
import ac.mdiq.podcini.util.config.ClientConfig
@ -78,9 +78,9 @@ class DbWriterTest {
// PodDBAdapter.tearDownTests()
// DBWriter.tearDownTests()
val testDir = context.getExternalFilesDir(TEST_FOLDER)
Assert.assertNotNull(testDir)
for (f in testDir!!.listFiles()) {
val testDir = context.getExternalFilesDir(TEST_FOLDER) ?: return
val files = testDir.listFiles() ?: return
for (f in files) {
f.delete()
}
}
@ -241,8 +241,7 @@ class DbWriterTest {
}
runBlocking {
val job = deleteFeed(context, feed.id)
withTimeout(TIMEOUT*1000) { job.join() }
deleteFeedSync(context, feed.id)
}
// check if files still exist
@ -284,8 +283,7 @@ class DbWriterTest {
Assert.assertTrue(feed.id != 0L)
runBlocking {
val job = deleteFeed(context, feed.id)
withTimeout(TIMEOUT*1000) { job.join() }
deleteFeedSync(context, feed.id)
}
// adapter = getInstance()
@ -324,8 +322,7 @@ class DbWriterTest {
}
runBlocking {
val job = deleteFeed(context, feed.id)
withTimeout(TIMEOUT*1000) { job.join() }
deleteFeedSync(context, feed.id)
}
// adapter = getInstance()
@ -383,8 +380,7 @@ class DbWriterTest {
//
// adapter.close()
runBlocking {
val job = deleteFeed(context, feed.id)
withTimeout(TIMEOUT*1000) { job.join() }
deleteFeedSync(context, feed.id)
}
// adapter.open()
//
@ -438,8 +434,7 @@ class DbWriterTest {
}
runBlocking {
val job = deleteFeed(context, feed.id)
withTimeout(TIMEOUT*1000) { job.join() }
deleteFeedSync(context, feed.id)
}
// adapter = getInstance()
@ -522,7 +517,7 @@ class DbWriterTest {
}
}
Assert.assertNotNull(media)
Assert.assertNotNull(media!!.playbackCompletionDate)
Assert.assertNotNull(media.playbackCompletionDate)
}
@Test
@ -539,7 +534,7 @@ class DbWriterTest {
}
Assert.assertNotNull(media)
Assert.assertNotNull(media!!.playbackCompletionDate)
Assert.assertNotNull(media.playbackCompletionDate)
Assert.assertNotEquals(media.playbackCompletionDate!!.time, oldDate)
}
@ -742,7 +737,7 @@ class DbWriterTest {
assertQueueByItemIds("Average case - 2 items removed successfully", itemIds[0], itemIds[2])
runBlocking {
val job = removeFromQueue(null,)
val job = removeFromQueue(null)
withTimeout(TIMEOUT*1000) { job.join() }
}
assertQueueByItemIds("Boundary case - no items supplied. queue should see no change", itemIds[0], itemIds[2])

View File

@ -1,3 +1,19 @@
## 6.0.5
* fixed threading issue of downloading multiple episodes
* tidied up and fixed the mal-functioning statistics view
* tidied up routine of delete media
* fixed issue of episode not properly marked after complete listening
* fixed redundant double-pass processing in episodes filter
* in episodes sort dialog, "Date" is changed to "Publish date"
* in preference "Delete Removes From Queue" is set to true by default
* added in preference "Remove from queue when marked as played" and set it to true by default
* added episode counts in Episodes and History views
* enhanced a bit on progress import
* restricted file types for DB import to only a .realm file and Progress import to a .json file
* enhanced play position updates in all episodes list views
* remove feeds is performed in blocking way
## 6.0.4
* bug fix on ShareDialog having no argument

View File

@ -0,0 +1,16 @@
Version 6.0.5 brings several changes:
* fixed threading issue of downloading multiple episodes
* tidied up and fixed the mal-functioning statistics view
* tidied up routine of delete media
* fixed issue of episode not properly marked after complete listening
* fixed redundant double-pass processing in episodes filter
* in episodes sort dialog, "Date" is changed to "Publish date"
* in preference "Delete Removes From Queue" is set to true by default
* added in preference "Remove from queue when marked as played" and set it to true by default
* added episode counts in Episodes and History views
* enhanced a bit on progress import
* restricted file types for DB import to only a .realm file and Progress import to a .json file
* enhanced play position updates in all episodes list views
* remove feeds is performed in blocking way