diff --git a/app/build.gradle b/app/build.gradle index a113a782..9951aca3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt index 5bd619fd..3d004bfb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt @@ -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) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index d34950c8..648375f9 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt index 07d41d92..953c25c3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt @@ -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("about_contributors")!!.onPreferenceClickListener = + findPreference("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("about_privacy_policy")!!.onPreferenceClickListener = @@ -68,10 +58,7 @@ class AboutFragment : PreferenceFragmentCompat() { } findPreference("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(private val context: Context, private val listItems: List) + : ArrayAdapter(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() - 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() - 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() - 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(private val context: Context, private val listItems: List) - : ArrayAdapter(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) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt index bcb9a988..3e0b0ed5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt @@ -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).. 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 - } } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt index 67ebaaa6..5e2d1f28 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt @@ -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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt index 93c61a16..aefe9fc5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt @@ -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_ } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/PlayQueue.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/PlayQueue.kt index c2f199cb..98b4a041 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/PlayQueue.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/PlayQueue.kt @@ -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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt index 9ff20af0..d98b11d3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt @@ -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) {} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt index 697659c3..cf5187e2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt @@ -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 { 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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index 45690e84..d438e926 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -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, 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, selectedItem: String, modifier: Modifier = Modi } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Spinner(items: List, 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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index 925531e3..7ac5f0c3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -133,7 +133,7 @@ fun InforBar(text: MutableState, leftAction: MutableState, } } -var queueChanged by mutableIntStateOf(0) +//var queueChanged by mutableIntStateOf(0) @Stable class EpisodeVM(var episode: Episode) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index f9642b09..00ca13d8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index 4edcc0a7..6ff2d6a6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 8d8359a2..ec0da4bb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt index 79a384db..6547d69c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt @@ -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 + var monthlyMaxDataValue = 1f + + fun loadMonthlyStatistics() { + try { + val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) + val months: MutableList = 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 - 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 { - Logd(TAG, "getMonthlyTimeStatistics called") - val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) - - val months: MutableList = 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) { 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).. 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 diff --git a/app/src/main/res/layout/pager_fragment.xml b/app/src/main/res/layout/pager_fragment.xml deleted file mode 100644 index ef3fab63..00000000 --- a/app/src/main/res/layout/pager_fragment.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f08152f..c43a9db5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -622,6 +622,8 @@ Developers Translators Special thanks + Online home page + Get more info online Privacy policy Licenses Podcini uses other great software diff --git a/app/src/main/res/xml/preferences_about.xml b/app/src/main/res/xml/preferences_about.xml index 8cd4a328..00a594f2 100644 --- a/app/src/main/res/xml/preferences_about.xml +++ b/app/src/main/res/xml/preferences_about.xml @@ -1,28 +1,33 @@ + xmlns:android="http://schemas.android.com/apk/res/android"> + android:layout="@layout/about_teaser"/> + android:key="about_version" + android:title="@string/podcini_version" + android:icon="@drawable/ic_star" + android:summary="1.7.2 (asd8qs)"/> + android:key="about_help" + android:icon="@drawable/ic_questionmark" + android:summary="@string/online_help_sum" + android:title="@string/online_help"/> + android:key="about_privacy_policy" + android:icon="@drawable/ic_questionmark" + android:summary="Podcini PrivacyPolicy.md" + android:title="@string/privacy_policy"/> + android:key="about_licenses" + android:icon="@drawable/ic_info" + android:summary="@string/licenses_summary" + android:title="@string/licenses"/> + + + + + diff --git a/changelog.md b/changelog.md index e06f7538..63c53724 100644 --- a/changelog.md +++ b/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 diff --git a/fastlane/metadata/android/en-US/changelogs/3020296.txt b/fastlane/metadata/android/en-US/changelogs/3020296.txt new file mode 100644 index 00000000..055a07e3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020296.txt @@ -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