6.13.9 commit

This commit is contained in:
Xilin Jia 2024-11-11 20:09:31 +01:00
parent 08822bd8ac
commit 435b6d4a6e
21 changed files with 513 additions and 793 deletions

View File

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

View File

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

View File

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

View File

@ -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,121 +124,6 @@ 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()
binding.toolbar.visibility = View.GONE
return binding.root
}
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) {
@ -280,12 +141,4 @@ class AboutFragment : PreferenceFragmentCompat() {
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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,140 +59,88 @@ 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 {
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
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
@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
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_reset) {
confirmResetStatistics()
return true
fun setTimeFilter(includeMarkedAsPlayed_: Boolean, timeFilterFrom_: Long, timeFilterTo_: Long) {
includeMarkedAsPlayed = includeMarkedAsPlayed_
timeFilterFrom = timeFilterFrom_
timeFilterTo = timeFilterTo_
}
return super.onOptionsItemSelected(item)
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
}
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)
}
})
}
private fun confirmResetStatistics() {
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()
}
}
conDialog.createNewDialog().show()
}
private fun doResetStatistics() {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
.putLong(PREF_FILTER_FROM, 0)
.putLong(PREF_FILTER_TO, Long.MAX_VALUE)
.apply()
lifecycleScope.launch {
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 {
withContext(Dispatchers.IO) { resetStatistics() }
EventFlow.postEvent(FlowEvent.StatisticsEvent())
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)) }
}
}
private fun resetStatistics(): Job {
return runOnIOScope {
val mediaAll = realm.query(EpisodeMedia::class).find()
for (m in mediaAll) update(m) { m.playedDuration = 0 }
}
}
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 {
@ -222,205 +161,25 @@ class StatisticsFragment : Fragment() {
}
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)
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(lineChartData)
StatsList(statisticsData, lineChartData) { item ->
context.getString(R.string.duration) + ": " + shortLocalizedDuration(context, item!!.timePlayed) +
"\t" + context.getString(R.string.spent) + ": " + shortLocalizedDuration(context, item.timeSpent)
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)
}
}
}
}
}
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)
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 {
@ -455,97 +214,85 @@ class StatisticsFragment : Fragment() {
mItem.timeSpent = spent
months.add(mItem)
}
return months
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)
}
}
}
}
}
class DownloadStatisticsFragment : Fragment() {
private lateinit var statisticsData: StatisticsResult
private lateinit var lineChartData: LineChartData
loadMonthlyStatistics()
Column {
Row(modifier = Modifier.horizontalScroll(rememberScrollState()).padding(start = 20.dp, end = 20.dp)) { BarChart() }
Spacer(Modifier.height(20.dp))
MonthList()
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
loadStatistics()
val composeView = ComposeView(requireContext()).apply {
setContent {
CustomTheme(requireContext()) {
@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, lineChartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
HorizontalLineChart(lineChartData)
StatsList(statisticsData, lineChartData) { item ->
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)))
}
}
}
}
}
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
init {
var valueSum = 0f
for (datum in values) valueSum += datum
this.sum = valueSum
}
private fun getPercentageOfItem(index: Int): Float {
if (sum == 0f) return 0f
return values[index] / sum
}
private fun isLargeEnoughToDisplay(index: Int): Boolean {
return getPercentageOfItem(index) > 0.04
}
fun getComposeColorOfItem(index: Int): Color {
if (!isLargeEnoughToDisplay(index)) return Color.Gray
return Color(COLOR_VALUES[index % COLOR_VALUES.size])
}
companion object {
private val COLOR_VALUES = mutableListOf(-0xc88a1a, -0x1ae3dd, -0x6800, -0xda64dc, -0x63d850,
-0xff663a, -0x22bb89, -0x995600, -0x47d1d2, -0xce9c6b,
-0x66bb67, -0xdd5567, -0x5555ef, -0x99cc34, -0xff8c1a)
}
}
companion object {
val TAG = StatisticsFragment::class.simpleName ?: "Anonymous"
private const val PREF_NAME: String = "StatisticsActivityPrefs"
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) {
@ -558,7 +305,7 @@ class StatisticsFragment : Fragment() {
var startX = 0f
for (index in data.indices) {
val segmentWidth = (data[index] / total) * canvasWidth
Logd(TAG, "index: $index segmentWidth: $segmentWidth")
// 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))
@ -603,6 +350,189 @@ class StatisticsFragment : Fragment() {
}
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
@Deprecated("Deprecated in Java")
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)
}
}
}
@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() {
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()
}
}
conDialog.createNewDialog().show()
}
private fun doResetStatistics() {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
.putLong(PREF_FILTER_FROM, 0)
.putLong(PREF_FILTER_TO, Long.MAX_VALUE)
.apply()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { resetStatistics() }
EventFlow.postEvent(FlowEvent.StatisticsEvent())
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
}
}
private fun resetStatistics(): Job {
return runOnIOScope {
val mediaAll = realm.query(EpisodeMedia::class).find()
for (m in mediaAll) update(m) { m.playedDuration = 0 }
}
}
class LineChartData(val values: MutableList<Float>) {
val sum: Float
init {
var valueSum = 0f
for (datum in values) valueSum += datum
this.sum = valueSum
}
private fun getPercentageOfItem(index: Int): Float {
if (sum == 0f) return 0f
return values[index] / sum
}
private fun isLargeEnoughToDisplay(index: Int): Boolean {
return getPercentageOfItem(index) > 0.01
}
fun getComposeColorOfItem(index: Int): Color {
if (!isLargeEnoughToDisplay(index)) return Color.Gray
return Color(COLOR_VALUES[index % COLOR_VALUES.size])
}
companion object {
private val COLOR_VALUES = mutableListOf(-0xc88a1a, -0x1ae3dd, -0x6800, -0xda64dc, -0x63d850,
-0xff663a, -0x22bb89, -0x995600, -0x47d1d2, -0xce9c6b,
-0x66bb67, -0xdd5567, -0x5555ef, -0x99cc34, -0xff8c1a)
}
}
companion object {
val TAG = StatisticsFragment::class.simpleName ?: "Anonymous"
private const val PREF_NAME: String = "StatisticsActivityPrefs"
const val PREF_INCLUDE_MARKED_PLAYED: String = "countAll"
const val PREF_FILTER_FROM: String = "filterFrom"
const val PREF_FILTER_TO: String = "filterTo"
var prefs: SharedPreferences? = null
fun getSharedPrefs(context: Context) {
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
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()
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
fun FeedStatisticsDialog(title: String, feedId: Long, onDismissRequest: () -> Unit) {
var statisticsData: StatisticsItem? = null

View File

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

View File

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

View File

@ -9,6 +9,11 @@
android:title="@string/podcini_version"
android:icon="@drawable/ic_star"
android:summary="1.7.2 (asd8qs)"/>
<Preference
android:key="about_help"
android:icon="@drawable/ic_questionmark"
android:summary="@string/online_help_sum"
android:title="@string/online_help"/>
<Preference
android:key="about_privacy_policy"
android:icon="@drawable/ic_questionmark"
@ -19,10 +24,10 @@
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"/>
<!-- <Preference-->
<!-- android:key="about_contributors"-->
<!-- android:icon="@drawable/ic_settings"-->
<!-- android:summary="@string/contributors_summary"-->
<!-- android:title="@string/contributors"/>-->
</PreferenceScreen>

View File

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

View File

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