6.0.5 commit
This commit is contained in:
parent
a00376bb45
commit
27f5f5e95a
|
@ -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 = ""
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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('—', '-')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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]"
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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) {}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) {}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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…</string>
|
||||
<string name="export_error_label">Export error</string>
|
||||
<string name="export_success_title">Export successful</string>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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])
|
||||
|
|
16
changelog.md
16
changelog.md
|
@ -1,3 +1,19 @@
|
|||
## 6.0.5
|
||||
|
||||
* fixed threading issue of downloading multiple episodes
|
||||
* tidied up and fixed the mal-functioning statistics view
|
||||
* tidied up routine of delete media
|
||||
* fixed issue of episode not properly marked after complete listening
|
||||
* fixed redundant double-pass processing in episodes filter
|
||||
* in episodes sort dialog, "Date" is changed to "Publish date"
|
||||
* in preference "Delete Removes From Queue" is set to true by default
|
||||
* added in preference "Remove from queue when marked as played" and set it to true by default
|
||||
* added episode counts in Episodes and History views
|
||||
* enhanced a bit on progress import
|
||||
* restricted file types for DB import to only a .realm file and Progress import to a .json file
|
||||
* enhanced play position updates in all episodes list views
|
||||
* remove feeds is performed in blocking way
|
||||
|
||||
## 6.0.4
|
||||
|
||||
* bug fix on ShareDialog having no argument
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue