6.13.9 commit
This commit is contained in:
parent
08822bd8ac
commit
435b6d4a6e
|
@ -26,8 +26,8 @@ android {
|
|||
vectorDrawables.useSupportLibrary false
|
||||
vectorDrawables.generatedDensities = []
|
||||
|
||||
versionCode 3020295
|
||||
versionName "6.13.8"
|
||||
versionCode 3020296
|
||||
versionName "6.13.9"
|
||||
|
||||
applicationId "ac.mdiq.podcini.R"
|
||||
def commit = ""
|
||||
|
|
|
@ -179,11 +179,12 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
val pos = curMedia?.getPosition() ?: -1
|
||||
seekTo(pos)
|
||||
callback.onPlaybackPause(curMedia, pos)
|
||||
// callback.onPostPlayback(curMedia, false, true, true)
|
||||
}
|
||||
// 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 = true, true)
|
||||
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,6 @@ import ac.mdiq.podcini.storage.database.Episodes.prefRemoveFromQueueMarkedPlayed
|
|||
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
|
||||
import ac.mdiq.podcini.storage.database.Feeds.allowForAutoDelete
|
||||
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
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
|
@ -56,7 +55,6 @@ import ac.mdiq.podcini.storage.utils.EpisodeUtil
|
|||
import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded
|
||||
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
|
||||
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
|
||||
import ac.mdiq.podcini.ui.compose.queueChanged
|
||||
import ac.mdiq.podcini.ui.utils.NotificationUtils
|
||||
import ac.mdiq.podcini.ui.widget.WidgetUpdater
|
||||
import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState
|
||||
|
@ -110,10 +108,6 @@ import kotlin.concurrent.Volatile
|
|||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Controls the MediaPlayer that plays a EpisodeMedia-file
|
||||
*/
|
||||
|
||||
class PlaybackService : MediaLibraryService() {
|
||||
private var mediaSession: MediaLibrarySession? = null
|
||||
|
||||
|
@ -232,7 +226,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
// Log.d(TAG, "positionSaverTick currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed")
|
||||
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, curPosition, curDuration))
|
||||
skipEndingIfNecessary()
|
||||
saveCurrentPosition(true, null, Playable.INVALID_TIME)
|
||||
persistCurrentPosition(true, null, Playable.INVALID_TIME)
|
||||
prevPosition = curPosition
|
||||
}
|
||||
}
|
||||
|
@ -293,7 +287,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
PlayerStatus.STOPPED -> {}
|
||||
PlayerStatus.PLAYING -> {
|
||||
writePlayerStatus(MediaPlayerBase.status)
|
||||
saveCurrentPosition(true, null, Playable.INVALID_TIME)
|
||||
persistCurrentPosition(true, null, Playable.INVALID_TIME)
|
||||
recreateMediaSessionIfNeeded()
|
||||
// set sleep timer if auto-enabled
|
||||
var autoEnableByTime = true
|
||||
|
@ -397,7 +391,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
override fun onPlaybackPause(playable: Playable?, position: Int) {
|
||||
Logd(TAG, "onPlaybackPause $position")
|
||||
taskManager.cancelPositionSaver()
|
||||
saveCurrentPosition(position == Playable.INVALID_TIME || playable == null, playable, position)
|
||||
persistCurrentPosition(position == Playable.INVALID_TIME || playable == null, playable, position)
|
||||
taskManager.cancelWidgetUpdater()
|
||||
if (playable != null) {
|
||||
if (playable is EpisodeMedia) SynchronizationQueueSink.enqueueEpisodePlayedIfSyncActive(applicationContext, playable, false)
|
||||
|
@ -1044,7 +1038,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
if (e.id == curEpisode?.id) {
|
||||
Logd(TAG, "onQueueEvent: queue event removed ${e.title}")
|
||||
mPlayer?.endPlayback(hasEnded = false, wasSkipped = true, shouldContinue = true, toStoppedState = true)
|
||||
queueChanged++
|
||||
// queueChanged++
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -1132,7 +1126,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
private fun saveCurrentPosition(fromMediaPlayer: Boolean, playable: Playable?, position: Int) {
|
||||
private fun persistCurrentPosition(fromMediaPlayer: Boolean, playable: Playable?, position: Int) {
|
||||
var playable = playable
|
||||
var position = position
|
||||
val duration_: Int
|
||||
|
@ -1154,6 +1148,9 @@ class PlaybackService : MediaLibraryService() {
|
|||
item = upsertBlk(item) {
|
||||
val media = it.media
|
||||
if (media != null) {
|
||||
media.startPosition = playable.startPosition
|
||||
media.startTime = playable.startTime
|
||||
media.playedDurationWhenStarted = playable.playedDurationWhenStarted
|
||||
media.setPosition(position)
|
||||
media.setLastPlayedTime(System.currentTimeMillis())
|
||||
if (it.isNew) it.playState = PlayState.UNPLAYED.code
|
||||
|
@ -1161,6 +1158,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
media.playedDuration = (media.playedDurationWhenStarted + media.getPosition() - media.startPosition)
|
||||
media.timeSpent = (System.currentTimeMillis() - media.startTime).toInt()
|
||||
}
|
||||
// Logd(TAG, "saveCurrentPosition ${media.startTime} ${media.timeSpent}")
|
||||
}
|
||||
}
|
||||
// This appears not too useful
|
||||
|
|
|
@ -1,36 +1,29 @@
|
|||
package ac.mdiq.podcini.preferences.fragments
|
||||
|
||||
import ac.mdiq.podcini.BuildConfig
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.SimpleIconListItemBinding
|
||||
import ac.mdiq.podcini.ui.activity.PreferenceActivity
|
||||
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import ac.mdiq.podcini.BuildConfig
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.PagerFragmentBinding
|
||||
import ac.mdiq.podcini.databinding.SimpleIconListItemBinding
|
||||
import ac.mdiq.podcini.ui.activity.PreferenceActivity
|
||||
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
|
||||
import android.R.color
|
||||
import android.content.DialogInterface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ListView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.ListFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import coil.load
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -53,12 +46,9 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
if (Build.VERSION.SDK_INT <= 32) Snackbar.make(requireView(), R.string.copied_to_clipboard, Snackbar.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("about_contributors")!!.onPreferenceClickListener =
|
||||
findPreference<Preference>("about_help")!!.onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.settingsContainer, ContributorsPagerFragment())
|
||||
.addToBackStack(getString(R.string.contributors))
|
||||
.commit()
|
||||
openInBrowser(requireContext(), "https://github.com/XilinJia/Podcini/")
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("about_privacy_policy")!!.onPreferenceClickListener =
|
||||
|
@ -68,10 +58,7 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
findPreference<Preference>("about_licenses")!!.onPreferenceClickListener =
|
||||
Preference.OnPreferenceClickListener {
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.settingsContainer, LicensesFragment())
|
||||
.addToBackStack(getString(R.string.translators))
|
||||
.commit()
|
||||
parentFragmentManager.beginTransaction().replace(R.id.settingsContainer, LicensesFragment()).addToBackStack(getString(R.string.translators)).commit()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -99,16 +86,12 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent),
|
||||
"", lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
listAdapter = ContributorsPagerFragment.SimpleIconListAdapter(requireContext(), licenses)
|
||||
}
|
||||
}.invokeOnCompletion { throwable ->
|
||||
if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
withContext(Dispatchers.Main) { listAdapter = SimpleIconListAdapter(requireContext(), licenses) }
|
||||
}.invokeOnCompletion { throwable -> if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
|
||||
private class LicenseItem(title: String, subtitle: String, imageUrl: String, val licenseUrl: String, val licenseTextFile: String)
|
||||
: ContributorsPagerFragment.SimpleIconListAdapter.ListItem(title, subtitle, imageUrl)
|
||||
: SimpleIconListAdapter.ListItem(title, subtitle, imageUrl)
|
||||
|
||||
override fun onListItemClick(l: ListView, v: View, position: Int, id: Long) {
|
||||
super.onListItemClick(l, v, position, id)
|
||||
|
@ -130,16 +113,9 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
val reader = BufferedReader(InputStreamReader(requireContext().assets.open(licenseTextFile), "UTF-8"))
|
||||
val licenseText = StringBuilder()
|
||||
var line = ""
|
||||
while ((reader.readLine()?.also { line = it }) != null) {
|
||||
licenseText.append(line).append("\n")
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(licenseText)
|
||||
.show()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
while ((reader.readLine()?.also { line = it }) != null) licenseText.append(line).append("\n")
|
||||
MaterialAlertDialogBuilder(requireContext()).setMessage(licenseText).show()
|
||||
} catch (e: IOException) { e.printStackTrace() }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
@ -148,144 +124,21 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the 'about->Contributors' pager screen.
|
||||
*/
|
||||
class ContributorsPagerFragment : Fragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
val binding = PagerFragmentBinding.inflate(inflater)
|
||||
val viewPager = binding.viewpager
|
||||
viewPager.adapter = StatisticsPagerAdapter(this)
|
||||
// Give the TabLayout the ViewPager
|
||||
val tabLayout = binding.slidingTabs
|
||||
TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int ->
|
||||
when (position) {
|
||||
POS_DEVELOPERS -> tab.setText(R.string.developers)
|
||||
POS_TRANSLATORS -> tab.setText(R.string.translators)
|
||||
POS_SPECIAL_THANKS -> tab.setText(R.string.special_thanks)
|
||||
else -> {}
|
||||
}
|
||||
}.attach()
|
||||
class SimpleIconListAdapter<T : SimpleIconListAdapter.ListItem>(private val context: Context, private val listItems: List<T>)
|
||||
: ArrayAdapter<T>(context, R.layout.simple_icon_list_item, listItems) {
|
||||
|
||||
binding.toolbar.visibility = View.GONE
|
||||
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
|
||||
var view = view
|
||||
if (view == null) view = View.inflate(context, R.layout.simple_icon_list_item, null)
|
||||
|
||||
return binding.root
|
||||
val item: ListItem = listItems[position]
|
||||
val binding = SimpleIconListItemBinding.bind(view!!)
|
||||
binding.title.text = item.title
|
||||
binding.subtitle.text = item.subtitle
|
||||
binding.icon.load(item.imageUrl)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.contributors)
|
||||
}
|
||||
|
||||
class StatisticsPagerAdapter internal constructor(fragment: Fragment) : FragmentStateAdapter(fragment) {
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
POS_TRANSLATORS -> TranslatorsFragment()
|
||||
POS_SPECIAL_THANKS -> SpecialThanksFragment()
|
||||
POS_DEVELOPERS -> DevelopersFragment()
|
||||
else -> DevelopersFragment()
|
||||
}
|
||||
}
|
||||
override fun getItemCount(): Int {
|
||||
return TOTAL_COUNT
|
||||
}
|
||||
}
|
||||
|
||||
class TranslatorsFragment : ListFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
listView.divider = null
|
||||
listView.setSelector(color.transparent)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val translators = ArrayList<SimpleIconListAdapter.ListItem>()
|
||||
val reader = BufferedReader(InputStreamReader(requireContext().assets.open("translators.csv"), "UTF-8"))
|
||||
var line = ""
|
||||
while ((reader.readLine()?.also { line = it }) != null) {
|
||||
val info = line.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
translators.add(SimpleIconListAdapter.ListItem(info[0], info[1], ""))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
listAdapter = SimpleIconListAdapter(requireContext(), translators) }
|
||||
}.invokeOnCompletion { throwable ->
|
||||
if (throwable != null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SpecialThanksFragment : ListFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
listView.divider = null
|
||||
listView.setSelector(color.transparent)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val translators = ArrayList<SimpleIconListAdapter.ListItem>()
|
||||
val reader = BufferedReader(InputStreamReader(requireContext().assets.open("special_thanks.csv"), "UTF-8"))
|
||||
var line = ""
|
||||
while ((reader.readLine()?.also { line = it }) != null) {
|
||||
val info = line.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
translators.add(SimpleIconListAdapter.ListItem(info[0], info[1], info[2]))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
listAdapter = SimpleIconListAdapter(requireContext(), translators)
|
||||
}
|
||||
}.invokeOnCompletion { throwable ->
|
||||
if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DevelopersFragment : ListFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
listView.divider = null
|
||||
listView.setSelector(color.transparent)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val developers = ArrayList<SimpleIconListAdapter.ListItem>()
|
||||
val reader = BufferedReader(InputStreamReader(requireContext().assets.open("developers.csv"), "UTF-8"))
|
||||
var line = ""
|
||||
while ((reader.readLine()?.also { line = it }) != null) {
|
||||
val info = line.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
developers.add(SimpleIconListAdapter.ListItem(info[0], info[2], "https://avatars2.githubusercontent.com/u/" + info[1] + "?s=60&v=4"))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
listAdapter = SimpleIconListAdapter(requireContext(), developers) }
|
||||
}.invokeOnCompletion { throwable ->
|
||||
if (throwable != null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a list of items that have a subtitle and an icon.
|
||||
*/
|
||||
class SimpleIconListAdapter<T : SimpleIconListAdapter.ListItem>(private val context: Context, private val listItems: List<T>)
|
||||
: ArrayAdapter<T>(context, R.layout.simple_icon_list_item, listItems) {
|
||||
|
||||
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
|
||||
var view = view
|
||||
if (view == null) view = View.inflate(context, R.layout.simple_icon_list_item, null)
|
||||
|
||||
val item: ListItem = listItems[position]
|
||||
val binding = SimpleIconListItemBinding.bind(view!!)
|
||||
binding.title.text = item.title
|
||||
binding.subtitle.text = item.subtitle
|
||||
binding.icon.load(item.imageUrl)
|
||||
return view
|
||||
}
|
||||
|
||||
open class ListItem(val title: String, val subtitle: String, val imageUrl: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val POS_DEVELOPERS = 0
|
||||
private const val POS_TRANSLATORS = 1
|
||||
private const val POS_SPECIAL_THANKS = 2
|
||||
private const val TOTAL_COUNT = 3
|
||||
}
|
||||
open class ListItem(val title: String, val subtitle: String, val imageUrl: String)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,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.storage.model.DownloadResult
|
||||
import ac.mdiq.podcini.storage.utils.DownloadResultComparator
|
||||
import ac.mdiq.podcini.util.EventFlow
|
||||
import ac.mdiq.podcini.util.FlowEvent
|
||||
import ac.mdiq.podcini.storage.utils.DownloadResultComparator
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
object LogsAndStats {
|
||||
|
@ -31,57 +30,4 @@ object LogsAndStats {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches the DB for statistics.
|
||||
* @return The list of statistics objects
|
||||
*/
|
||||
fun getStatistics(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long, feedId: Long = 0L): StatisticsResult {
|
||||
Logd(TAG, "getStatistics called")
|
||||
val medias = if (feedId == 0L) realm.query(EpisodeMedia::class).find() else realm.query(EpisodeMedia::class).query("episode.feedId == $feedId").find()
|
||||
|
||||
val groupdMedias = medias.groupBy { it.episodeOrFetch()?.feedId ?: 0L }
|
||||
val result = StatisticsResult()
|
||||
result.oldestDate = Long.MAX_VALUE
|
||||
for ((fid, feedMedias) in groupdMedias) {
|
||||
val feed = getFeed(fid, false) ?: continue
|
||||
val numEpisodes = feed.episodes.size.toLong()
|
||||
var feedPlayedTime = 0L
|
||||
var timeSpent = 0L
|
||||
var durationWithSkip = 0L
|
||||
var feedTotalTime = 0L
|
||||
var episodesStarted = 0L
|
||||
var totalDownloadSize = 0L
|
||||
var episodesDownloadCount = 0L
|
||||
for (m in feedMedias) {
|
||||
if (m.lastPlayedTime > 0 && m.lastPlayedTime < result.oldestDate) result.oldestDate = m.lastPlayedTime
|
||||
feedTotalTime += m.duration
|
||||
if (m.lastPlayedTime in (timeFilterFrom + 1)..<timeFilterTo) {
|
||||
if (includeMarkedAsPlayed) {
|
||||
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || (m.episodeOrFetch()?.playState?:-10) > PlayState.SKIPPED.code || m.position > 0) {
|
||||
episodesStarted += 1
|
||||
feedPlayedTime += m.duration
|
||||
timeSpent += m.timeSpent
|
||||
}
|
||||
} else {
|
||||
feedPlayedTime += m.playedDuration
|
||||
timeSpent += m.timeSpent
|
||||
Logd(TAG, "m.playedDuration: ${m.playedDuration} m.timeSpent: ${m.timeSpent}")
|
||||
if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1
|
||||
}
|
||||
durationWithSkip += m.duration
|
||||
}
|
||||
if (m.downloaded) {
|
||||
episodesDownloadCount += 1
|
||||
totalDownloadSize += m.size
|
||||
}
|
||||
}
|
||||
feedPlayedTime /= 1000
|
||||
durationWithSkip /= 1000
|
||||
timeSpent /= 1000
|
||||
feedTotalTime /= 1000
|
||||
result.statsItems.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, timeSpent, durationWithSkip, numEpisodes, episodesStarted, totalDownloadSize, episodesDownloadCount))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -48,7 +48,6 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
var startPosition: Int = -1
|
||||
|
||||
var playedDurationWhenStarted: Int = 0
|
||||
private set
|
||||
|
||||
var playedDuration: Int = 0 // How many ms of this file have been played
|
||||
|
||||
|
@ -288,6 +287,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
}
|
||||
|
||||
override fun onPlaybackStart() {
|
||||
Logd(TAG, "onPlaybackStart ${System.currentTimeMillis()}")
|
||||
startPosition = max(position.toDouble(), 0.0).toInt()
|
||||
playedDurationWhenStarted = playedDuration
|
||||
startTime = System.currentTimeMillis()
|
||||
|
@ -404,7 +404,6 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
result = 31 * result + startPosition
|
||||
result = 31 * result + playedDurationWhenStarted
|
||||
result = 31 * result + (hasEmbeddedPicture?.hashCode() ?: 0)
|
||||
// result = 31 * result + isInProgress.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
@ -292,7 +292,7 @@ class Feed : RealmObject {
|
|||
var qString = "feedId == $id AND playState < ${PlayState.SKIPPED.code}"
|
||||
// TODO: perhaps need to set prefStreamOverDownload for youtube feeds
|
||||
if (type != FeedType.YOUTUBE.name && preferences?.prefStreamOverDownload != true) qString += " AND media.downloaded == true"
|
||||
val eList_ = realm.query(Episode::class, qString).find().toMutableList()
|
||||
val eList_ = realm.query(Episode::class, qString).query(episodeFilter.queryString()).find().toMutableList()
|
||||
if (sortOrder != null) getPermutor(sortOrder!!).reorder(eList_)
|
||||
return eList_
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ class PlayQueue : RealmObject {
|
|||
get() {
|
||||
if (field.isEmpty() && episodeIds.isNotEmpty())
|
||||
field.addAll(realm.query(Episode::class, "id IN $0", episodeIds).find().sortedBy { episodeIds.indexOf(it.id) })
|
||||
// size = episodeIds.size
|
||||
return field
|
||||
}
|
||||
|
||||
|
@ -38,6 +39,9 @@ class PlayQueue : RealmObject {
|
|||
updated = Date().time
|
||||
}
|
||||
|
||||
// @Ignore
|
||||
// var size by mutableIntStateOf( episodeIds.size )
|
||||
|
||||
fun size() : Int {
|
||||
return episodeIds.size
|
||||
}
|
||||
|
|
|
@ -9,14 +9,6 @@ import java.util.*
|
|||
* Interface for objects that can be played by the PlaybackService.
|
||||
*/
|
||||
interface Playable : Parcelable, Serializable {
|
||||
/**
|
||||
* Save information about the playable in a preference so that it can be
|
||||
* restored later via CurrentState.createInstanceFromPreferences.
|
||||
* Implementations must NOT call commit() after they have written the values
|
||||
* to the preferences file.
|
||||
*/
|
||||
// fun writeToPreferences(prefEditor: SharedPreferences.Editor)
|
||||
|
||||
/**
|
||||
* Returns the title of the episode that this playable represents
|
||||
*/
|
||||
|
@ -97,8 +89,6 @@ interface Playable : Parcelable, Serializable {
|
|||
|
||||
/**
|
||||
* This method should be called every time playback starts on this object.
|
||||
*
|
||||
*
|
||||
* Position held by this Playable should be set accurately before a call to this method is made.
|
||||
*/
|
||||
fun onPlaybackStart() {}
|
||||
|
@ -108,8 +98,6 @@ interface Playable : Parcelable, Serializable {
|
|||
* including just before a seeking operation is performed, after which a call to
|
||||
* [.onPlaybackStart] should be made. If playback completes, calling this method is not
|
||||
* necessary, as long as a call to [.onPlaybackCompleted] is made.
|
||||
*
|
||||
*
|
||||
* Position held by this Playable should be set accurately before a call to this method is made.
|
||||
*/
|
||||
fun onPlaybackPause(context: Context) {}
|
||||
|
|
|
@ -93,10 +93,6 @@ class RemoteMedia : Playable {
|
|||
return Date(pubDate)
|
||||
}
|
||||
|
||||
// override fun writeToPreferences(prefEditor: SharedPreferences.Editor) {
|
||||
// //it seems pointless to do it, since the session should be kept by the remote device.
|
||||
// }
|
||||
|
||||
override fun getChapters(): List<Chapter> {
|
||||
return chapters ?: listOf()
|
||||
}
|
||||
|
@ -139,14 +135,6 @@ class RemoteMedia : Playable {
|
|||
return streamUrl
|
||||
}
|
||||
|
||||
// override fun getLocalMediaUrl(): String? {
|
||||
// return null
|
||||
// }
|
||||
|
||||
// override fun localFileAvailable(): Boolean {
|
||||
// return false
|
||||
// }
|
||||
|
||||
override fun setPosition(newPosition: Int) {
|
||||
position = newPosition
|
||||
}
|
||||
|
@ -159,17 +147,6 @@ class RemoteMedia : Playable {
|
|||
this.lastPlayedTime = lastPlayedTime
|
||||
}
|
||||
|
||||
// override fun onPlaybackStart() {
|
||||
// // no-op
|
||||
// }
|
||||
|
||||
// override fun onPlaybackPause(context: Context) {
|
||||
// // no-op
|
||||
// }
|
||||
|
||||
// override fun onPlaybackCompleted(context: Context) {
|
||||
// // no-op
|
||||
// }
|
||||
override fun getPlayableType(): Int {
|
||||
return PLAYABLE_TYPE_REMOTE_MEDIA
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package ac.mdiq.podcini.ui.compose
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
@ -65,7 +64,7 @@ fun Spinner(items: List<String>, selectedItem: String, modifier: Modifier = Modi
|
|||
var expanded by remember { mutableStateOf(false) }
|
||||
var currentSelectedItem by remember { mutableStateOf(selectedItem) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
BasicTextField(readOnly = true, value = currentSelectedItem, onValueChange = {},
|
||||
BasicTextField(readOnly = true, value = currentSelectedItem, onValueChange = { currentSelectedItem = it},
|
||||
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.bodyLarge.fontSize, fontWeight = FontWeight.Bold),
|
||||
modifier = modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true), // Material3 requirement
|
||||
decorationBox = { innerTextField ->
|
||||
|
@ -88,6 +87,35 @@ fun Spinner(items: List<String>, selectedItem: String, modifier: Modifier = Modi
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Spinner(items: List<String>, selectedIndex: Int, modifier: Modifier = Modifier, onItemSelected: (Int) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var currentSelectedIndex by remember { mutableStateOf(selectedIndex) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
BasicTextField(readOnly = true, value = items.getOrNull(currentSelectedIndex) ?: "Select Item", onValueChange = { },
|
||||
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.bodyLarge.fontSize, fontWeight = FontWeight.Bold),
|
||||
modifier = modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true), // Material3 requirement
|
||||
decorationBox = { innerTextField ->
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
innerTextField()
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
})
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
for (i in items.indices) {
|
||||
DropdownMenuItem(text = { Text(items[i]) },
|
||||
onClick = {
|
||||
currentSelectedIndex = i
|
||||
onItemSelected(i)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomToast(message: String, durationMillis: Long = 2000L, onDismiss: () -> Unit) {
|
||||
// Launch a coroutine to auto-dismiss the toast after a certain time
|
||||
|
|
|
@ -133,7 +133,7 @@ fun InforBar(text: MutableState<String>, leftAction: MutableState<SwipeAction>,
|
|||
}
|
||||
}
|
||||
|
||||
var queueChanged by mutableIntStateOf(0)
|
||||
//var queueChanged by mutableIntStateOf(0)
|
||||
|
||||
@Stable
|
||||
class EpisodeVM(var episode: Episode) {
|
||||
|
|
|
@ -558,7 +558,7 @@ class AudioPlayerFragment : Fragment() {
|
|||
}
|
||||
|
||||
private fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) {
|
||||
Logd(TAG, "onPositionUpdate")
|
||||
// Logd(TAG, "onPositionUpdate")
|
||||
if (!playButInit && playButRes == R.drawable.ic_play_48dp && curMedia is EpisodeMedia) {
|
||||
if (isCurrentlyPlaying(curMedia as? EpisodeMedia)) playButRes = R.drawable.ic_pause
|
||||
playButInit = true
|
||||
|
|
|
@ -460,7 +460,7 @@ import java.util.concurrent.Semaphore
|
|||
val pos: Int = ieMap[item.id] ?: -1
|
||||
if (pos >= 0) {
|
||||
// episodes[pos].inQueueState.value = event.inQueue()
|
||||
queueChanged++
|
||||
// queueChanged++
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
|
@ -137,11 +137,11 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
|
||||
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
|
||||
|
||||
// toolbar.title = "Queues"
|
||||
queues = realm.query(PlayQueue::class).find()
|
||||
queueNames = queues.map { it.name }.toTypedArray()
|
||||
spinnerTexts.clear()
|
||||
spinnerTexts.addAll(queues.map { "${it.name} : ${it.episodeIds.size}" })
|
||||
spinnerTexts.addAll(queues.map { "${it.name} : ${it.size()}" })
|
||||
var curIndex = queues.indexOf(curQueue)
|
||||
|
||||
(activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow)
|
||||
toolbar.inflateMenu(R.menu.queue)
|
||||
|
@ -150,7 +150,7 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
spinnerView = ComposeView(requireContext()).apply {
|
||||
setContent {
|
||||
CustomTheme(requireContext()) {
|
||||
Spinner(items = spinnerTexts, selectedItem = curQueue.name + " : ${curQueue.episodeIds.size}") { index: Int ->
|
||||
Spinner(items = spinnerTexts, selectedIndex = curIndex) { index: Int ->
|
||||
Logd(TAG, "Queue selected: $queues[index].name")
|
||||
val prevQueueSize = curQueue.size()
|
||||
curQueue = upsertBlk(queues[index]) { it.update() }
|
||||
|
@ -370,7 +370,10 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
}
|
||||
FlowEvent.QueueEvent.Action.MOVED, FlowEvent.QueueEvent.Action.DELETED_MEDIA -> return
|
||||
}
|
||||
queueChanged++
|
||||
queues = realm.query(PlayQueue::class).find()
|
||||
queueNames = queues.map { it.name }.toTypedArray()
|
||||
spinnerTexts.clear()
|
||||
spinnerTexts.addAll(queues.map { "${it.name} : ${it.size()}" })
|
||||
refreshMenuItems()
|
||||
refreshInfoBar()
|
||||
}
|
||||
|
@ -665,6 +668,9 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
|
|||
}
|
||||
for (e in queueItems) vms.add(EpisodeVM(e))
|
||||
Logd(TAG, "loadCurQueue() curQueue.episodes: ${curQueue.episodes.size}")
|
||||
queues = realm.query(PlayQueue::class).find()
|
||||
spinnerTexts.clear()
|
||||
spinnerTexts.addAll(queues.map { "${it.name} : ${it.size()}" })
|
||||
refreshInfoBar()
|
||||
loadItemsRunning = false
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package ac.mdiq.podcini.ui.fragment
|
||||
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.PagerFragmentBinding
|
||||
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
|
||||
import ac.mdiq.podcini.databinding.ComposeFragmentBinding
|
||||
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.update
|
||||
|
@ -30,15 +30,12 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
@ -46,18 +43,12 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
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
|
||||
|
@ -68,40 +59,297 @@ import kotlin.math.max
|
|||
import kotlin.math.min
|
||||
|
||||
class StatisticsFragment : Fragment() {
|
||||
private lateinit var tabLayout: TabLayout
|
||||
private lateinit var viewPager: ViewPager2
|
||||
private var _binding: ComposeFragmentBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
|
||||
private var _binding: PagerFragmentBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val selectedTabIndex = mutableIntStateOf(0)
|
||||
lateinit var statsResult: StatisticsResult
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
_binding = PagerFragmentBinding.inflate(inflater)
|
||||
viewPager = binding.viewpager
|
||||
_binding = ComposeFragmentBinding.inflate(inflater)
|
||||
toolbar = binding.toolbar
|
||||
toolbar.title = getString(R.string.statistics_label)
|
||||
toolbar.inflateMenu(R.menu.statistics)
|
||||
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
|
||||
(activity as MainActivity).setupToolbarToggle(toolbar, false)
|
||||
|
||||
viewPager.adapter = PagerAdapter(this)
|
||||
// Give the TabLayout the ViewPager
|
||||
tabLayout = binding.slidingTabs
|
||||
setupPagedToolbar(toolbar, viewPager)
|
||||
|
||||
TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int ->
|
||||
when (position) {
|
||||
POS_SUBSCRIPTIONS -> tab.setText(R.string.subscriptions_label)
|
||||
POS_YEARS -> tab.setText(R.string.months_statistics_label)
|
||||
POS_SPACE_TAKEN -> tab.setText(R.string.downloads_label)
|
||||
else -> {}
|
||||
binding.mainView.setContent {
|
||||
CustomTheme(requireContext()) {
|
||||
val tabTitles = listOf(R.string.subscriptions_label, R.string.months_statistics_label, R.string.downloads_label)
|
||||
Column {
|
||||
TabRow(modifier = Modifier.fillMaxWidth(), selectedTabIndex = selectedTabIndex.value, divider = {}, indicator = { tabPositions ->
|
||||
Box(modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex.value]).height(4.dp).background(Color.Blue))
|
||||
}) {
|
||||
tabTitles.forEachIndexed { index, titleRes ->
|
||||
Tab(text = { Text(stringResource(titleRes)) }, selected = selectedTabIndex.value == index, onClick = { selectedTabIndex.value = index })
|
||||
}
|
||||
}
|
||||
when (selectedTabIndex.value) {
|
||||
0 -> PlayedTime()
|
||||
1 -> MonthlyStats()
|
||||
2 -> DownloadStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.attach()
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlayedTime() {
|
||||
val context = LocalContext.current
|
||||
lateinit var chartData: LineChartData
|
||||
var timeSpentSum = 0L
|
||||
var timeFilterFrom = 0L
|
||||
var timeFilterTo = Long.MAX_VALUE
|
||||
var includeMarkedAsPlayed = false
|
||||
var timePlayedToday = 0L
|
||||
var timeSpentToday = 0L
|
||||
|
||||
fun setTimeFilter(includeMarkedAsPlayed_: Boolean, timeFilterFrom_: Long, timeFilterTo_: Long) {
|
||||
includeMarkedAsPlayed = includeMarkedAsPlayed_
|
||||
timeFilterFrom = timeFilterFrom_
|
||||
timeFilterTo = timeFilterTo_
|
||||
}
|
||||
fun loadStatistics() {
|
||||
val statsToday = getStatistics(true, LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), Long.MAX_VALUE)
|
||||
for (item in statsToday.statsItems) {
|
||||
timePlayedToday += item.timePlayed
|
||||
timeSpentToday += item.timeSpent
|
||||
}
|
||||
includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
|
||||
timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
|
||||
timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
|
||||
try {
|
||||
statsResult = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)
|
||||
statsResult.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.timePlayed.compareTo(item1.timePlayed) }
|
||||
val dataValues = MutableList(statsResult.statsItems.size){0f}
|
||||
for (i in statsResult.statsItems.indices) {
|
||||
val item = statsResult.statsItems[i]
|
||||
dataValues[i] = item.timePlayed.toFloat()
|
||||
timeSpentSum += item.timeSpent
|
||||
}
|
||||
chartData = LineChartData(dataValues)
|
||||
// When "from" is "today", set it to today
|
||||
setTimeFilter(includeMarkedAsPlayed,
|
||||
max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statsResult.oldestDate.toDouble()).toLong(),
|
||||
min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong())
|
||||
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
|
||||
}
|
||||
|
||||
loadStatistics()
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.statistics_today), color = MaterialTheme.colorScheme.onSurface)
|
||||
Row {
|
||||
Text(stringResource(R.string.duration) + ": " + shortLocalizedDuration(context, timePlayedToday), color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Text( stringResource(R.string.spent) + ": " + shortLocalizedDuration(context, timeSpentToday), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
val headerCaption = if (includeMarkedAsPlayed) stringResource(R.string.statistics_counting_total)
|
||||
else {
|
||||
if (timeFilterFrom != 0L || timeFilterTo != Long.MAX_VALUE) {
|
||||
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))
|
||||
stringResource(R.string.statistics_counting_range, dateFrom, dateTo)
|
||||
} else stringResource(R.string.statistics_counting_total)
|
||||
}
|
||||
Text(headerCaption, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp))
|
||||
Row {
|
||||
Text(stringResource(R.string.duration) + ": " + shortLocalizedDuration(context, chartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Text( stringResource(R.string.spent) + ": " + shortLocalizedDuration(context, timeSpentSum), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
HorizontalLineChart(chartData)
|
||||
StatsList(statsResult, chartData) { item ->
|
||||
context.getString(R.string.duration) + ": " + shortLocalizedDuration(context, item!!.timePlayed) + " \t " + context.getString(R.string.spent) + ": " + shortLocalizedDuration(context, item.timeSpent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthlyStats() {
|
||||
lateinit var monthlyStats: List<MonthlyStatisticsItem>
|
||||
var monthlyMaxDataValue = 1f
|
||||
|
||||
fun loadMonthlyStatistics() {
|
||||
try {
|
||||
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
|
||||
var spent = 0L
|
||||
for (m in medias_) {
|
||||
dur += if (m.playedDuration > 0) m.playedDuration
|
||||
else {
|
||||
if (includeMarkedAsPlayed) {
|
||||
if (m.playbackCompletionTime > 0 || (m.episodeOrFetch()?.playState ?: -10) >= PlayState.SKIPPED.code) m.duration
|
||||
else if (m.position > 0) m.position else 0
|
||||
} else m.position
|
||||
}
|
||||
spent += m.timeSpent
|
||||
}
|
||||
mItem.timePlayed = dur
|
||||
mItem.timeSpent = spent
|
||||
months.add(mItem)
|
||||
}
|
||||
monthlyStats = months
|
||||
for (item in monthlyStats) monthlyMaxDataValue = max(monthlyMaxDataValue.toDouble(), item.timePlayed.toDouble()).toFloat()
|
||||
Logd(TAG, "maxDataValue: $monthlyMaxDataValue")
|
||||
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
|
||||
}
|
||||
@Composable
|
||||
fun BarChart() {
|
||||
val barWidth = 40f
|
||||
val spaceBetweenBars = 16f
|
||||
Canvas(modifier = Modifier.width((monthlyStats.size * (barWidth + spaceBetweenBars)).dp).height(150.dp)) {
|
||||
// val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
for (index in monthlyStats.indices) {
|
||||
val barHeight = (monthlyStats[index].timePlayed / monthlyMaxDataValue) * canvasHeight // Normalize height
|
||||
Logd(TAG, "index: $index barHeight: $barHeight")
|
||||
val xOffset = spaceBetweenBars + index * (barWidth + spaceBetweenBars) // Calculate x position
|
||||
drawRect(color = Color.Cyan,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(xOffset, canvasHeight - barHeight),
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun MonthList() {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(monthlyStats) { index, item ->
|
||||
Row(Modifier.background(MaterialTheme.colorScheme.surface)) {
|
||||
Column {
|
||||
val monthString = String.format(Locale.getDefault(), "%d-%d", monthlyStats[index].year, monthlyStats[index].month)
|
||||
Text(monthString, color = textColor, style = MaterialTheme.typography.bodyLarge.merge())
|
||||
val hoursString = stringResource(R.string.duration) + ": " + String.format(Locale.getDefault(), "%.1f ", monthlyStats[index].timePlayed / 3600000.0f) + stringResource(R.string.time_hours) +
|
||||
" \t " + stringResource(R.string.spent) + ": " + String.format(Locale.getDefault(), "%.1f ", monthlyStats[index].timeSpent / 3600000.0f) + stringResource(R.string.time_hours)
|
||||
Text(hoursString, color = textColor, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMonthlyStatistics()
|
||||
Column {
|
||||
Row(modifier = Modifier.horizontalScroll(rememberScrollState()).padding(start = 20.dp, end = 20.dp)) { BarChart() }
|
||||
Spacer(Modifier.height(20.dp))
|
||||
MonthList()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadStats() {
|
||||
val context = LocalContext.current
|
||||
lateinit var downloadstatsData: StatisticsResult
|
||||
lateinit var downloadChartData: LineChartData
|
||||
|
||||
fun loadDownloadStatistics() {
|
||||
downloadstatsData = getStatistics(false, 0, Long.MAX_VALUE, forDL = true)
|
||||
downloadstatsData.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.totalDownloadSize.compareTo(item1.totalDownloadSize) }
|
||||
val dataValues = MutableList(downloadstatsData.statsItems.size) { 0f }
|
||||
for (i in downloadstatsData.statsItems.indices) {
|
||||
val item = downloadstatsData.statsItems[i]
|
||||
dataValues[i] = item.totalDownloadSize.toFloat()
|
||||
}
|
||||
downloadChartData = LineChartData(dataValues)
|
||||
}
|
||||
|
||||
loadDownloadStatistics()
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(R.string.total_size_downloaded_podcasts), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp, bottom = 10.dp))
|
||||
Text(Formatter.formatShortFileSize(context, downloadChartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
|
||||
HorizontalLineChart(downloadChartData)
|
||||
StatsList(downloadstatsData, downloadChartData) { item ->
|
||||
("${Formatter.formatShortFileSize(context, item!!.totalDownloadSize)} • "
|
||||
+ String.format(Locale.getDefault(), "%d%s", item.episodesDownloadCount, context.getString(R.string.episodes_suffix)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HorizontalLineChart(lineChartData: LineChartData) {
|
||||
val data = lineChartData.values
|
||||
val total = data.sum()
|
||||
Canvas(modifier = Modifier.fillMaxWidth().height(50.dp).padding(start = 20.dp, end = 20.dp)) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val lineY = canvasHeight / 2
|
||||
var startX = 0f
|
||||
for (index in data.indices) {
|
||||
val segmentWidth = (data[index] / total) * canvasWidth
|
||||
// Logd(TAG, "index: $index segmentWidth: $segmentWidth")
|
||||
drawRect(color = lineChartData.getComposeColorOfItem(index),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(startX, lineY - 10),
|
||||
size = androidx.compose.ui.geometry.Size(segmentWidth, 20f))
|
||||
startX += segmentWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatsList(statisticsData: StatisticsResult, lineChartData: LineChartData, infoCB: (StatisticsItem?)->String) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
var showFeedStats by remember { mutableStateOf(false) }
|
||||
var feedId by remember { mutableLongStateOf(0L) }
|
||||
var feedTitle by remember { mutableStateOf("") }
|
||||
if (showFeedStats) FeedStatisticsDialog(feedTitle, feedId) { showFeedStats = false }
|
||||
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(statisticsData.statsItems, key = { _, item -> item.feed.id }) { index, item ->
|
||||
Row(Modifier.background(MaterialTheme.colorScheme.surface).fillMaxWidth().clickable(onClick = {
|
||||
Logd(SubscriptionsFragment.TAG, "icon clicked!")
|
||||
feedId = item.feed.id
|
||||
feedTitle = item.feed.title ?: "No title"
|
||||
showFeedStats = true
|
||||
})) {
|
||||
val imgLoc = remember(item) { item.feed.imageUrl }
|
||||
AsyncImage(model = ImageRequest.Builder(context).data(imgLoc).memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(),
|
||||
contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
|
||||
modifier = Modifier.width(40.dp).height(40.dp).padding(end = 5.dp)
|
||||
)
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
Column {
|
||||
Text(item.feed.title?:"No title", color = textColor, style = MaterialTheme.typography.bodyLarge.merge())
|
||||
Row {
|
||||
val chipColor = lineChartData.getComposeColorOfItem(index)
|
||||
Text("⬤", style = MaterialTheme.typography.bodyMedium.merge(), color = chipColor)
|
||||
Text(infoCB(item), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
Logd(TAG, "onDestroyView")
|
||||
_binding = null
|
||||
|
@ -109,30 +357,53 @@ class StatisticsFragment : Fragment() {
|
|||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.statistics_reset) {
|
||||
confirmResetStatistics()
|
||||
return true
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
when (selectedTabIndex.value) {
|
||||
0 -> {
|
||||
menu.findItem(R.id.statistics_reset).setVisible(true)
|
||||
menu.findItem(R.id.statistics_filter).setVisible(true)
|
||||
}
|
||||
1 -> {
|
||||
menu.findItem(R.id.statistics_reset).setVisible(true)
|
||||
menu.findItem(R.id.statistics_filter).setVisible(false)
|
||||
}
|
||||
else -> {
|
||||
menu.findItem(R.id.statistics_reset).setVisible(false)
|
||||
menu.findItem(R.id.statistics_filter).setVisible(false)
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
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)
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.statistics_reset -> {
|
||||
confirmResetStatistics()
|
||||
return true
|
||||
}
|
||||
})
|
||||
R.id.statistics_filter -> {
|
||||
val dialog = object: DatesFilterDialog(requireContext(), statsResult.oldestDate) {
|
||||
override fun initParams() {
|
||||
prefs = Companion.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
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmResetStatistics() {
|
||||
|
@ -168,340 +439,6 @@ class StatisticsFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private class PagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
POS_SUBSCRIPTIONS -> SubscriptionStatisticsFragment()
|
||||
POS_YEARS -> MonthlyStatisticsFragment()
|
||||
POS_SPACE_TAKEN -> DownloadStatisticsFragment()
|
||||
else -> DownloadStatisticsFragment()
|
||||
}
|
||||
}
|
||||
override fun getItemCount(): Int {
|
||||
return TOTAL_COUNT
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionStatisticsFragment : Fragment() {
|
||||
lateinit var statisticsData: StatisticsResult
|
||||
private lateinit var lineChartData: LineChartData
|
||||
private var timeSpentSum = 0L
|
||||
private var timeFilterFrom: Long = 0
|
||||
private var timeFilterTo = Long.MAX_VALUE
|
||||
private var includeMarkedAsPlayed = false
|
||||
|
||||
private var timePlayedToday: Long = 0
|
||||
private var timeSpentToday: Long = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
loadStatistics()
|
||||
val composeView = ComposeView(requireContext()).apply {
|
||||
setContent {
|
||||
CustomTheme(requireContext()) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.statistics_today), color = MaterialTheme.colorScheme.onSurface)
|
||||
Row {
|
||||
Text(stringResource(R.string.duration) + ": " + shortLocalizedDuration(context, timePlayedToday), color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Text( stringResource(R.string.spent) + ": " + shortLocalizedDuration(context, timeSpentToday), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
val headerCaption = if (includeMarkedAsPlayed) stringResource(R.string.statistics_counting_total)
|
||||
else {
|
||||
if (timeFilterFrom != 0L || timeFilterTo != Long.MAX_VALUE) {
|
||||
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))
|
||||
stringResource(R.string.statistics_counting_range, dateFrom, dateTo)
|
||||
} else stringResource(R.string.statistics_counting_total)
|
||||
}
|
||||
Text(headerCaption, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp))
|
||||
Row {
|
||||
Text(stringResource(R.string.duration) + ": " + shortLocalizedDuration(context, lineChartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Text( stringResource(R.string.spent) + ": " + shortLocalizedDuration(context, timeSpentSum), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
HorizontalLineChart(lineChartData)
|
||||
StatsList(statisticsData, lineChartData) { item ->
|
||||
context.getString(R.string.duration) + ": " + shortLocalizedDuration(context, item!!.timePlayed) +
|
||||
"\t" + context.getString(R.string.spent) + ": " + shortLocalizedDuration(context, item.timeSpent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return composeView
|
||||
}
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
procFlowEvents()
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
cancelFlowEvents()
|
||||
}
|
||||
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 -> loadStatistics()
|
||||
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) {
|
||||
val dialog = object: DatesFilterDialog(requireContext(), statisticsData.oldestDate) {
|
||||
override fun initParams() {
|
||||
prefs = Companion.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 setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) {
|
||||
this.includeMarkedAsPlayed = includeMarkedAsPlayed
|
||||
this.timeFilterFrom = timeFilterFrom
|
||||
this.timeFilterTo = timeFilterTo
|
||||
}
|
||||
private fun loadStatistics() {
|
||||
val statsToday = getStatistics(true, LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), Long.MAX_VALUE)
|
||||
for (item in statsToday.statsItems) {
|
||||
timePlayedToday += item.timePlayed
|
||||
timeSpentToday += item.timeSpent
|
||||
}
|
||||
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)
|
||||
try {
|
||||
statisticsData = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)
|
||||
statisticsData.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.timePlayed.compareTo(item1.timePlayed) }
|
||||
val dataValues = MutableList(statisticsData.statsItems.size){0f}
|
||||
for (i in statisticsData.statsItems.indices) {
|
||||
val item = statisticsData.statsItems[i]
|
||||
dataValues[i] = item.timePlayed.toFloat()
|
||||
timeSpentSum += item.timeSpent
|
||||
}
|
||||
lineChartData = LineChartData(dataValues)
|
||||
// When "from" is "today", set it to today
|
||||
setTimeFilter(includeMarkedAsPlayed,
|
||||
max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(),
|
||||
min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong())
|
||||
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
class MonthlyStatisticsFragment : Fragment() {
|
||||
private lateinit var monthlyStats: List<MonthlyStatisticsItem>
|
||||
private var maxDataValue = 1f
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
loadStatistics()
|
||||
val composeView = ComposeView(requireContext()).apply {
|
||||
setContent {
|
||||
CustomTheme(requireContext()) {
|
||||
Column {
|
||||
Row(modifier = Modifier.horizontalScroll(rememberScrollState()).padding(start = 20.dp, end = 20.dp)) { BarChart() }
|
||||
Spacer(Modifier.height(20.dp))
|
||||
MonthList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return composeView
|
||||
}
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
procFlowEvents()
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
cancelFlowEvents()
|
||||
}
|
||||
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 -> loadStatistics()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun BarChart() {
|
||||
val barWidth = 40f
|
||||
val spaceBetweenBars = 16f
|
||||
Canvas(modifier = Modifier.width((monthlyStats.size * (barWidth + spaceBetweenBars)).dp).height(150.dp)) {
|
||||
// val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
for (index in monthlyStats.indices) {
|
||||
val barHeight = (monthlyStats[index].timePlayed / maxDataValue) * canvasHeight // Normalize height
|
||||
Logd(TAG, "index: $index barHeight: $barHeight")
|
||||
val xOffset = spaceBetweenBars + index * (barWidth + spaceBetweenBars) // Calculate x position
|
||||
drawRect(color = Color.Cyan,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(xOffset, canvasHeight - barHeight),
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun MonthList() {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(monthlyStats) { index, item ->
|
||||
Row(Modifier.background(MaterialTheme.colorScheme.surface)) {
|
||||
Column {
|
||||
val monthString = String.format(Locale.getDefault(), "%d-%d", monthlyStats[index].year, monthlyStats[index].month)
|
||||
Text(monthString, color = textColor, style = MaterialTheme.typography.bodyLarge.merge())
|
||||
val hoursString = stringResource(R.string.duration) + ": " + String.format(Locale.getDefault(), "%.1f ", monthlyStats[index].timePlayed / 3600000.0f) + stringResource(R.string.time_hours) +
|
||||
"\t" + stringResource(R.string.spent) + ": " + String.format(Locale.getDefault(), "%.1f ", monthlyStats[index].timeSpent / 3600000.0f) + stringResource(R.string.time_hours)
|
||||
Text(hoursString, color = textColor, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@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 loadStatistics() {
|
||||
try {
|
||||
monthlyStats = getMonthlyTimeStatistics()
|
||||
for (item in monthlyStats) maxDataValue = max(maxDataValue.toDouble(), item.timePlayed.toDouble()).toFloat()
|
||||
Logd(TAG, "maxDataValue: $maxDataValue")
|
||||
} catch (error: Throwable) { 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
|
||||
var spent = 0L
|
||||
for (m in medias_) {
|
||||
dur += if (m.playedDuration > 0) m.playedDuration
|
||||
else {
|
||||
if (includeMarkedAsPlayed) {
|
||||
if (m.playbackCompletionTime > 0 || (m.episodeOrFetch()?.playState ?: -10) >= PlayState.SKIPPED.code) m.duration
|
||||
else if (m.position > 0) m.position else 0
|
||||
} else m.position
|
||||
}
|
||||
spent += m.timeSpent
|
||||
}
|
||||
mItem.timePlayed = dur
|
||||
mItem.timeSpent = spent
|
||||
months.add(mItem)
|
||||
}
|
||||
return months
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadStatisticsFragment : Fragment() {
|
||||
private lateinit var statisticsData: StatisticsResult
|
||||
private lateinit var lineChartData: LineChartData
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
loadStatistics()
|
||||
val composeView = ComposeView(requireContext()).apply {
|
||||
setContent {
|
||||
CustomTheme(requireContext()) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(R.string.total_size_downloaded_podcasts), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp, bottom = 10.dp))
|
||||
Text(Formatter.formatShortFileSize(context, lineChartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
|
||||
HorizontalLineChart(lineChartData)
|
||||
StatsList(statisticsData, lineChartData) { item ->
|
||||
("${Formatter.formatShortFileSize(context, item!!.totalDownloadSize)} • "
|
||||
+ String.format(Locale.getDefault(), "%d%s", item.episodesDownloadCount, context.getString(R.string.episodes_suffix)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return composeView
|
||||
}
|
||||
|
||||
@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 loadStatistics() {
|
||||
statisticsData = getStatistics(false, 0, Long.MAX_VALUE)
|
||||
statisticsData.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.totalDownloadSize.compareTo(item1.totalDownloadSize) }
|
||||
val dataValues = MutableList(statisticsData.statsItems.size) { 0f }
|
||||
for (i in statisticsData.statsItems.indices) {
|
||||
val item = statisticsData.statsItems[i]
|
||||
dataValues[i] = item.totalDownloadSize.toFloat()
|
||||
}
|
||||
lineChartData = LineChartData(dataValues)
|
||||
}
|
||||
}
|
||||
|
||||
class LineChartData(val values: MutableList<Float>) {
|
||||
val sum: Float
|
||||
|
||||
|
@ -515,7 +452,7 @@ class StatisticsFragment : Fragment() {
|
|||
return values[index] / sum
|
||||
}
|
||||
private fun isLargeEnoughToDisplay(index: Int): Boolean {
|
||||
return getPercentageOfItem(index) > 0.04
|
||||
return getPercentageOfItem(index) > 0.01
|
||||
}
|
||||
fun getComposeColorOfItem(index: Int): Color {
|
||||
if (!isLargeEnoughToDisplay(index)) return Color.Gray
|
||||
|
@ -535,72 +472,65 @@ class StatisticsFragment : Fragment() {
|
|||
const val PREF_INCLUDE_MARKED_PLAYED: String = "countAll"
|
||||
const val PREF_FILTER_FROM: String = "filterFrom"
|
||||
const val PREF_FILTER_TO: String = "filterTo"
|
||||
|
||||
private const val POS_SUBSCRIPTIONS = 0
|
||||
private const val POS_YEARS = 1
|
||||
private const val POS_SPACE_TAKEN = 2
|
||||
private const val TOTAL_COUNT = 3
|
||||
|
||||
var prefs: SharedPreferences? = null
|
||||
|
||||
fun getSharedPrefs(context: Context) {
|
||||
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HorizontalLineChart(lineChartData: LineChartData) {
|
||||
val data = lineChartData.values
|
||||
val total = data.sum()
|
||||
Canvas(modifier = Modifier.fillMaxWidth().height(50.dp).padding(start = 20.dp, end = 20.dp)) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val lineY = canvasHeight / 2
|
||||
var startX = 0f
|
||||
for (index in data.indices) {
|
||||
val segmentWidth = (data[index] / total) * canvasWidth
|
||||
Logd(TAG, "index: $index segmentWidth: $segmentWidth")
|
||||
drawRect(color = lineChartData.getComposeColorOfItem(index),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(startX, lineY - 10),
|
||||
size = androidx.compose.ui.geometry.Size(segmentWidth, 20f))
|
||||
startX += segmentWidth
|
||||
}
|
||||
fun getStatistics(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long, feedId: Long = 0L, forDL: Boolean = false): StatisticsResult {
|
||||
Logd(TAG, "getStatistics called")
|
||||
val queryString = if (feedId != 0L) "episode.feedId == $feedId AND (lastPlayedTime > 0 OR downloaded == true)"
|
||||
else {
|
||||
if (forDL) "downloaded == true" else "lastPlayedTime > 0"
|
||||
}
|
||||
}
|
||||
// val medias = if (feedId == 0L) realm.query(EpisodeMedia::class).query("lastPlayedTime > 0 OR downloaded == true").find()
|
||||
// else realm.query(EpisodeMedia::class).query("episode.feedId == $feedId").query("lastPlayedTime > 0 OR downloaded == true").find()
|
||||
val medias = realm.query(EpisodeMedia::class).query(queryString).find()
|
||||
|
||||
@Composable
|
||||
fun StatsList(statisticsData: StatisticsResult, lineChartData: LineChartData, infoCB: (StatisticsItem?)->String) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
var showFeedStats by remember { mutableStateOf(false) }
|
||||
var feedId by remember { mutableLongStateOf(0L) }
|
||||
var feedTitle by remember { mutableStateOf("") }
|
||||
if (showFeedStats) FeedStatisticsDialog(feedTitle, feedId) { showFeedStats = false }
|
||||
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(statisticsData.statsItems, key = { _, item -> item.feed.id }) { index, item ->
|
||||
Row(Modifier.background(MaterialTheme.colorScheme.surface).fillMaxWidth().clickable(onClick = {
|
||||
Logd(SubscriptionsFragment.TAG, "icon clicked!")
|
||||
feedId = item.feed.id
|
||||
feedTitle = item.feed.title ?: "No title"
|
||||
showFeedStats = true
|
||||
})) {
|
||||
val imgLoc = remember(item) { item.feed.imageUrl }
|
||||
AsyncImage(model = ImageRequest.Builder(context).data(imgLoc).memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(),
|
||||
contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
|
||||
modifier = Modifier.width(40.dp).height(40.dp).padding(end = 5.dp)
|
||||
)
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
Column {
|
||||
Text(item.feed.title?:"No title", color = textColor, style = MaterialTheme.typography.bodyLarge.merge())
|
||||
Row {
|
||||
val chipColor = lineChartData.getComposeColorOfItem(index)
|
||||
Text("⬤", style = MaterialTheme.typography.bodyMedium.merge(), color = chipColor)
|
||||
Text(infoCB(item), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 2.dp))
|
||||
val groupdMedias = medias.groupBy { it.episodeOrFetch()?.feedId ?: 0L }
|
||||
val result = StatisticsResult()
|
||||
result.oldestDate = Long.MAX_VALUE
|
||||
for ((fid, feedMedias) in groupdMedias) {
|
||||
val feed = getFeed(fid, false) ?: continue
|
||||
val numEpisodes = feed.episodes.size.toLong()
|
||||
var feedPlayedTime = 0L
|
||||
var timeSpent = 0L
|
||||
var durationWithSkip = 0L
|
||||
var feedTotalTime = 0L
|
||||
var episodesStarted = 0L
|
||||
var totalDownloadSize = 0L
|
||||
var episodesDownloadCount = 0L
|
||||
for (m in feedMedias) {
|
||||
if (m.lastPlayedTime > 0 && m.lastPlayedTime < result.oldestDate) result.oldestDate = m.lastPlayedTime
|
||||
feedTotalTime += m.duration
|
||||
if (m.lastPlayedTime in (timeFilterFrom + 1)..<timeFilterTo) {
|
||||
if (includeMarkedAsPlayed) {
|
||||
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || (m.episodeOrFetch()?.playState?:-10) > PlayState.SKIPPED.code || m.position > 0) {
|
||||
episodesStarted += 1
|
||||
feedPlayedTime += m.duration
|
||||
timeSpent += m.timeSpent
|
||||
}
|
||||
} else {
|
||||
feedPlayedTime += m.playedDuration
|
||||
timeSpent += m.timeSpent
|
||||
// Logd(TAG, "m.playedDuration: ${m.playedDuration} m.timeSpent: ${m.timeSpent}")
|
||||
if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1
|
||||
}
|
||||
durationWithSkip += m.duration
|
||||
}
|
||||
if (m.downloaded) {
|
||||
episodesDownloadCount += 1
|
||||
totalDownloadSize += m.size
|
||||
}
|
||||
}
|
||||
feedPlayedTime /= 1000
|
||||
durationWithSkip /= 1000
|
||||
timeSpent /= 1000
|
||||
feedTotalTime /= 1000
|
||||
result.statsItems.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, timeSpent, durationWithSkip, numEpisodes, episodesStarted, totalDownloadSize, episodesDownloadCount))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/pager_fragment"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:attr/actionBarSize"
|
||||
app:navigationContentDescription="@string/toolbar_back_button_content_description"
|
||||
app:navigationIcon="?homeAsUpIndicator" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/sliding_tabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/colorBackground"
|
||||
app:tabBackground="?attr/selectableItemBackground"
|
||||
app:tabMode="auto"
|
||||
app:tabGravity="fill" />
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/viewpager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</LinearLayout>
|
|
@ -622,6 +622,8 @@
|
|||
<string name="developers">Developers</string>
|
||||
<string name="translators">Translators</string>
|
||||
<string name="special_thanks">Special thanks</string>
|
||||
<string name="online_help">Online home page</string>
|
||||
<string name="online_help_sum">Get more info online</string>
|
||||
<string name="privacy_policy">Privacy policy</string>
|
||||
<string name="licenses">Licenses</string>
|
||||
<string name="licenses_summary">Podcini uses other great software</string>
|
||||
|
|
|
@ -1,28 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<Preference
|
||||
android:layout="@layout/about_teaser"/>
|
||||
android:layout="@layout/about_teaser"/>
|
||||
<Preference
|
||||
android:key="about_version"
|
||||
android:title="@string/podcini_version"
|
||||
android:icon="@drawable/ic_star"
|
||||
android:summary="1.7.2 (asd8qs)"/>
|
||||
android:key="about_version"
|
||||
android:title="@string/podcini_version"
|
||||
android:icon="@drawable/ic_star"
|
||||
android:summary="1.7.2 (asd8qs)"/>
|
||||
<Preference
|
||||
android:key="about_privacy_policy"
|
||||
android:icon="@drawable/ic_questionmark"
|
||||
android:summary="Podcini PrivacyPolicy.md"
|
||||
android:title="@string/privacy_policy"/>
|
||||
android:key="about_help"
|
||||
android:icon="@drawable/ic_questionmark"
|
||||
android:summary="@string/online_help_sum"
|
||||
android:title="@string/online_help"/>
|
||||
<Preference
|
||||
android:key="about_licenses"
|
||||
android:icon="@drawable/ic_info"
|
||||
android:summary="@string/licenses_summary"
|
||||
android:title="@string/licenses"/>
|
||||
android:key="about_privacy_policy"
|
||||
android:icon="@drawable/ic_questionmark"
|
||||
android:summary="Podcini PrivacyPolicy.md"
|
||||
android:title="@string/privacy_policy"/>
|
||||
<Preference
|
||||
android:key="about_contributors"
|
||||
android:icon="@drawable/ic_settings"
|
||||
android:summary="@string/contributors_summary"
|
||||
android:title="@string/contributors"/>
|
||||
android:key="about_licenses"
|
||||
android:icon="@drawable/ic_info"
|
||||
android:summary="@string/licenses_summary"
|
||||
android:title="@string/licenses"/>
|
||||
<!-- <Preference-->
|
||||
<!-- android:key="about_contributors"-->
|
||||
<!-- android:icon="@drawable/ic_settings"-->
|
||||
<!-- android:summary="@string/contributors_summary"-->
|
||||
<!-- android:title="@string/contributors"/>-->
|
||||
|
||||
</PreferenceScreen>
|
||||
|
|
11
changelog.md
11
changelog.md
|
@ -1,3 +1,14 @@
|
|||
# 6.13.9
|
||||
|
||||
* made Spinner in Queues view update accordingly
|
||||
* if playing from the virtual queue in FeedEpisodes, the next episode comes from the filtered list
|
||||
* when playing another media, post playback is performed on the current media so as to set the right state
|
||||
* fixed timeSpent not being correctly recorded
|
||||
* further enhanced efficiency of statistics calculations
|
||||
* in Statistics, feeds with no media started or downloaded are not shown
|
||||
* furthered Statistics view into Compose
|
||||
* added online homepage item in Settings->About to open the Github page
|
||||
|
||||
# 6.13.8
|
||||
|
||||
* Subscriptions sorting added the negative sides of downloaded and commented
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
Version 6.13.9
|
||||
|
||||
* made Spinner in Queues view update accordingly
|
||||
* if playing from the virtual queue in FeedEpisodes, the next episode comes from the filtered list
|
||||
* when playing another media, post playback is performed on the current media so as to set the right state
|
||||
* fixed timeSpent not being correctly recorded
|
||||
* further enhanced efficiency of statistics calculations
|
||||
* in Statistics, feeds with no media started or downloaded are not shown
|
||||
* furthered Statistics view into Compose
|
||||
* added online homepage item in Settings->About to open the Github page
|
Loading…
Reference in New Issue