diff --git a/app/build.gradle b/app/build.gradle index 068f6808..102057bd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,11 +28,11 @@ android { vectorDrawables.useSupportLibrary false vectorDrawables.generatedDensities = [] - testApplicationId "ac.mdiq.podcini.tests" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" +// testApplicationId "ac.mdiq.podcini.tests" +// testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020286 - versionName "6.12.8" + versionCode 3020287 + versionName "6.13.0" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt index 68df9b7f..365c306f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/InTheatre.kt @@ -29,14 +29,14 @@ object InTheatre { value != null -> { field = unmanaged(value) if (field?.media != null && curMedia?.getIdentifier() != field?.media?.getIdentifier()) curMedia = unmanaged(field!!.media!!) +// field = value +// if (field?.media != null && curMedia?.getIdentifier() != field?.media?.getIdentifier()) curMedia = field!!.media!! } else -> { field = null if (curMedia != null) curMedia = null } } -// field = if (value != null) unmanaged(value) else null -// if (field?.media != null && curMedia?.getIdentifier() != field?.media?.getIdentifier()) curMedia = unmanaged(field!!.media!!) } var curMedia: Playable? = null // unmanged if EpisodeMedia @@ -45,6 +45,8 @@ object InTheatre { value is EpisodeMedia -> { field = unmanaged(value) if (value.episode != null && curEpisode?.id != value.episode?.id) curEpisode = unmanaged(value.episode!!) +// field = value +// if (value.episode != null && curEpisode?.id != value.episode?.id) curEpisode = value.episode!! } value == null -> { field = null @@ -52,12 +54,6 @@ object InTheatre { } else -> field = value } -// if (value is EpisodeMedia) { -// field = unmanaged(value) -// if (value.episode != null && curEpisode?.id != value.episode?.id) curEpisode = unmanaged(value.episode!!) -// } else { -// field = value -// } } var curState: CurrentState // managed 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 aad9f522..982c8359 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 @@ -13,10 +13,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.storage.model.Playable +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent @@ -518,10 +515,16 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP Logd(TAG, "setVolume: $volumeLeft $volumeRight") val playable = curMedia if (playable is EpisodeMedia) { - val preferences = playable.episodeOrFetch()?.feed?.preferences - if (preferences != null) { - val volumeAdaptionSetting = preferences.volumeAdaptionSetting - val adaptionFactor = volumeAdaptionSetting.adaptionFactor + var adaptionFactor = 1f + if (playable.volumeAdaptionSetting != VolumeAdaptionSetting.OFF) adaptionFactor = playable.volumeAdaptionSetting.adaptionFactor + else { + val preferences = playable.episodeOrFetch()?.feed?.preferences + if (preferences != null) { + val volumeAdaptionSetting = preferences.volumeAdaptionSetting + adaptionFactor = volumeAdaptionSetting.adaptionFactor + } + } + if (adaptionFactor != 1f) { volumeLeft *= adaptionFactor volumeRight *= adaptionFactor } 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 db3d9830..14648e5a 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 @@ -18,6 +18,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybac import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo import ac.mdiq.podcini.playback.cast.CastMediaPlayer import ac.mdiq.podcini.playback.cast.CastStateListener +import ac.mdiq.podcini.playback.service.PlaybackService.TaskManager.Companion.positionUpdateInterval import ac.mdiq.podcini.preferences.SleepTimerPreferences import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableFrom @@ -28,6 +29,7 @@ import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence +import ac.mdiq.podcini.preferences.UserPreferences.prefAdaptiveProgressUpdate import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs import ac.mdiq.podcini.receiver.MediaButtonReceiver import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync @@ -369,7 +371,7 @@ class PlaybackService : MediaLibraryService() { val shouldAutoDelete = (action == AutoDeleteAction.ALWAYS || (action == AutoDeleteAction.GLOBAL && item?.feed != null && shouldAutoDeleteItem(item!!.feed!!))) if (playable is EpisodeMedia && shouldAutoDelete && (item?.isSUPER != true || !shouldFavoriteKeepEpisode)) { - item = deleteMediaSync(this@PlaybackService, item!!) + if (playable.localFileAvailable()) item = deleteMediaSync(this@PlaybackService, item!!) if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item!!) } } @@ -380,11 +382,12 @@ class PlaybackService : MediaLibraryService() { override fun onPlaybackStart(playable: Playable, position: Int) { Logd(TAG, "onPlaybackStart position: $position") - taskManager.startWidgetUpdater() - if (position != Playable.INVALID_TIME) playable.setPosition(position) + val delayInterval = positionUpdateInterval(playable.getDuration()) + taskManager.startWidgetUpdater(delayInterval) + if (position != Playable.INVALID_TIME) playable.setPosition(position + (delayInterval/2).toInt()) else skipIntro(playable) playable.onPlaybackStart() - taskManager.startPositionSaver() + taskManager.startPositionSaver(delayInterval) } override fun onPlaybackPause(playable: Playable?, position: Int) { @@ -1310,12 +1313,14 @@ class PlaybackService : MediaLibraryService() { get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone @Synchronized - fun startPositionSaver() { + fun startPositionSaver(delayInterval: Long) { if (!isPositionSaverActive) { var positionSaver = Runnable { callback.positionSaverTick() } positionSaver = useMainThreadIfNecessary(positionSaver) - positionSaverFuture = schedExecutor.scheduleWithFixedDelay( - positionSaver, POSITION_SAVER_WAITING_INTERVAL.toLong(), POSITION_SAVER_WAITING_INTERVAL.toLong(), TimeUnit.MILLISECONDS) +// val delayInterval = positionUpdateInterval(duration) +// positionSaverFuture = schedExecutor.scheduleWithFixedDelay( +// positionSaver, POSITION_SAVER_WAITING_INTERVAL.toLong(), POSITION_SAVER_WAITING_INTERVAL.toLong(), TimeUnit.MILLISECONDS) + positionSaverFuture = schedExecutor.scheduleWithFixedDelay(positionSaver, delayInterval, delayInterval, TimeUnit.MILLISECONDS) Logd(TAG, "Started PositionSaver") } else Logd(TAG, "Call to startPositionSaver was ignored.") } @@ -1329,12 +1334,14 @@ class PlaybackService : MediaLibraryService() { } @Synchronized - fun startWidgetUpdater() { + fun startWidgetUpdater(delayInterval: Long) { if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) { var widgetUpdater = Runnable { this.requestWidgetUpdate() } widgetUpdater = useMainThreadIfNecessary(widgetUpdater) - widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay( - widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), TimeUnit.MILLISECONDS) +// val delayInterval = positionUpdateInterval(duration) +// widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay( +// widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), TimeUnit.MILLISECONDS) + widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, delayInterval, delayInterval, TimeUnit.MILLISECONDS) Logd(TAG, "Started WidgetUpdater") } } @@ -1401,7 +1408,6 @@ class PlaybackService : MediaLibraryService() { fun startChapterLoader(media: Playable) { // chapterLoaderFuture?.dispose() // chapterLoaderFuture = null - if (!media.chaptersLoaded()) { val scope = CoroutineScope(Dispatchers.Main) scope.launch(Dispatchers.IO) { @@ -1423,7 +1429,6 @@ class PlaybackService : MediaLibraryService() { cancelPositionSaver() cancelWidgetUpdater() disableSleepTimer() - // chapterLoaderFuture?.dispose() // chapterLoaderFuture = null } @@ -1557,8 +1562,13 @@ class PlaybackService : MediaLibraryService() { private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000 // in millisoconds - const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds +// const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds const val NOTIFICATION_THRESHOLD: Long = 10000 // in millisoconds + + fun positionUpdateInterval(duration: Int): Long { + return if (prefAdaptiveProgressUpdate) max(POSITION_SAVER_WAITING_INTERVAL, duration/50).toLong() + else POSITION_SAVER_WAITING_INTERVAL.toLong() + } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt index 285f1253..90c56256 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt @@ -203,6 +203,12 @@ object UserPreferences { appPrefs.edit().putBoolean(Prefs.prefLowQualityOnMobile.name, stream).apply() } + var prefAdaptiveProgressUpdate: Boolean + get() = appPrefs.getBoolean(Prefs.prefUseAdaptiveProgressUpdate.name, false) + set(value) { + appPrefs.edit().putBoolean(Prefs.prefUseAdaptiveProgressUpdate.name, value).apply() + } + /** * Sets up the UserPreferences class. * @throws IllegalArgumentException if context is null @@ -313,6 +319,7 @@ object UserPreferences { prefStreamOverDownload, prefLowQualityOnMobile, prefSpeedforwardSpeed, + prefUseAdaptiveProgressUpdate, // Network prefEnqueueDownloaded, diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt index 4b607142..692fa7c8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/algorithms/AutoDownloads.kt @@ -126,6 +126,10 @@ object AutoDownloads { queryString += " AND playState <= ${PlayState.SOON.code} SORT(pubDate DESC) LIMIT(${3*allowedDLCount})" episodes = realm.query(Episode::class).query(queryString).find().toMutableList() } + FeedPreferences.AutoDownloadPolicy.SOON -> { + queryString += " AND playState == ${PlayState.SOON.code} SORT(pubDate DESC) LIMIT(${3*allowedDLCount})" + episodes = realm.query(Episode::class).query(queryString).find().toMutableList() + } FeedPreferences.AutoDownloadPolicy.OLDER -> { queryString += " AND playState <= ${PlayState.SOON.code} SORT(pubDate ASC) LIMIT(${3*allowedDLCount})" episodes = realm.query(Episode::class).query(queryString).find().toMutableList() @@ -136,7 +140,7 @@ object AutoDownloads { var count = 0 for (e in episodes) { if (isCurMedia(e.media)) continue - if (f.preferences?.autoDownloadFilter?.shouldAutoDownload(e) == true) { + if (f.preferences?.autoDownloadFilter?.meetsAutoDLCriteria(e) == true) { Logd(TAG, "autoDownloadEpisodeMedia add to cadidates: ${e.title} ${e.isDownloaded}") candidates.add(e) if (++count >= allowedDLCount) break diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt index 2dce6a37..05f70aaf 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt @@ -129,7 +129,7 @@ object Episodes { episode = upsertBlk(episode) { it.media?.setfileUrlOrNull(null) if (media.downloadUrl.isNullOrEmpty()) it.media = null - it.playState = PlayState.SKIPPED.code + if (it.playState < PlayState.SKIPPED.code) it.playState = PlayState.SKIPPED.code } EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(episode)) localDelete = true @@ -139,7 +139,7 @@ object Episodes { val mediaFile = File(url) if (!mediaFile.delete()) { Log.e(TAG, "delete media file failed: $url") - val evt = FlowEvent.MessageEvent(context.getString(R.string.delete_failed)) + val evt = FlowEvent.MessageEvent(context.getString(R.string.delete_failed_simple) + ": $url") EventFlow.postEvent(evt) return episode } @@ -147,7 +147,7 @@ object Episodes { it.media?.downloaded = false it.media?.setfileUrlOrNull(null) it.media?.hasEmbeddedPicture = false - it.playState = PlayState.SKIPPED.code + if (it.playState < PlayState.SKIPPED.code) it.playState = PlayState.SKIPPED.code if (media.downloadUrl.isNullOrEmpty()) it.media = null } EventFlow.postEvent(FlowEvent.EpisodePlayedEvent(episode)) 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 40b5e1f2..af28eb17 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 @@ -3,6 +3,7 @@ package ac.mdiq.podcini.storage.model import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk +import ac.mdiq.podcini.storage.model.VolumeAdaptionSetting.Companion.fromInteger import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.showStackTrace @@ -68,6 +69,16 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { var playedDurationWhenStarted: Int = 0 private set + @Ignore + var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF + get() = fromInteger(volumeAdaption) + set(value) { + field = value + volumeAdaption = field.toInteger() + } + @Ignore + var volumeAdaption: Int = 0 + // if null: unknown, will be checked // TODO: what to do with this? can be expensive @Ignore diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedAutoDownloadFilter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedAutoDownloadFilter.kt index 73e90cd0..25b4b400 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedAutoDownloadFilter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedAutoDownloadFilter.kt @@ -35,7 +35,7 @@ class FeedAutoDownloadFilter(val includeFilterRaw: String? = "", val excludeFilt * @param item * @return true if the item should be downloaded */ - fun shouldAutoDownload(item: Episode): Boolean { + fun meetsAutoDLCriteria(item: Episode): Boolean { // if (includeTerms == null) includeTerms = parseTerms(includeFilterRaw) // if (excludeTerms == null) excludeTerms = parseTerms(excludeFilterRaw) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt index c41a881e..45333d8e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt @@ -172,7 +172,8 @@ class FeedPreferences : EmbeddedRealmObject { enum class AutoDownloadPolicy(val code: Int, val resId: Int) { ONLY_NEW(0, R.string.feed_auto_download_new), NEWER(1, R.string.feed_auto_download_newer), - OLDER(2, R.string.feed_auto_download_older); + OLDER(2, R.string.feed_auto_download_older), + SOON(3, R.string.feed_auto_download_soon); companion object { fun fromCode(code: Int): AutoDownloadPolicy { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/VolumeAdaptionSetting.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/VolumeAdaptionSetting.kt index a3a1cce5..209949fd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/VolumeAdaptionSetting.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/VolumeAdaptionSetting.kt @@ -7,9 +7,6 @@ enum class VolumeAdaptionSetting(private val value: Int, @JvmField val adaptionF LIGHT_REDUCTION(1, 0.5f, R.string.feed_volume_reduction_light), HEAVY_REDUCTION(2, 0.2f, R.string.feed_volume_reduction_heavy), LIGHT_BOOST(3, 1.6f, R.string.feed_volume_boost_light), -// MEDIUM_BOOST(4, 2f, R.string.feed_volume_boost_medium), -// HEAVY_BOOST(5, 2.5f, R.string.feed_volume_boost_heavy); -// LIGHT_BOOST(3, 2f, R.string.feed_volume_boost_light), MEDIUM_BOOST(4, 2.4f, R.string.feed_volume_boost_medium), HEAVY_BOOST(5, 3.6f, R.string.feed_volume_boost_heavy); diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt index b286b417..4e75e2bb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt @@ -81,7 +81,8 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis abstract fun onClick(context: Context) - fun forItem(): EpisodeActionButton { + open fun forItem(item_: Episode): EpisodeActionButton { + item = item_ val media = item.media ?: return TTSActionButton(item) val isDownloadingMedia = when (media.downloadUrl) { null -> false @@ -189,6 +190,11 @@ class VisitWebsiteActionButton(item: Episode) : EpisodeActionButton(item) { if (!item.link.isNullOrEmpty()) IntentUtils.openInBrowser(context, item.link!!) actionState.value = getLabel() } + + override fun forItem(item_: Episode): EpisodeActionButton { + item = item_ + return this + } } class CancelDownloadActionButton(item: Episode) : EpisodeActionButton(item) { @@ -358,6 +364,21 @@ class DownloadActionButton(item: Episode) : EpisodeActionButton(item) { val isDownloading = DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false return isDownloading || media.downloaded } + +// override fun forItem(item_: Episode): EpisodeActionButton { +// item = item_ +// val media = item.media ?: return TTSActionButton(item) +// val isDownloadingMedia = when (media.downloadUrl) { +// null -> false +// else -> DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false +// } +// Logd("DownloadActionButton", "forItem: local feed: ${item.feed?.isLocalFeed} downloaded: ${media.downloaded} playing: ${isCurrentlyPlaying(media)} ${item.title} ") +// return when { +// media.downloaded -> PlayActionButton(item) +// isDownloadingMedia -> CancelDownloadActionButton(item) +// else -> DownloadActionButton(item) +// } +// } } class StreamActionButton(item: Episode) : EpisodeActionButton(item) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt index 391421b7..b0c06b96 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt @@ -20,16 +20,20 @@ interface SwipeAction { fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) - fun willRemove(filter: EpisodeFilter, item: Episode): Boolean + fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return false + } enum class ActionTypes { NO_ACTION, COMBO, ADD_TO_QUEUE, + PUT_TO_QUEUE, START_DOWNLOAD, MARK_FAV, - TOGGLE_PLAYED, SET_PLAY_STATE, + SHELVE, + ERASE, REMOVE_FROM_QUEUE, DELETE, REMOVE_FROM_HISTORY diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt index dbbc242d..2495a03c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt @@ -21,9 +21,7 @@ import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.ui.actions.SwipeAction.ActionTypes import ac.mdiq.podcini.ui.actions.SwipeAction.ActionTypes.NO_ACTION import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.compose.ChooseRatingDialog -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.compose.PlayStateDialog +import ac.mdiq.podcini.ui.compose.* import ac.mdiq.podcini.ui.fragment.* import ac.mdiq.podcini.ui.utils.LocalDeleteModal.deleteEpisodesWarnLocal import ac.mdiq.podcini.util.EventFlow @@ -77,22 +75,12 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) actions = getPrefs(tag) } -// override fun onStop(owner: LifecycleOwner) { -//// actions = null -// } - @JvmName("setFilterFunction") fun setFilter(filter: EpisodeFilter?) { this.filter = filter } fun showDialog() { -// SwipeActionsDialog(fragment.requireContext(), tag).show(object : SwipeActionsDialog.Callback { -// override fun onCall() { -// actions = getPrefs(tag) -// EventFlow.postEvent(FlowEvent.SwipeActionsChangedEvent()) -// } -// }) Logd("SwipeActions", "showDialog()") val composeView = ComposeView(fragment.requireContext()).apply { setContent { @@ -133,47 +121,34 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) override fun getId(): String { return ActionTypes.ADD_TO_QUEUE.name } - override fun getActionIcon(): Int { return R.drawable.ic_playlist_play } - override fun getActionColor(): Int { return androidx.appcompat.R.attr.colorAccent } - override fun getTitle(context: Context): String { return context.getString(R.string.add_to_queue_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { addToQueue(item) } - - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false -// return filter.showQueued || filter.showNew - } } class ComboSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.COMBO.name } - override fun getActionIcon(): Int { return R.drawable.baseline_category_24 } - override fun getActionColor(): Int { return androidx.appcompat.R.attr.colorAccent } - override fun getTitle(context: Context): String { - return context.getString(R.string.add_to_queue_label) + return context.getString(R.string.combo_action) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { val composeView = ComposeView(fragment.requireContext()).apply { @@ -210,36 +185,26 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } (fragment.view as? ViewGroup)?.addView(composeView) } - - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false -// return filter.showQueued || filter.showNew - } } class DeleteSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.DELETE.name } - override fun getActionIcon(): Int { return R.drawable.ic_delete } - override fun getActionColor(): Int { return R.attr.icon_red } - override fun getTitle(context: Context): String { return context.getString(R.string.delete_episode_label) } - @UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { if (!item.isDownloaded && item.feed?.isLocalFeed != true) return deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item)) } - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { return filter.showDownloaded && (item.isDownloaded || item.feed?.isLocalFeed == true) } @@ -249,19 +214,15 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) override fun getId(): String { return ActionTypes.MARK_FAV.name } - override fun getActionIcon(): Int { return R.drawable.ic_star } - override fun getActionColor(): Int { return R.attr.icon_yellow } - override fun getTitle(context: Context): String { return context.getString(R.string.switch_rating_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { var showChooseRatingDialog by mutableStateOf(true) @@ -277,36 +238,22 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } (fragment.view as? ViewGroup)?.addView(composeView) } - - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false -// return filter.showIsFavorite || filter.showNotFavorite - } } class NoActionSwipeAction : SwipeAction { override fun getId(): String { return NO_ACTION.name } - override fun getActionIcon(): Int { return R.drawable.ic_questionmark } - override fun getActionColor(): Int { return R.attr.icon_red } - override fun getTitle(context: Context): String { return context.getString(R.string.no_action_label) } - - @UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {} - - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false - } } class RemoveFromHistorySwipeAction : SwipeAction { @@ -315,19 +262,15 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) override fun getId(): String { return ActionTypes.REMOVE_FROM_HISTORY.name } - override fun getActionIcon(): Int { return R.drawable.ic_history_remove } - override fun getActionColor(): Int { return R.attr.icon_purple } - override fun getTitle(context: Context): String { return context.getString(R.string.remove_history_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { val playbackCompletionDate: Date? = item.media?.playbackCompletionDate @@ -339,11 +282,9 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) .setAction(fragment.getString(R.string.undo)) { if (playbackCompletionDate != null) setHistoryDates(item, lastPlayedDate?:0, playbackCompletionDate) } } - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { return true } - private fun setHistoryDates(episode: Episode, lastPlayed: Long = 0, completed: Date = Date(0)) { runOnIOScope { val episode_ = realm.query(Episode::class).query("id == $0", episode.id).first().find() @@ -362,19 +303,15 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) override fun getId(): String { return ActionTypes.REMOVE_FROM_QUEUE.name } - override fun getActionIcon(): Int { return R.drawable.ic_playlist_remove } - override fun getActionColor(): Int { return androidx.appcompat.R.attr.colorAccent } - override fun getTitle(context: Context): String { return context.getString(R.string.remove_from_queue_label) } - @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { val position: Int = curQueue.episodes.indexOf(item) @@ -386,11 +323,9 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } } } - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { return filter.showQueued || filter.showNotQueued } - /** * Inserts a Episode in the queue at the specified index. The 'read'-attribute of the Episode will be set to * true. If the Episode is already in the queue, the queue will not be modified. @@ -414,51 +349,68 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } } + class PutToQueueSwipeAction : SwipeAction { + override fun getId(): String { + return ActionTypes.PUT_TO_QUEUE.name + } + override fun getActionIcon(): Int { + return R.drawable.ic_playlist_play + } + override fun getActionColor(): Int { + return R.attr.icon_gray + } + override fun getTitle(context: Context): String { + return context.getString(R.string.put_in_queue_label) + } + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + var showPutToQueueDialog by mutableStateOf(true) + val composeView = ComposeView(fragment.requireContext()).apply { + setContent { + CustomTheme(fragment.requireContext()) { + if (showPutToQueueDialog ) PutToQueueDialog(listOf(item)) { + showPutToQueueDialog = false + (fragment.view as? ViewGroup)?.removeView(this@apply) + } + } + } + } + (fragment.view as? ViewGroup)?.addView(composeView) + } + } + class StartDownloadSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.START_DOWNLOAD.name } - override fun getActionIcon(): Int { return R.drawable.ic_download } - override fun getActionColor(): Int { return R.attr.icon_green } - override fun getTitle(context: Context): String { return context.getString(R.string.download_label) } - override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) { DownloadActionButton(item).onClick(fragment.requireContext()) } } - - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false - } } class SetPlaybackStateSwipeAction : SwipeAction { override fun getId(): String { return ActionTypes.SET_PLAY_STATE.name } - override fun getActionIcon(): Int { return R.drawable.ic_mark_played } - override fun getActionColor(): Int { return R.attr.icon_gray } - override fun getTitle(context: Context): String { return context.getString(R.string.set_play_state_label) } - override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { var showPlayStateDialog by mutableStateOf(true) val composeView = ComposeView(fragment.requireContext()).apply { @@ -473,21 +425,72 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } (fragment.view as? ViewGroup)?.addView(composeView) } - private fun delayedExecution(item: Episode, fragment: Fragment, duration: Float) = runBlocking { delay(ceil((duration * 1.05f).toDouble()).toLong()) val media: EpisodeMedia? = item.media val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!) if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) { // deleteMediaOfEpisode(fragment.requireContext(), item) - var item = deleteMediaSync(fragment.requireContext(), item) - if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) } + val item_ = deleteMediaSync(fragment.requireContext(), item) + if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item_) } } + } - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false -// return if (item.playState == PlayState.NEW.code) filter.showPlayed || filter.showNew -// else filter.showUnplayed || filter.showPlayed || filter.showNew + class ShelveSwipeAction : SwipeAction { + override fun getId(): String { + return ActionTypes.SHELVE.name + } + override fun getActionIcon(): Int { + return R.drawable.baseline_shelves_24 + } + override fun getActionColor(): Int { + return R.attr.icon_gray + } + override fun getTitle(context: Context): String { + return context.getString(R.string.shelve_label) + } + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + var showShelveDialog by mutableStateOf(true) + val composeView = ComposeView(fragment.requireContext()).apply { + setContent { + CustomTheme(fragment.requireContext()) { + if (showShelveDialog) ShelveDialog(listOf(item)) { + showShelveDialog = false + (fragment.view as? ViewGroup)?.removeView(this@apply) + } + } + } + } + (fragment.view as? ViewGroup)?.addView(composeView) + } + } + + class EraseSwipeAction : SwipeAction { + override fun getId(): String { + return ActionTypes.ERASE.name + } + override fun getActionIcon(): Int { + return R.drawable.baseline_delete_forever_24 + } + override fun getActionColor(): Int { + return R.attr.icon_gray + } + override fun getTitle(context: Context): String { + return context.getString(R.string.erase_episodes_label) + } + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + var showEraseDialog by mutableStateOf(true) + val composeView = ComposeView(fragment.requireContext()).apply { + setContent { + CustomTheme(fragment.requireContext()) { + if (showEraseDialog) EraseEpisodesDialog(listOf(item), item.feed) { + showEraseDialog = false + (fragment.view as? ViewGroup)?.removeView(this@apply) + } + } + } + } + (fragment.view as? ViewGroup)?.addView(composeView) } } @@ -503,10 +506,12 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) @JvmField val swipeActions: List = listOf( - NoActionSwipeAction(), ComboSwipeAction(), AddToQueueSwipeAction(), + NoActionSwipeAction(), ComboSwipeAction(), + AddToQueueSwipeAction(), PutToQueueSwipeAction(), StartDownloadSwipeAction(), SetRatingSwipeAction(), SetPlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(), - DeleteSwipeAction(), RemoveFromHistorySwipeAction()) + DeleteSwipeAction(), RemoveFromHistorySwipeAction(), + ShelveSwipeAction(), EraseSwipeAction()) private fun getPrefs(tag: String, defaultActions: String): Actions { val prefsString = prefs!!.getString(KEY_PREFIX_SWIPEACTIONS + tag, defaultActions) @@ -609,9 +614,9 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } QueuesFragment.TAG -> { forFragment = stringResource(R.string.queue_label) -// keys = Stream.of(keys).filter { a: SwipeAction -> -// (!a.getId().equals(SwipeAction.ADD_TO_QUEUE) && !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY)) }.toList() - keys = keys.filter { a: SwipeAction -> (!a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name)) } + keys = keys.filter { a: SwipeAction -> + (!a.getId().equals(ActionTypes.ADD_TO_QUEUE.name) && !a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name)) }.toList() +// keys = keys.filter { a: SwipeAction -> (!a.getId().equals(ActionTypes.REMOVE_FROM_HISTORY.name)) } } HistoryFragment.TAG -> { forFragment = stringResource(R.string.playback_history_label) 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 a2685a31..58f65ef4 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 @@ -65,6 +65,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -366,16 +367,18 @@ fun ShelveDialog(selected: List, onDismissRequest: () -> Unit) { .padding(16.dp), verticalArrangement = Arrangement.spacedBy(1.dp)) { var removeChecked by remember { mutableStateOf(false) } var toFeed by remember { mutableStateOf(null) } - for (f in synthetics) { - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = toFeed == f, onClick = { toFeed = f }) - Text(f.title ?: "No title") + if (synthetics.isNotEmpty()) { + for (f in synthetics) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = toFeed == f, onClick = { toFeed = f }) + Text(f.title ?: "No title") + } } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = removeChecked, onCheckedChange = { removeChecked = it }) - Text(text = stringResource(R.string.remove_from_current_feed), style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 10.dp)) - } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = removeChecked, onCheckedChange = { removeChecked = it }) + Text(text = stringResource(R.string.remove_from_current_feed), style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 10.dp)) + } + } else Text(text = stringResource(R.string.create_synthetic_first_note)) if (toFeed != null) Row { Spacer(Modifier.weight(1f)) Button(onClick = { @@ -413,6 +416,58 @@ fun ShelveDialog(selected: List, onDismissRequest: () -> Unit) { } } +@Composable +fun EraseEpisodesDialog(selected: List, feed: Feed?, onDismissRequest: () -> Unit) { + val message = stringResource(R.string.erase_episodes_confirmation_msg) + val textColor = MaterialTheme.colorScheme.onSurface + var textState by remember { mutableStateOf(TextFieldValue("")) } + val context = LocalContext.current + + Dialog(onDismissRequest = onDismissRequest) { + Surface(shape = RoundedCornerShape(16.dp)) { + if (feed == null || feed.id > MAX_SYNTHETIC_ID) Text(stringResource(R.string.not_erase_message), modifier = Modifier.padding(10.dp)) + else Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(message + ": ${selected.size}") + Text(stringResource(R.string.feed_delete_reason_msg)) + BasicTextField(value = textState, onValueChange = { textState = it }, textStyle = TextStyle(fontSize = 16.sp, color = textColor), + modifier = Modifier.fillMaxWidth().height(100.dp).padding(start = 10.dp, end = 10.dp, bottom = 10.dp) + .border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) + ) + Button(onClick = { + CoroutineScope(Dispatchers.IO).launch { + try { + for (e in selected) { + val sLog = SubscriptionLog(e.id, e.title?:"", e.media?.downloadUrl?:"", e.link?:"", SubscriptionLog.Type.Media.name) + upsert(sLog) { + it.rating = e.rating + it.comment = e.comment + it.comment += "\nReason to remove:\n" + textState.text + it.cancelDate = Date().time + } + } + realm.write { + for (e in selected) { + val url = e.media?.fileUrl + when { + url != null && url.startsWith("content://") -> DocumentFile.fromSingleUri(context, Uri.parse(url))?.delete() + url != null -> File(url).delete() + } + findLatest(feed)?.episodes?.remove(e) + findLatest(e)?.let { delete(it) } + } + } + EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false)) + } catch (e: Throwable) { Log.e("EraseEpisodesDialog", Log.getStackTraceString(e)) } + } + onDismissRequest() + }) { + Text("Confirm") + } + } + } + } +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList, feed: Feed? = null, @@ -447,59 +502,8 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList, feed: var showShelveDialog by remember { mutableStateOf(false) } if (showShelveDialog) ShelveDialog(selected) { showShelveDialog = false } - @Composable - fun EraseEpisodesDialog(onDismissRequest: () -> Unit) { - val message = stringResource(R.string.erase_episodes_confirmation_msg) - val textColor = MaterialTheme.colorScheme.onSurface - var textState by remember { mutableStateOf(TextFieldValue("")) } - - Dialog(onDismissRequest = onDismissRequest) { - Surface(shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(message + ": ${selected.size}") - Text(stringResource(R.string.feed_delete_reason_msg)) - BasicTextField(value = textState, onValueChange = { textState = it }, - textStyle = TextStyle(fontSize = 16.sp, color = textColor), - modifier = Modifier.fillMaxWidth().height(100.dp).padding(start = 10.dp, end = 10.dp, bottom = 10.dp) - .border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) - ) - Button(onClick = { - CoroutineScope(Dispatchers.IO).launch { - try { - for (e in selected) { - val sLog = SubscriptionLog(e.id, e.title?:"", e.media?.downloadUrl?:"", e.link?:"", SubscriptionLog.Type.Media.name) - upsert(sLog) { - it.rating = e.rating - it.comment = e.comment - it.comment += "\nReason to remove:\n" + textState.text - it.cancelDate = Date().time - } - } - realm.write { - for (e in selected) { - val url = e.media?.fileUrl - when { - url != null && url.startsWith("content://") -> DocumentFile.fromSingleUri(context, Uri.parse(url))?.delete() - url != null -> File(url).delete() - } - findLatest(feed!!)?.episodes?.remove(e) - findLatest(e)?.let { delete(it) } - } - } - EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false)) - } catch (e: Throwable) { Log.e("EraseEpisodesDialog", Log.getStackTraceString(e)) } - } - onDismissRequest() - }) { - Text("Confirm") - } - } - } - } - } - var showEraseDialog by remember { mutableStateOf(false) } - if (showEraseDialog) EraseEpisodesDialog(onDismissRequest = { showEraseDialog = false }) + if (showEraseDialog && feed != null) EraseEpisodesDialog(selected, feed, onDismissRequest = { showEraseDialog = false }) @Composable fun EpisodeSpeedDial(modifier: Modifier = Modifier) { @@ -627,7 +631,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList, feed: showEraseDialog = true Logd(TAG, "reserve: ${selected.size}") }, verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.AddCircle, "Erase episodes") + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.baseline_delete_forever_24), "Erase episodes") Text(stringResource(id = R.string.erase_episodes_label)) } } @@ -722,13 +726,14 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList, feed: // Logd(TAG, "info row") val ratingIconRes = Rating.fromCode(vm.ratingState).res if (vm.ratingState != Rating.UNRATED.code) - Icon(imageVector = ImageVector.vectorResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(18.dp).height(18.dp)) + Icon(imageVector = ImageVector.vectorResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(16.dp).height(16.dp)) val playStateRes = PlayState.fromCode(vm.playedState).res - Icon(imageVector = ImageVector.vectorResource(playStateRes), tint = textColor, contentDescription = "playState", modifier = Modifier.width(18.dp).height(18.dp)) + Icon(imageVector = ImageVector.vectorResource(playStateRes), tint = textColor, contentDescription = "playState", modifier = Modifier.width(16.dp).height(16.dp)) // if (vm.inQueueState) -// Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(18.dp).height(18.dp)) +// Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(16.dp).height(16.dp)) if (vm.episode.media?.getMediaType() == MediaType.VIDEO) - Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(18.dp).height(18.dp)) + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(16.dp).height(16.dp)) val curContext = LocalContext.current val dur = remember { vm.episode.media?.getDuration() ?: 0 } val durText = remember { DurationConverter.getDurationStringLong(dur) } @@ -738,7 +743,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList, feed: } Text(vm.episode.title ?: "", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis) } - var actionButton by remember(vm.episode.id) { mutableStateOf(vm.actionButton.forItem()) } + var actionButton by remember(vm.episode.id) { mutableStateOf(vm.actionButton.forItem(vm.episode)) } fun isDownloading(): Boolean { return vms[index].downloadState > DownloadStatus.State.UNKNOWN.ordinal && vms[index].downloadState < DownloadStatus.State.COMPLETED.ordinal } @@ -748,7 +753,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: MutableList, feed: if (isDownloading()) vm.dlPercent = dls?.getProgress(vms[index].episode.media?.downloadUrl ?: "") ?: 0 Logd(TAG, "LaunchedEffect $index isPlayingState: ${vms[index].isPlayingState} ${vms[index].episode.title}") Logd(TAG, "LaunchedEffect $index downloadState: ${vms[index].downloadState} ${vm.episode.media?.downloaded} ${vm.dlPercent}") - vm.actionButton = vm.actionButton.forItem() + vm.actionButton = vm.actionButton.forItem(vm.episode) if (vm.actionButton.getLabel() != actionButton.getLabel()) actionButton = vm.actionButton } } else { 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 23cfa5c8..b82973ae 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 @@ -58,10 +58,8 @@ import android.view.ViewGroup import android.widget.Toast import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,11 +67,13 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog import androidx.compose.ui.zIndex import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat @@ -310,7 +310,8 @@ class AudioPlayerFragment : Fragment() { }, onValueChangeFinished = { Logd(TAG, "Slider onValueChangeFinished: $sliderValue") currentPosition = sliderValue.toInt() - if (playbackService?.isServiceReady() == true) seekTo(currentPosition) +// if (playbackService?.isServiceReady() == true) seekTo(currentPosition) + seekTo(currentPosition) }) Row { Text(DurationConverter.getDurationStringLong(currentPosition), color = textColor, style = MaterialTheme.typography.bodySmall) @@ -335,6 +336,45 @@ class AudioPlayerFragment : Fragment() { } } + @Composable + fun VolumeAdaptionDialog(showDialog: Boolean, onDismissRequest: () -> Unit) { + if (showDialog) { + val (selectedOption, onOptionSelected) = remember { mutableStateOf((currentMedia as? EpisodeMedia)?.volumeAdaptionSetting ?: VolumeAdaptionSetting.OFF) } + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column { + VolumeAdaptionSetting.entries.forEach { item -> + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = (item == selectedOption), + onCheckedChange = { _ -> + Logd(TAG, "row clicked: $item $selectedOption") + if (item != selectedOption) { + onOptionSelected(item) +// currentItem = upsertBlk(currentItem!!) { +// it.media?.volumeAdaptionSetting = item +// } + if (currentMedia is EpisodeMedia) { + (currentMedia as? EpisodeMedia)?.volumeAdaptionSetting = item + currentMedia = currentItem!!.media + curMedia = currentMedia + playbackService?.mPlayer?.pause(false, reinit = true) + playbackService?.mPlayer?.resume() + } + onDismissRequest() + } + } + ) + Text(text = stringResource(item.resId), style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 16.dp)) + } + } + } + } + } + } + } + } + @Composable fun Toolbar() { val media: Playable = curMedia ?: return @@ -342,6 +382,8 @@ class AudioPlayerFragment : Fragment() { val textColor = MaterialTheme.colorScheme.onSurface val mediaType = curMedia?.getMediaType() val notAudioOnly = (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY + var showVolumeDialog by remember { mutableStateOf(false) } + if (showVolumeDialog) VolumeAdaptionDialog(showVolumeDialog, onDismissRequest = { showVolumeDialog = false }) Row(modifier = Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_down), tint = textColor, contentDescription = "Collapse", modifier = Modifier.clickable { (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) @@ -380,6 +422,11 @@ class AudioPlayerFragment : Fragment() { shareDialog.show((requireActivity().supportFragmentManager), "ShareEpisodeDialog") } }) + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_volume_adaption), tint = textColor, contentDescription = "Volume adaptation", modifier = Modifier.clickable { + if (currentItem != null) { + showVolumeDialog = true + } + }) Icon(imageVector = ImageVector.vectorResource(R.drawable.baseline_offline_share_24), tint = textColor, contentDescription = "Share Note", modifier = Modifier.clickable { val notes = if (showHomeText) readerhtml else feedItem?.description if (!notes.isNullOrEmpty()) { @@ -547,7 +594,6 @@ class AudioPlayerFragment : Fragment() { fun updateUi(media: Playable) { Logd(TAG, "updateUi called $media") titleText = media.getEpisodeTitle() - onPositionUpdate(FlowEvent.PlaybackPositionEvent(media, media.getPosition(), media.getDuration())) if (prevMedia?.getIdentifier() != media.getIdentifier()) imgLoc = ImageResourceUtils.getEpisodeListImageLocation(media) if (isPlayingVideoLocally && (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { // (activity as MainActivity).bottomSheet.setLocked(true) @@ -722,7 +768,7 @@ class AudioPlayerFragment : Fragment() { isCollapsed = false if (shownotesCleaner == null) shownotesCleaner = ShownotesCleaner(requireContext()) // showPlayer1 = false - if (currentMedia != null) updateUi(currentMedia!!) +// if (currentMedia != null) updateUi(currentMedia!!) setIsShowPlay(isShowPlay) updateDetails() // } @@ -732,7 +778,7 @@ class AudioPlayerFragment : Fragment() { Logd(TAG, "onCollaped()") isCollapsed = true // showPlayer1 = true - if (currentMedia != null) updateUi(currentMedia!!) +// if (currentMedia != null) updateUi(currentMedia!!) setIsShowPlay(isShowPlay) } @@ -821,15 +867,16 @@ class AudioPlayerFragment : Fragment() { } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - retainInstance = true - } +// override fun onCreate(savedInstanceState: Bundle?) { +// super.onCreate(savedInstanceState) +// retainInstance = true +// } override fun onResume() { Logd(TAG, "onResume() isCollapsed: $isCollapsed") super.onResume() loadMediaInfo() + if (curMedia != null) onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia!!, curMedia!!.getPosition(), curMedia!!.getDuration())) } override fun onStart() { @@ -844,7 +891,7 @@ class AudioPlayerFragment : Fragment() { //// Logd(TAG, "controllerFuture.addListener: $mediaController") // }, MoreExecutors.directExecutor()) - loadMediaInfo() +// loadMediaInfo() } override fun onStop() { @@ -883,6 +930,7 @@ class AudioPlayerFragment : Fragment() { val currentitem = event.episode if (currentMedia?.getIdentifier() == null || currentitem.media?.getIdentifier() != currentMedia?.getIdentifier()) { currentMedia = currentitem.media + updateUi(currentMedia!!) setItem(currentitem) } (activity as MainActivity).setPlayerVisible(true) diff --git a/app/src/main/res/drawable/baseline_delete_forever_24.xml b/app/src/main/res/drawable/baseline_delete_forever_24.xml new file mode 100644 index 00000000..2c71fc6e --- /dev/null +++ b/app/src/main/res/drawable/baseline_delete_forever_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa1db407..a26c920f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,10 +165,12 @@ Only new items Newest unplayed Oldest unplayed + Marked as Soon Add to queue… Remove from other queues + You need to create some synthetic feeds first Remove from current feed Nothing @@ -223,6 +225,7 @@ Please confirm that you want to remove the selected podcasts, ALL their episodes (including downloaded episodes), and its statistics. Please confirm that you want to remove the podcast \"%1$s\" and its statistics. The files in the local source folder will not be deleted. Removing podcast + Only episodes from synthetic feeds can be erased. For future reference, you can record a reason here: Refresh complete podcast Multi select @@ -258,6 +261,7 @@ Reserve episodes Null Delete + Unable to delete file. Unable to delete file. Rebooting the device could help. Unable to delete file. Try re-connecting the local folder from the podcast info screen. Delete episode media @@ -280,6 +284,7 @@ %d episodes marked as favorite. + Combo action Shelve to synthetic Add to active queue @@ -521,6 +526,8 @@ Display stream button instead of download button in lists Prefer low quality on mobile On metered network, only low quality media (if available) is fetched + Update progress adaptively + Update the progress in an adaptive interval set as the higher of 5 seconds or 2 percent of the media duration. This saves some energy. Otherwise it\'s updated every 5 seconds. Metered network settings Select what should be allowed over the mobile data connection Podcast refresh diff --git a/app/src/main/res/xml/preferences_playback.xml b/app/src/main/res/xml/preferences_playback.xml index 759cb1ce..1bdf76e1 100644 --- a/app/src/main/res/xml/preferences_playback.xml +++ b/app/src/main/res/xml/preferences_playback.xml @@ -66,6 +66,11 @@ android:key="prefLowQualityOnMobile" android:summary="@string/pref_low_quality_on_mobile_sum" android:title="@string/pref_low_quality_on_mobile_title"/> + Playback->"Update progress adaptively", default to true + * unadaptive interval, same as the previous, is 2 seconds +* added volume adaptation control to player detailed view to set for current media and it takes precedence over that in feed settings +* tuned the AudioPlayer fragment +* added a few new actions to swipe, bring it essentially equivalent to multi-select menus +* added auto-download policy: "Marked as Soon" +* when deleting media file, set the playState to Skipped only if the current state is lower than Skipped +* during cast to speaker (in the Play app), tap on the position bar in the PlayerUI changes the position +* avoided the snack message "can't delete file ..." after streaming an episode +* fixed (again, sorry) the action button not updating issue after download in episodes lists +* google cast framework is updated to 22.0 (in the Play apk) + # 6.12.8 * set episode's playState to Skipped when its media file is removed diff --git a/fastlane/metadata/android/en-US/changelogs/3020287.txt b/fastlane/metadata/android/en-US/changelogs/3020287.txt new file mode 100644 index 00000000..81f0ef6a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020287.txt @@ -0,0 +1,12 @@ + Version 6.13.0 + +* updates playback position adaptively (in app and in widget) in an interval being the longer of 5 seconds and 2 percent of the media duration + * this can be enabled/disabled in Settings->Playback->"Update progress adaptively", default to true + * unadaptive interval, same as the previous, is 2 seconds +* added volume adaptation control to player detailed view to set for current media and it takes precedence over that in feed settings +* added a few new actions to swipe, bring it essentially equivalent to multi-select menus +* added auto-download policy: "Marked as Soon" +* when deleting media file, set the playState to Skipped only if the current state is lower than Skipped +* avoided the snack message "can't delete file ..." after streaming an episode +* fixed (again, sorry) the action button not updating issue after download in episodes lists +* tuned the AudioPlayer fragment diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c392a6b..e590a954 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,7 +52,7 @@ okhttpUrlconnection = "4.12.0" okio = "3.9.0" paletteKtx = "1.0.0" playServicesBase = "18.5.0" -playServicesCastFramework = "21.5.0" +playServicesCastFramework = "22.0.0" preferenceKtx = "1.2.1" readability4j = "1.0.8" recyclerview = "1.3.2"