From b2501d6f2b2b8f5b8f815b58656c9dcaf3e6d933 Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:32:28 +0100 Subject: [PATCH] 6.13.6 commit --- app/build.gradle | 5 +- .../net/download/service/HttpDownloader.kt | 1 - .../podcini/net/feed/FeedUpdateManager.kt | 151 +++++++++-- .../podcini/playback/base/LocalMediaPlayer.kt | 147 +---------- .../podcini/playback/base/MediaPlayerBase.kt | 154 +++++++++++- .../mdiq/podcini/storage/database/Episodes.kt | 25 +- .../ac/mdiq/podcini/storage/database/Feeds.kt | 80 +++++- .../mdiq/podcini/storage/database/RealmDB.kt | 2 +- .../ac/mdiq/podcini/storage/model/Feed.kt | 6 +- .../podcini/storage/model/FeedPreferences.kt | 40 ++- .../storage/model/VolumeAdaptionSetting.kt | 2 +- .../ac/mdiq/podcini/ui/compose/EpisodesVM.kt | 5 +- .../ui/fragment/FeedEpisodesFragment.kt | 4 +- .../ui/fragment/FeedSettingsFragment.kt | 234 +++++++++++------- .../ui/fragment/SubscriptionsFragment.kt | 7 +- app/src/main/res/values/strings.xml | 4 + .../podcini/playback/cast/CastMediaPlayer.kt | 63 ++++- changelog.md | 13 + .../android/en-US/changelogs/3020293.txt | 12 + gradle/libs.versions.toml | 2 - 20 files changed, 647 insertions(+), 310 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/3020293.txt diff --git a/app/build.gradle b/app/build.gradle index 5df002c6..95d16086 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,8 +26,8 @@ android { vectorDrawables.useSupportLibrary false vectorDrawables.generatedDensities = [] - versionCode 3020292 - versionName "6.13.5" + versionCode 3020293 + versionName "6.13.6" applicationId "ac.mdiq.podcini.R" def commit = "" @@ -239,7 +239,6 @@ dependencies { implementation libs.searchpreference implementation libs.balloon -// implementation libs.stream implementation libs.fyydlin diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/HttpDownloader.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/HttpDownloader.kt index bde05cc1..87565aab 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/HttpDownloader.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/HttpDownloader.kt @@ -131,7 +131,6 @@ class HttpDownloader(request: DownloadRequest) : Downloader(request) { Logd(TAG, "Starting download") try { while (!cancelled && (connection.read(buffer).also { count = it }) != -1) { - // Log.d(TAG,"buffer: $buffer") out.write(buffer, 0, count) downloadRequest.soFar += count val progressPercent = (100.0 * downloadRequest.soFar / downloadRequest.size).toInt() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt index 7e90e0fd..dea355ac 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt @@ -12,6 +12,7 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.isFeedRefreshAllowed import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi import ac.mdiq.podcini.net.utils.NetworkUtils.networkAvailable +import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia @@ -27,6 +28,7 @@ import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.config.ClientConfigurator +import ac.mdiq.vista.extractor.InfoItem import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.channel.ChannelInfo import ac.mdiq.vista.extractor.channel.tabs.ChannelTabInfo @@ -50,6 +52,8 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.types.RealmList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.xml.sax.SAXException import java.io.File import java.io.IOException @@ -67,6 +71,7 @@ object FeedUpdateManager { private const val WORK_ID_FEED_UPDATE_MANUAL = "feedUpdateManual" const val EXTRA_FEED_ID: String = "feed_id" const val EXTRA_NEXT_PAGE: String = "next_page" + const val EXTRA_FULL_UPDATE: String = "full_update" const val EXTRA_EVEN_ON_MOBILE: String = "even_on_mobile" private val updateInterval: Long @@ -93,7 +98,7 @@ object FeedUpdateManager { @JvmStatic @JvmOverloads - fun runOnce(context: Context, feed: Feed? = null, nextPage: Boolean = false) { + fun runOnce(context: Context, feed: Feed? = null, nextPage: Boolean = false, fullUpdate: Boolean = false) { val workRequest: OneTimeWorkRequest.Builder = OneTimeWorkRequest.Builder(FeedUpdateWorker::class.java) .setInitialDelay(0L, TimeUnit.MILLISECONDS) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) @@ -102,6 +107,7 @@ object FeedUpdateManager { val builder = Data.Builder() builder.putBoolean(EXTRA_EVEN_ON_MOBILE, true) + if (fullUpdate) builder.putBoolean(EXTRA_FULL_UPDATE, true) if (feed != null) { builder.putLong(EXTRA_FEED_ID, feed.id) builder.putBoolean(EXTRA_NEXT_PAGE, nextPage) @@ -112,12 +118,12 @@ object FeedUpdateManager { @JvmStatic @JvmOverloads - fun runOnceOrAsk(context: Context, feed: Feed? = null) { + fun runOnceOrAsk(context: Context, feed: Feed? = null, fullUpdate: Boolean = false) { Logd(TAG, "Run auto update immediately in background.") when { - feed != null && feed.isLocalFeed -> runOnce(context, feed) + feed != null && feed.isLocalFeed -> runOnce(context, feed, fullUpdate = fullUpdate) !networkAvailable() -> EventFlow.postEvent(FlowEvent.MessageEvent(context.getString(R.string.download_error_no_connection))) - isFeedRefreshAllowed -> runOnce(context, feed) + isFeedRefreshAllowed -> runOnce(context, feed, fullUpdate = fullUpdate) else -> confirmMobileRefresh(context, feed) } } @@ -169,7 +175,8 @@ object FeedUpdateManager { return Result.retry() } } - refreshFeeds(feedsToUpdate, force) + val fullUpdate = inputData.getBoolean(EXTRA_FULL_UPDATE, false) + refreshFeeds(feedsToUpdate, force, fullUpdate) notificationManager.cancel(R.id.notification_updating_feeds) autodownloadEpisodeMedia(applicationContext, feedsToUpdate.toList()) feedsToUpdate.clear() @@ -197,7 +204,7 @@ object FeedUpdateManager { return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null))) } - private fun refreshFeeds(feedsToUpdate: MutableList, force: Boolean) { + private fun refreshFeeds(feedsToUpdate: MutableList, force: Boolean, fullUpdate: Boolean) { if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { // TODO: Consider calling @@ -222,8 +229,8 @@ object FeedUpdateManager { Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}") when { feed.isLocalFeed -> LocalFeedUpdater.updateFeed(feed, applicationContext, null) - feed.type == Feed.FeedType.YOUTUBE.name -> refreshYoutubeFeed(feed) - else -> refreshFeed(feed, force) + feed.type == Feed.FeedType.YOUTUBE.name -> refreshYoutubeFeed(feed, fullUpdate) + else -> refreshFeed(feed, force, fullUpdate) } } catch (e: Exception) { Logd(TAG, "update failed ${e.message}") @@ -234,23 +241,44 @@ object FeedUpdateManager { titles.removeAt(0) } } - private fun refreshYoutubeFeed(feed: Feed) { + private fun refreshYoutubeFeed(feed: Feed, fullUpdate: Boolean) { if (feed.downloadUrl.isNullOrEmpty()) return - val url = feed.downloadUrl + val url = feed.downloadUrl!! + val newestItem = feed.mostRecentItem + val oldestItem = feed.oldestItem try { - val service = try { Vista.getService("YouTube") - } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") } - + val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") } val uURL = URL(url) if (uURL.path.startsWith("/channel")) { - val channelInfo = ChannelInfo.getInfo(service, url!!) + val channelInfo = ChannelInfo.getInfo(service, url) Logd(TAG, "refreshYoutubeFeed channelInfo: $channelInfo ${channelInfo.tabs.size}") if (channelInfo.tabs.isEmpty()) return try { val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) Logd(TAG, "refreshYoutubeFeed result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") + var infoItems = channelTabInfo.relatedItems + var nextPage = channelTabInfo.nextPage val eList: RealmList = realmListOf() - for (r in channelTabInfo.relatedItems) eList.add(episodeFromStreamInfoItem(r as StreamInfoItem)) + while (infoItems.isNotEmpty()) { + for (r_ in infoItems) { + val r = r_ as StreamInfoItem + if (r.infoType != InfoItem.InfoType.STREAM) continue + Logd(TAG, "item: ${r.uploadDate?.date()?.time} ${r.name}") + if ((r.uploadDate?.date()?.time ?: Date(0)) > (newestItem?.getPubDate() ?: Date(0))) + eList.add(episodeFromStreamInfoItem(r)) + else nextPage = null + } + if (nextPage == null) break + try { + val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage) + nextPage = page.nextPage + infoItems = page.items + Logd(TAG, "refreshYoutubeFeed more infoItems: ${infoItems.size}") + } catch (e: Throwable) { + Logd(TAG, "refreshYoutubeFeed ChannelTabInfo.getMoreItems error: ${e.message}") + break + } + } val feed_ = Feed(url, null) feed_.type = Feed.FeedType.YOUTUBE.name feed_.hasVideoMedia = true @@ -260,13 +288,32 @@ object FeedUpdateManager { feed_.author = channelInfo.parentChannelName feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null feed_.episodes = eList - Feeds.updateFeed(applicationContext, feed_, false) + if (fullUpdate) Feeds.updateFeed(applicationContext, feed_) else Feeds.updateFeedSimple(feed_) } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed channel error1 ${e.message}") } } else if (uURL.path.startsWith("/playlist")) { - val playlistInfo = PlaylistInfo.getInfo(Vista.getService(0), url) ?: return + val playlistInfo = PlaylistInfo.getInfo(service, url) ?: return val eList: RealmList = realmListOf() try { - for (r in playlistInfo.relatedItems) eList.add(episodeFromStreamInfoItem(r)) + var infoItems = playlistInfo.relatedItems + var nextPage = playlistInfo.nextPage + while (infoItems.isNotEmpty()) { + for (r in infoItems) { + if (r.infoType != InfoItem.InfoType.STREAM) continue + if ((r.uploadDate?.date()?.time ?: Date(0)) > (newestItem?.getPubDate() ?: Date(0))) + eList.add(episodeFromStreamInfoItem(r)) + else nextPage = null + } + if (nextPage == null) break + try { + val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break + nextPage = page.nextPage + infoItems = page.items + Logd(TAG, "more infoItems: ${infoItems.size}") + } catch (e: Throwable) { + Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}") + break + } + } val feed_ = Feed(url, null) feed_.type = Feed.FeedType.YOUTUBE.name feed_.hasVideoMedia = true @@ -276,14 +323,68 @@ object FeedUpdateManager { feed_.author = playlistInfo.uploaderName feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null feed_.episodes = eList - Feeds.updateFeed(applicationContext, feed_, false) + if (fullUpdate) Feeds.updateFeed(applicationContext, feed_) else Feeds.updateFeedSimple(feed_) } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed playlist error1 ${e.message}") } + } else { + val pathSegments = uURL.path.split("/") + val channelUrl = "https://www.youtube.com/channel/${pathSegments[1]}" + Logd(TAG, "channelUrl: $channelUrl") + val channelInfo = ChannelInfo.getInfo(service, channelUrl) + Logd(TAG, "refreshYoutubeFeed channelInfo: $channelInfo ${channelInfo.tabs.size}") + if (channelInfo.tabs.isEmpty()) return + var index = -1 + for (i in channelInfo.tabs.indices) { + val url_ = prepareUrl(channelInfo.tabs[i].url) + if (feed.downloadUrl == url_) { + index = i + break + } + } + if (index < 0) return + try { + val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs[index]) + Logd(TAG, "refreshYoutubeFeed result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") + var infoItems = channelTabInfo.relatedItems + var nextPage = channelTabInfo.nextPage + val eList: RealmList = realmListOf() + while (infoItems.isNotEmpty()) { + for (r_ in infoItems) { + val r = r_ as StreamInfoItem + if (r.infoType != InfoItem.InfoType.STREAM) continue + Logd(TAG, "item: ${r.uploadDate?.date()?.time} ${r.name}") + if ((r.uploadDate?.date()?.time ?: Date(0)) > (newestItem?.getPubDate() ?: Date(0))) + eList.add(episodeFromStreamInfoItem(r)) + else nextPage = null + } + if (nextPage == null) break + try { + val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs[index], nextPage) + nextPage = page.nextPage + infoItems = page.items + Logd(TAG, "refreshYoutubeFeed more infoItems: ${infoItems.size}") + } catch (e: Throwable) { + Logd(TAG, "refreshYoutubeFeed ChannelTabInfo.getMoreItems error: ${e.message}") + break + } + } + Logd(TAG, "refreshYoutubeFeed eList.size: ${eList.size}") + val feed_ = Feed(url, null) + feed_.type = Feed.FeedType.YOUTUBE.name + feed_.hasVideoMedia = true + feed_.title = channelInfo.name + feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() + feed_.description = channelInfo.description + feed_.author = channelInfo.parentChannelName + feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null + feed_.episodes = eList + if (fullUpdate) Feeds.updateFeed(applicationContext, feed_) else Feeds.updateFeedSimple(feed_) + } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed channel error2 ${e.message}") } } } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed error ${e.message}") } } @Throws(Exception::class) - fun refreshFeed(feed: Feed, force: Boolean) { + fun refreshFeed(feed: Feed, force: Boolean, fullUpdate: Boolean) { val nextPage = (inputData.getBoolean(EXTRA_NEXT_PAGE, false) && feed.nextPageLink != null) if (nextPage) feed.pageNr += 1 val builder = create(feed) @@ -300,7 +401,7 @@ object FeedUpdateManager { return } val feedUpdateTask = FeedUpdateTask(applicationContext, request) - val success = feedUpdateTask.run() + val success = if (fullUpdate) feedUpdateTask.run() else feedUpdateTask.runSimple() if (!success) { Logd(TAG, "update failed: unsuccessful") Feeds.persistFeedLastUpdateFailed(feed, true) @@ -423,6 +524,14 @@ object FeedUpdateManager { Feeds.updateFeed(context, feedHandlerResult!!.feed, false) return true } + + fun runSimple(): Boolean { + feedHandlerResult = task.call() + if (!task.isSuccessful) return false + Feeds.updateFeedSimple(feedHandlerResult!!.feed) + return true + } + } } } 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 79ce5b48..5bd619fd 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 @@ -1,16 +1,13 @@ package ac.mdiq.podcini.playback.base import ac.mdiq.podcini.R -import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder import ac.mdiq.podcini.net.download.service.PodciniHttpClient -import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curQueue 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.Episodes.setPlayStateSync import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope @@ -21,15 +18,10 @@ import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.config.ClientConfig -import ac.mdiq.vista.extractor.MediaFormat -import ac.mdiq.vista.extractor.stream.AudioStream -import ac.mdiq.vista.extractor.stream.DeliveryMethod -import ac.mdiq.vista.extractor.stream.VideoStream import android.app.UiModeManager import android.content.Context import android.content.res.Configuration import android.media.audiofx.LoudnessEnhancer -import android.net.Uri import android.util.Log import android.util.Pair import android.view.SurfaceHolder @@ -37,23 +29,15 @@ import androidx.core.util.Consumer import androidx.media3.common.* import androidx.media3.common.Player.* import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.HttpDataSource.HttpDataSourceException import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.SeekParameters -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.MergingMediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride import androidx.media3.exoplayer.trackselection.ExoTrackSelection -import androidx.media3.extractor.DefaultExtractorsFactory -import androidx.media3.extractor.mp3.Mp3Extractor import androidx.media3.ui.DefaultTrackNameProvider import androidx.media3.ui.TrackNameProvider import kotlinx.coroutines.* @@ -75,8 +59,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP private var seekLatch: CountDownLatch? = null private val bufferUpdateInterval = 5000L - private var mediaSource: MediaSource? = null - private var mediaItem: MediaItem? = null private var playbackParameters: PlaybackParameters private var bufferedPercentagePrev = 0 @@ -113,8 +95,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP init { if (httpDataSourceFactory == null) { runOnIOScope { - httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) - .setUserAgent(ClientConfig.USER_AGENT) + httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory).setUserAgent(ClientConfig.USER_AGENT) } } if (exoPlayer == null) { @@ -164,130 +145,15 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP exoPlayer?.setAudioAttributes(b.build(), true) } - @Throws(IllegalArgumentException::class, IllegalStateException::class) - private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { - Logd(TAG, "setDataSource: $mediaUrl") - mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build() - mediaSource = null - setSourceCredentials(user, password) - } - - @Throws(IllegalArgumentException::class, IllegalStateException::class) - private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { - Logd(TAG, "setDataSource1 called") - val url = media.getStreamUrl() ?: return - val preferences = media.episodeOrFetch()?.feed?.preferences - val user = preferences?.username - val password = preferences?.password - if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) { - Logd(TAG, "setDataSource1 setting for YouTube source") - try { -// val vService = Vista.getService(0) - val streamInfo = media.episode!!.streamInfo ?: return - val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams) - Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}") - val audioIndex = if (isNetworkRestricted && prefLowQualityMedia) 0 else audioStreamsList.size - 1 - val audioStream = audioStreamsList[audioIndex] - Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}") - val aSource = DefaultMediaSourceFactory(context).createMediaSource( - MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build()) - if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { - Logd(TAG, "setDataSource1 result: $streamInfo") - Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}") - val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true) - val videoIndex = 0 - val videoStream = videoStreamsList[videoIndex] - Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}") - val vSource = DefaultMediaSourceFactory(context).createMediaSource( - MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build()) - val mediaSources: MutableList = ArrayList() - mediaSources.add(vSource) - mediaSources.add(aSource) - mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray()) -// mediaSource = null - } else mediaSource = aSource - mediaItem = mediaSource?.mediaItem - setSourceCredentials(user, password) - } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") } - } else { - Logd(TAG, "setDataSource1 setting for Podcast source") - setDataSource(metadata, url,user, password) - } - } - - private fun setSourceCredentials(user: String?, password: String?) { - if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) { - if (httpDataSourceFactory == null) - httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) - .setUserAgent(ClientConfig.USER_AGENT) - - val requestProperties = HashMap() - requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1") - httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties) - - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!) - val extractorsFactory = DefaultExtractorsFactory() - extractorsFactory.setConstantBitrateSeekingEnabled(true) - extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA) - val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) - - mediaSource = f.createMediaSource(mediaItem!!) - } - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param defaultFormat format to give preference - * @param showHigherResolutions show >1080p resolutions - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean, - preferVideoOnlyStreams: Boolean): List { - val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams) - val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList() - val comparator = compareBy { it.resolution.toResolutionValue() } - return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) } - } - - private fun String.toResolutionValue(): Int { - val match = Regex("(\\d+)p|(\\d+)k").find(this) - return when { - match?.groupValues?.get(1) != null -> match.groupValues[1].toInt() - match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024 - else -> 0 - } - } - - private fun getFilteredAudioStreams(audioStreams: List?): List { - if (audioStreams == null) return listOf() - val collectedStreams = mutableSetOf() - for (stream in audioStreams) { - Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}") - if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS)) - continue - collectedStreams.add(stream) - } - return collectedStreams.toList().sortedWith(compareBy { it.bitrate }) - } - /** * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing - * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will - * not do anything. + * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will not do anything. * Whether playback starts immediately depends on the given parameters. See below for more details. * States: * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. - * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If - * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. - * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object - * will enter the ERROR state. + * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. + * If 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. + * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object will enter the ERROR state. * This method is executed on an internal executor service. * @param playable The Playable object that is supposed to be played. This parameter must not be null. * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via @@ -397,7 +263,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP seekTo(newPosition) } if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() -// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) } exoPlayer?.play() // Can't set params when paused - so always set it on start in case they changed exoPlayer?.playbackParameters = playbackParameters @@ -758,8 +623,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP const val BUFFERING_STARTED: Int = -1 const val BUFFERING_ENDED: Int = -2 - private var httpDataSourceFactory: OkHttpDataSource.Factory? = null - private var trackSelector: DefaultTrackSelector? = null var exoPlayer: ExoPlayer? = null diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt index 28848580..ab70b976 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt @@ -1,16 +1,22 @@ package ac.mdiq.podcini.playback.base //import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed +import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder +import ac.mdiq.podcini.net.download.service.PodciniHttpClient +import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.preferences.UserPreferences.Prefs import ac.mdiq.podcini.preferences.UserPreferences.appPrefs +import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia import ac.mdiq.podcini.preferences.UserPreferences.setPlaybackSpeed -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.FeedPreferences -import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.storage.model.Playable -import ac.mdiq.podcini.util.showStackTrace +import ac.mdiq.podcini.storage.model.* +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.config.ClientConfig +import ac.mdiq.vista.extractor.MediaFormat +import ac.mdiq.vista.extractor.stream.AudioStream +import ac.mdiq.vista.extractor.stream.DeliveryMethod +import ac.mdiq.vista.extractor.stream.VideoStream import android.content.Context import android.media.AudioManager import android.net.Uri @@ -24,6 +30,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.mp3.Mp3Extractor import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.Volatile @@ -44,6 +59,9 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont private var oldStatus: PlayerStatus? = null internal var prevMedia: Playable? = null + protected var mediaSource: MediaSource? = null + protected var mediaItem: MediaItem? = null + internal var mediaType: MediaType = MediaType.UNKNOWN internal val startWhenPrepared = AtomicBoolean(false) @@ -96,6 +114,128 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont open fun createMediaPlayer() {} + @Throws(IllegalArgumentException::class, IllegalStateException::class) + protected fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { + Logd(TAG, "setDataSource: $mediaUrl") + mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build() + mediaSource = null + setSourceCredentials(user, password) + } + + @Throws(IllegalArgumentException::class, IllegalStateException::class) + protected fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { + Logd(TAG, "setDataSource1 called") + val url = media.getStreamUrl() ?: return + val preferences = media.episodeOrFetch()?.feed?.preferences + val user = preferences?.username + val password = preferences?.password + if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) { + Logd(TAG, "setDataSource1 setting for YouTube source") + try { + val streamInfo = media.episode!!.streamInfo ?: return + val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams) + Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}") + val audioIndex = if (isNetworkRestricted && prefLowQualityMedia && media.episode?.feed?.preferences?.audioQualitySetting == FeedPreferences.AVQuality.GLOBAL) 0 else { + when (media.episode?.feed?.preferences?.audioQualitySetting) { + FeedPreferences.AVQuality.LOW -> 0 + FeedPreferences.AVQuality.MEDIUM -> audioStreamsList.size / 2 + FeedPreferences.AVQuality.HIGH -> audioStreamsList.size - 1 + else -> audioStreamsList.size - 1 + } + } + val audioStream = audioStreamsList[audioIndex] + Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}") + val aSource = DefaultMediaSourceFactory(context).createMediaSource( + MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build()) + if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { + Logd(TAG, "setDataSource1 result: $streamInfo") + Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}") + val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true) + val videoIndex = if (isNetworkRestricted && prefLowQualityMedia && media.episode?.feed?.preferences?.videoQualitySetting == FeedPreferences.AVQuality.GLOBAL) 0 else { + when (media.episode?.feed?.preferences?.videoQualitySetting) { + FeedPreferences.AVQuality.LOW -> 0 + FeedPreferences.AVQuality.MEDIUM -> videoStreamsList.size / 2 + FeedPreferences.AVQuality.HIGH -> videoStreamsList.size - 1 + else -> 0 + } + } + val videoStream = videoStreamsList[videoIndex] + Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}") + val vSource = DefaultMediaSourceFactory(context).createMediaSource( + MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build()) + val mediaSources: MutableList = ArrayList() + mediaSources.add(vSource) + mediaSources.add(aSource) + mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray()) +// mediaSource = null + } else mediaSource = aSource + mediaItem = mediaSource?.mediaItem + setSourceCredentials(user, password) + } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") } + } else { + Logd(TAG, "setDataSource1 setting for Podcast source") + setDataSource(metadata, url,user, password) + } + } + + private fun setSourceCredentials(user: String?, password: String?) { + if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) { + if (httpDataSourceFactory == null) + httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) + .setUserAgent(ClientConfig.USER_AGENT) + + val requestProperties = HashMap() + requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1") + httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties) + + val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!) + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA) + val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + + mediaSource = f.createMediaSource(mediaItem!!) + } + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according with default format chosen by the user. + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only streams and normal video streams are available + * @return the sorted list + */ + private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean): List { + val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams) + val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList() + val comparator = compareBy { it.resolution.toResolutionValue() } + return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) } + } + + private fun String.toResolutionValue(): Int { + val match = Regex("(\\d+)p|(\\d+)k").find(this) + return when { + match?.groupValues?.get(1) != null -> match.groupValues[1].toInt() + match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024 + else -> 0 + } + } + + private fun getFilteredAudioStreams(audioStreams: List?): List { + if (audioStreams == null) return listOf() + val collectedStreams = mutableSetOf() + for (stream in audioStreams) { + Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}") + if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS)) + continue + collectedStreams.add(stream) + } + return collectedStreams.toList().sortedWith(compareBy { it.bitrate }) + } + /** * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will @@ -292,7 +432,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont private val TAG: String = MediaPlayerBase::class.simpleName ?: "Anonymous" @get:Synchronized -// @Volatile @JvmStatic var status by mutableStateOf(PlayerStatus.STOPPED) @@ -310,6 +449,9 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont @JvmField val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20) + @JvmStatic + var httpDataSourceFactory: OkHttpDataSource.Factory? = null + val prefPlaybackSpeed: Float get() { try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat() 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 dfa80d0d..89714921 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 @@ -42,6 +42,9 @@ import kotlin.math.min object Episodes { private val TAG: String = Episodes::class.simpleName ?: "Anonymous" + val prefRemoveFromQueueMarkedPlayed by lazy { appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true) } + val prefDeleteRemovesFromQueue by lazy { appPrefs.getBoolean(Prefs.prefDeleteRemovesFromQueue.name, false) } + /** * @param offset The first episode that should be loaded. * @param limit The maximum number of episodes that should be loaded. @@ -64,13 +67,6 @@ object Episodes { return realm.query(Episode::class).query(queryString).count().find().toInt() } -// used in tests only - fun getEpisode(itemId: Long): Episode? { - Logd(TAG, "getFeedItem called with id $itemId") - val it = realm.query(Episode::class).query("id == $0", itemId).first().find() - return if (it != null) realm.copyFromRealm(it) else null - } - /** * Loads a specific FeedItem from the database. * @param guid feed episode guid @@ -104,12 +100,6 @@ object Episodes { } } - val prefDeleteRemovesFromQueue: Boolean - get() = appPrefs.getBoolean(Prefs.prefDeleteRemovesFromQueue.name, false) -// fun shouldDeleteRemoveFromQueue(): Boolean { -// return appPrefs.getBoolean(Prefs.prefDeleteRemovesFromQueue.name, false) -// } - fun deleteMediaSync(context: Context, episode: Episode): Episode { Logd(TAG, "deleteMediaSync called") val media = episode.media ?: return episode @@ -179,7 +169,6 @@ object Episodes { * Remove the listed episodes and their EpisodeMedia entries. * Deleting media also removes the download log entries. */ - fun deleteEpisodes(context: Context, episodes: List) : Job { return runOnIOScope { val removedFromQueue: MutableList = mutableListOf() @@ -224,7 +213,6 @@ object Episodes { * @param episodes the FeedItems. * @param resetMediaPosition true if this method should also reset the position of the Episode's EpisodeMedia object. */ - fun setPlayState(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job { Logd(TAG, "setPlayState called") return runOnIOScope { @@ -253,13 +241,6 @@ object Episodes { return result } - val prefRemoveFromQueueMarkedPlayed: Boolean - get() = appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true) - -// fun shouldMarkedPlayedRemoveFromQueues(): Boolean { -// return appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true) -// } - fun episodeFromStreamInfoItem(item: StreamInfoItem): Episode { val e = Episode() e.link = item.url diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index dd4c4b46..e13006f3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -201,7 +201,7 @@ object Feeds { * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise. */ @Synchronized - fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? { + fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean= false): Feed? { Logd(TAG, "updateFeed called") var resultFeed: Feed? // val unlistedItems: MutableList = ArrayList() @@ -237,8 +237,6 @@ object Feeds { val priorMostRecent = savedFeed.mostRecentItem val priorMostRecentDate: Date? = priorMostRecent?.getPubDate() var idLong = Feed.newId() - Logd(TAG, "updateFeed building newFeedAssistant") - val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id) Logd(TAG, "updateFeed building savedFeedAssistant") val savedFeedAssistant = FeedAssistant(savedFeed) @@ -293,7 +291,6 @@ object Feeds { if (pubDate == null || priorMostRecentDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) { Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate") episode.playState = PlayState.NEW.code -// episode.setNew() if (savedFeed.preferences?.autoAddNewToQueue == true) { val q = savedFeed.preferences?.queue if (q != null) runOnIOScope { addToQueueSync(episode, q) } @@ -306,6 +303,8 @@ object Feeds { val unlistedItems: MutableList = ArrayList() // identify episodes to be removed if (removeUnlistedItems) { + Logd(TAG, "updateFeed building newFeedAssistant") + val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id) val it = savedFeed.episodes.toMutableList().iterator() while (it.hasNext()) { val feedItem = it.next() @@ -314,19 +313,17 @@ object Feeds { it.remove() } } + newFeedAssistant.clear() } - newFeedAssistant.clear() // update attributes savedFeed.lastUpdate = newFeed.lastUpdate savedFeed.type = newFeed.type savedFeed.lastUpdateFailed = false - resultFeed = savedFeed - savedFeed.totleDuration = 0 - for (e in savedFeed.episodes) { - savedFeed.totleDuration += e.media?.duration ?: 0 - } + for (e in savedFeed.episodes) savedFeed.totleDuration += e.media?.duration ?: 0 + + resultFeed = savedFeed try { upsertBlk(savedFeed) {} if (removeUnlistedItems && unlistedItems.isNotEmpty()) runBlocking { deleteEpisodes(context, unlistedItems).join() } @@ -335,6 +332,69 @@ object Feeds { return resultFeed } + @Synchronized + fun updateFeedSimple(newFeed: Feed): Feed? { + Logd(TAG, "updateFeedSimple called") + val savedFeed = searchFeedByIdentifyingValueOrID(newFeed, true) ?: return newFeed + + Logd(TAG, "Feed with title " + newFeed.title + " already exists. Syncing new with existing one.") + newFeed.episodes.sortWith(EpisodePubdateComparator()) + if (newFeed.pageNr == savedFeed.pageNr) { + if (savedFeed.compareWithOther(newFeed)) { + Logd(TAG, "Feed has updated attribute values. Updating old feed's attributes") + savedFeed.updateFromOther(newFeed) + } + } else { + Logd(TAG, "New feed has a higher page number: ${newFeed.nextPageLink}") + savedFeed.nextPageLink = newFeed.nextPageLink + } + val priorMostRecent = savedFeed.mostRecentItem + val priorMostRecentDate: Date = priorMostRecent?.getPubDate() ?: Date(0) + var idLong = Feed.newId() + Logd(TAG, "updateFeedSimple building savedFeedAssistant") + + // Look for new or updated Items + for (idx in newFeed.episodes.indices) { + val episode = newFeed.episodes[idx] + if ((episode.getPubDate()?: Date(0)) <= priorMostRecentDate) continue + + Logd(TAG, "Found new episode: ${episode.title}") + episode.feed = savedFeed + episode.id = idLong++ + episode.feedId = savedFeed.id + if (episode.media != null) { + episode.media!!.id = episode.id + if (!savedFeed.hasVideoMedia && episode.media!!.getMediaType() == MediaType.VIDEO) savedFeed.hasVideoMedia = true + } + if (idx >= savedFeed.episodes.size) savedFeed.episodes.add(episode) + else savedFeed.episodes.add(idx, episode) + + val pubDate = episode.getPubDate() + if (pubDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) { + Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate") + episode.playState = PlayState.NEW.code + if (savedFeed.preferences?.autoAddNewToQueue == true) { + val q = savedFeed.preferences?.queue + if (q != null) runOnIOScope { addToQueueSync(episode, q) } + } + } + } + + // update attributes + savedFeed.lastUpdate = newFeed.lastUpdate + savedFeed.type = newFeed.type + savedFeed.lastUpdateFailed = false + savedFeed.totleDuration = 0 + for (e in savedFeed.episodes) savedFeed.totleDuration += e.media?.duration ?: 0 + + val resultFeed = savedFeed + try { + upsertBlk(savedFeed) {} + } catch (e: InterruptedException) { e.printStackTrace() + } catch (e: ExecutionException) { e.printStackTrace() } + return resultFeed + } + fun persistFeedLastUpdateFailed(feed: Feed, lastUpdateFailed: Boolean) : Job { Logd(TAG, "persistFeedLastUpdateFailed called") return runOnIOScope { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt index 7e751b6f..9a89cc5f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt @@ -40,7 +40,7 @@ object RealmDB { SubscriptionLog::class, Chapter::class)) .name("Podcini.realm") - .schemaVersion(28) + .schemaVersion(29) .migration({ mContext -> val oldRealm = mContext.oldRealm // old realm using the previous schema val newRealm = mContext.newRealm // new realm using the new schema 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 b62a17a2..93c61a16 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 @@ -142,7 +142,11 @@ class Feed : RealmObject { @Ignore val mostRecentItem: Episode? - get() = realm.query(Episode::class).query("feedId == $id SORT(pubDate DESC)").first().find() + get() = realm.query(Episode::class).query("feedId == $id SORT (pubDate DESC)").first().find() + + @Ignore + val oldestItem: Episode? + get() = realm.query(Episode::class).query("feedId == $id SORT (pubDate ASC)").first().find() @Ignore var title: String? 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 45333d8e..22328045 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 @@ -48,7 +48,7 @@ class FeedPreferences : EmbeddedRealmObject { field = value autoDelete = field.code } - var autoDelete: Int = 0 + var autoDelete: Int = AutoDeleteAction.GLOBAL.code @Ignore var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF @@ -57,7 +57,25 @@ class FeedPreferences : EmbeddedRealmObject { field = value volumeAdaption = field.toInteger() } - var volumeAdaption: Int = 0 + var volumeAdaption: Int = VolumeAdaptionSetting.OFF.value + + @Ignore + var audioQualitySetting: AVQuality = AVQuality.GLOBAL + get() = AVQuality.fromCode(audioQuality) + set(value) { + field = value + audioQuality = field.code + } + var audioQuality: Int = AVQuality.GLOBAL.code + + @Ignore + var videoQualitySetting: AVQuality = AVQuality.GLOBAL + get() = AVQuality.fromCode(videoQuality) + set(value) { + field = value + videoQuality = field.code + } + var videoQuality: Int = AVQuality.GLOBAL.code var prefStreamOverDownload: Boolean = false @@ -132,7 +150,7 @@ class FeedPreferences : EmbeddedRealmObject { field = value autoDLPolicyCode = value.code } - var autoDLPolicyCode: Int = 0 + var autoDLPolicyCode: Int = AutoDownloadPolicy.ONLY_NEW.code constructor() {} @@ -197,6 +215,22 @@ class FeedPreferences : EmbeddedRealmObject { } } + enum class AVQuality(val code: Int, val tag: String) { + GLOBAL(0, "Global"), + LOW(1, "Low"), + MEDIUM(5, "Medium"), + HIGH(10, "High"); + + companion object { + fun fromCode(code: Int): AVQuality { + return enumValues().firstOrNull { it.code == code } ?: GLOBAL + } + fun fromTag(tag: String): AVQuality { + return enumValues().firstOrNull { it.tag == tag } ?: GLOBAL + } + } + } + companion object { const val SPEED_USE_GLOBAL: Float = -1f const val TAG_ROOT: String = "#root" 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 209949fd..ed61386e 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 @@ -2,7 +2,7 @@ package ac.mdiq.podcini.storage.model import ac.mdiq.podcini.R -enum class VolumeAdaptionSetting(private val value: Int, @JvmField val adaptionFactor: Float, val resId: Int) { +enum class VolumeAdaptionSetting(val value: Int, @JvmField val adaptionFactor: Float, val resId: Int) { OFF(0, 1.0f, R.string.feed_volume_reduction_off), LIGHT_REDUCTION(1, 0.5f, R.string.feed_volume_reduction_light), HEAVY_REDUCTION(2, 0.2f, R.string.feed_volume_reduction_heavy), 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 ad3456fb..c917f62b 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 @@ -997,14 +997,15 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List, showDialog: Boolean, onDi @Composable fun EpisodesFilterDialog(filter: EpisodeFilter? = null, filtersDisabled: MutableSet = mutableSetOf(), onDismissRequest: () -> Unit, onFilterChanged: (Set) -> Unit) { - val filterValues: MutableSet = mutableSetOf() + val filterValues = remember { filter?.properties ?: mutableSetOf() } Dialog(properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { onDismissRequest() }) { val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider dialogWindowProvider?.window?.let { window -> window.setGravity(Gravity.BOTTOM) window.setDimAmount(0f) } - Surface(modifier = Modifier.fillMaxWidth().height(350.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp)) { + Surface(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 10.dp).height(350.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) { val textColor = MaterialTheme.colorScheme.onSurface val scrollState = rememberScrollState() Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { 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 abc27526..b900a1d3 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 @@ -179,8 +179,8 @@ import java.util.concurrent.Semaphore runOnIOScope { val feed_ = realm.query(Feed::class, "id == ${feed!!.id}").first().find() if (feed_ != null) { - upsert(feed_) { it.preferences?.filterString = filterValues.joinToString() } - loadFeed() + feed = upsert(feed_) { it.preferences?.filterString = filterValues.joinToString() } +// loadFeed() } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt index 440a19d0..043ea5d0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt @@ -38,23 +38,19 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.fragment.app.Fragment - import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.util.* @@ -110,9 +106,7 @@ class FeedSettingsFragment : Fragment() { Switch(checked = checked, modifier = Modifier.height(24.dp), onCheckedChange = { checked = it - feed = upsertBlk(feed!!) { f -> - f.preferences?.keepUpdated = checked - } + feed = upsertBlk(feed!!) { f -> f.preferences?.keepUpdated = checked } } ) } @@ -142,21 +136,55 @@ class FeedSettingsFragment : Fragment() { Spacer(modifier = Modifier.width(20.dp)) Text(text = stringResource(R.string.pref_stream_over_download_title), style = MaterialTheme.typography.titleLarge, color = textColor) Spacer(modifier = Modifier.weight(1f)) - var checked by remember { - mutableStateOf(feed?.preferences?.prefStreamOverDownload ?: false) - } + var checked by remember { mutableStateOf(feed?.preferences?.prefStreamOverDownload ?: false) } Switch(checked = checked, modifier = Modifier.height(24.dp), onCheckedChange = { checked = it - feed = upsertBlk(feed!!) { f -> - f.preferences?.prefStreamOverDownload = checked - } + feed = upsertBlk(feed!!) { f -> f.preferences?.prefStreamOverDownload = checked } } ) } Text(text = stringResource(R.string.pref_stream_over_download_sum), style = MaterialTheme.typography.bodyMedium, color = textColor) } } + if (feed?.type == Feed.FeedType.YOUTUBE.name) { + // audio quality + Column { + var showDialog by remember { mutableStateOf(false) } + var selectedOption by remember { mutableStateOf(feed?.preferences?.audioQualitySetting?.tag ?: FeedPreferences.AVQuality.GLOBAL.tag) } + if (showDialog) SetAudioQuality(showDialog, selectedOption = selectedOption, onDismissRequest = { showDialog = false }) + Row(Modifier.fillMaxWidth()) { + Icon(ImageVector.vectorResource(id = R.drawable.baseline_audiotrack_24), "", tint = textColor) + Spacer(modifier = Modifier.width(20.dp)) + Text(text = stringResource(R.string.pref_feed_audio_quality), style = MaterialTheme.typography.titleLarge, color = textColor, + modifier = Modifier.clickable(onClick = { + selectedOption = feed!!.preferences?.audioQualitySetting?.tag ?: FeedPreferences.AVQuality.GLOBAL.tag + showDialog = true + }) + ) + } + Text(text = stringResource(R.string.pref_feed_audio_quality_sum), style = MaterialTheme.typography.bodyMedium, color = textColor) + } + if (feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { + // video quality + Column { + var showDialog by remember { mutableStateOf(false) } + var selectedOption by remember { mutableStateOf(feed?.preferences?.videoQualitySetting?.tag ?: FeedPreferences.AVQuality.GLOBAL.tag) } + if (showDialog) SetVideoQuality(showDialog, selectedOption = selectedOption, onDismissRequest = { showDialog = false }) + Row(Modifier.fillMaxWidth()) { + Icon(ImageVector.vectorResource(id = R.drawable.ic_videocam), "", tint = textColor) + Spacer(modifier = Modifier.width(20.dp)) + Text(text = stringResource(R.string.pref_feed_video_quality), style = MaterialTheme.typography.titleLarge, color = textColor, + modifier = Modifier.clickable(onClick = { + selectedOption = feed!!.preferences?.videoQualitySetting?.tag ?: FeedPreferences.AVQuality.GLOBAL.tag + showDialog = true + }) + ) + } + Text(text = stringResource(R.string.pref_feed_video_quality_sum), style = MaterialTheme.typography.bodyMedium, color = textColor) + } + } + } // associated queue Column { curPrefQueue = feed?.preferences?.queueTextExt ?: "Default" @@ -187,9 +215,7 @@ class FeedSettingsFragment : Fragment() { Switch(checked = checked, modifier = Modifier.height(24.dp), onCheckedChange = { checked = it - feed = upsertBlk(feed!!) { f -> - f.preferences?.autoAddNewToQueue = checked - } + feed = upsertBlk(feed!!) { f -> f.preferences?.autoAddNewToQueue = checked } } ) } @@ -230,10 +256,7 @@ class FeedSettingsFragment : Fragment() { Icon(ImageVector.vectorResource(id = R.drawable.ic_playback_speed), "", tint = textColor) Spacer(modifier = Modifier.width(20.dp)) Text(text = stringResource(R.string.playback_speed), style = MaterialTheme.typography.titleLarge, color = textColor, - modifier = Modifier.clickable(onClick = { - PlaybackSpeedDialog().show() - }) - ) + modifier = Modifier.clickable(onClick = { PlaybackSpeedDialog().show() })) } Text(text = stringResource(R.string.pref_feed_playback_speed_sum), style = MaterialTheme.typography.bodyMedium, color = textColor) } @@ -286,12 +309,10 @@ class FeedSettingsFragment : Fragment() { onCheckedChange = { audoDownloadChecked = it feed = upsertBlk(feed!!) { f -> f.preferences?.autoDownload = audoDownloadChecked } - } - ) + }) } - if (!isEnableAutodownload) { + if (!isEnableAutodownload) Text(text = stringResource(R.string.auto_download_disabled_globally), style = MaterialTheme.typography.bodyMedium, color = textColor) - } } if (audoDownloadChecked) { // auto download policy @@ -300,10 +321,7 @@ class FeedSettingsFragment : Fragment() { val showDialog = remember { mutableStateOf(false) } if (showDialog.value) AutoDownloadPolicyDialog(showDialog.value, onDismissRequest = { showDialog.value = false }) Text(text = stringResource(R.string.feed_auto_download_policy), style = MaterialTheme.typography.titleLarge, color = textColor, - modifier = Modifier.clickable(onClick = { - showDialog.value = true - }) - ) + modifier = Modifier.clickable(onClick = { showDialog.value = true })) } } // episode cache @@ -312,8 +330,7 @@ class FeedSettingsFragment : Fragment() { val showDialog = remember { mutableStateOf(false) } if (showDialog.value) SetEpisodesCacheDialog(showDialog.value, onDismiss = { showDialog.value = false }) Text(text = stringResource(R.string.pref_episode_cache_title), style = MaterialTheme.typography.titleLarge, color = textColor, - modifier = Modifier.clickable(onClick = { showDialog.value = true }) - ) + modifier = Modifier.clickable(onClick = { showDialog.value = true })) } Text(text = stringResource(R.string.pref_episode_cache_summary), style = MaterialTheme.typography.bodyMedium, color = textColor) } @@ -322,15 +339,11 @@ class FeedSettingsFragment : Fragment() { Row(Modifier.fillMaxWidth()) { Text(text = stringResource(R.string.pref_auto_download_counting_played_title), style = MaterialTheme.typography.titleLarge, color = textColor) Spacer(modifier = Modifier.weight(1f)) - var checked by remember { - mutableStateOf(feed?.preferences?.countingPlayed ?: true) - } + var checked by remember { mutableStateOf(feed?.preferences?.countingPlayed ?: true) } Switch(checked = checked, modifier = Modifier.height(24.dp), onCheckedChange = { checked = it - feed = upsertBlk(feed!!) { f -> - f.preferences?.countingPlayed = checked - } + feed = upsertBlk(feed!!) { f -> f.preferences?.countingPlayed = checked } } ) } @@ -341,14 +354,9 @@ class FeedSettingsFragment : Fragment() { Row(Modifier.fillMaxWidth()) { Text(text = stringResource(R.string.episode_inclusive_filters_label), style = MaterialTheme.typography.titleLarge, color = textColor, modifier = Modifier.clickable(onClick = { - object : AutoDownloadFilterPrefDialog(requireContext(), - feed?.preferences!!.autoDownloadFilter!!, - 1) { - + object : AutoDownloadFilterPrefDialog(requireContext(), feed?.preferences!!.autoDownloadFilter!!, 1) { override fun onConfirmed(filter: FeedAutoDownloadFilter) { - feed = upsertBlk(feed!!) { - it.preferences?.autoDownloadFilter = filter - } + feed = upsertBlk(feed!!) { it.preferences?.autoDownloadFilter = filter } } }.show() }) @@ -361,14 +369,9 @@ class FeedSettingsFragment : Fragment() { Row(Modifier.fillMaxWidth()) { Text(text = stringResource(R.string.episode_exclusive_filters_label), style = MaterialTheme.typography.titleLarge, color = textColor, modifier = Modifier.clickable(onClick = { - object : AutoDownloadFilterPrefDialog(requireContext(), - feed?.preferences!!.autoDownloadFilter!!, - -1) { - + object : AutoDownloadFilterPrefDialog(requireContext(), feed?.preferences!!.autoDownloadFilter!!, -1) { override fun onConfirmed(filter: FeedAutoDownloadFilter) { - feed = upsertBlk(feed!!) { - it.preferences?.autoDownloadFilter = filter - } + feed = upsertBlk(feed!!) { it.preferences?.autoDownloadFilter = filter } } }.show() }) @@ -543,11 +546,7 @@ class FeedSettingsFragment : Fragment() { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column { AutoDownloadPolicy.entries.forEach { item -> - Row(Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = (item == selectedOption), onCheckedChange = { Logd(TAG, "row clicked: $item $selectedOption") @@ -577,18 +576,13 @@ class FeedSettingsFragment : Fragment() { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { var newCache by remember { mutableStateOf((feed?.preferences?.autoDLMaxEpisodes ?: 1).toString()) } TextField(value = newCache, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) newCache = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), -// visualTransformation = PositiveIntegerTransform(), - label = { Text("Max episodes allowed") } - ) + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("Max episodes allowed") }) Button(onClick = { if (newCache.isNotEmpty()) { feed = upsertBlk(feed!!) { it.preferences?.autoDLMaxEpisodes = newCache.toIntOrNull() ?: 1 } onDismiss() } - }) { - Text("Confirm") - } + }) { Text("Confirm") } } } } @@ -648,6 +642,90 @@ class FeedSettingsFragment : Fragment() { } } + @Composable + private fun SetAudioQuality(showDialog: Boolean, selectedOption: String, onDismissRequest: () -> Unit) { + var selected by remember {mutableStateOf(selectedOption)} + if (showDialog) { + 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)) { + FeedPreferences.AVQuality.entries.forEach { option -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = option.tag == selected, + onCheckedChange = { isChecked -> + selected = option.tag + if (isChecked) Logd(TAG, "$option is checked") + when (selected) { + FeedPreferences.AVQuality.LOW.tag -> { + feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.LOW.code } + onDismissRequest() + } + FeedPreferences.AVQuality.MEDIUM.tag -> { + feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.MEDIUM.code } + onDismissRequest() + } + FeedPreferences.AVQuality.HIGH.tag -> { + feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.HIGH.code } + onDismissRequest() + } + else -> { + feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.GLOBAL.code } + onDismissRequest() + } + } + } + ) + Text(option.tag) + } + } + } + } + } + } + } + + @Composable + private fun SetVideoQuality(showDialog: Boolean, selectedOption: String, onDismissRequest: () -> Unit) { + var selected by remember {mutableStateOf(selectedOption)} + if (showDialog) { + 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)) { + FeedPreferences.AVQuality.entries.forEach { option -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = option.tag == selected, + onCheckedChange = { isChecked -> + selected = option.tag + if (isChecked) Logd(TAG, "$option is checked") + when (selected) { + FeedPreferences.AVQuality.LOW.tag -> { + feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.LOW.code } + onDismissRequest() + } + FeedPreferences.AVQuality.MEDIUM.tag -> { + feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.MEDIUM.code } + onDismissRequest() + } + FeedPreferences.AVQuality.HIGH.tag -> { + feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.HIGH.code } + onDismissRequest() + } + else -> { + feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.GLOBAL.code } + onDismissRequest() + } + } + } + ) + Text(option.tag) + } + } + } + } + } + } + } + @Composable fun AuthenticationDialog(showDialog: Boolean, onDismiss: () -> Unit) { if (showDialog) { @@ -669,9 +747,7 @@ class FeedSettingsFragment : Fragment() { Thread({ runOnce(requireContext(), feed) }, "RefreshAfterCredentialChange").start() onDismiss() } - }) { - Text("Confirm") - } + }) { Text("Confirm") } } } } @@ -685,19 +761,11 @@ class FeedSettingsFragment : Fragment() { 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)) { var intro by remember { mutableStateOf((feed?.preferences?.introSkip ?: 0).toString()) } - TextField(value = intro, - onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) intro = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), -// visualTransformation = PositiveIntegerTransform(), - label = { Text("Skip first (seconds)") } - ) + TextField(value = intro, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) intro = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("Skip first (seconds)") }) var ending by remember { mutableStateOf((feed?.preferences?.endingSkip ?: 0).toString()) } - TextField(value = ending, - onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) ending = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), -// visualTransformation = PositiveIntegerTransform(), - label = { Text("Skip last (seconds)") } - ) + TextField(value = ending, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) ending = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("Skip last (seconds)") }) Button(onClick = { if (intro.isNotEmpty() || ending.isNotEmpty()) { feed = upsertBlk(feed!!) { @@ -706,9 +774,7 @@ class FeedSettingsFragment : Fragment() { } onDismiss() } - }) { - Text("Confirm") - } + }) { Text("Confirm") } } } } @@ -728,9 +794,7 @@ class FeedSettingsFragment : Fragment() { val speed = feed?.preferences!!.playSpeed binding.useGlobalCheckbox.isChecked = speed == FeedPreferences.SPEED_USE_GLOBAL binding.seekBar.updateSpeed(if (speed == FeedPreferences.SPEED_USE_GLOBAL) 1f else speed) - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.playback_speed) - .setView(binding.root) + return MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.playback_speed).setView(binding.root) .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> val newSpeed = if (binding.useGlobalCheckbox.isChecked) FeedPreferences.SPEED_USE_GLOBAL else binding.seekBar.currentSpeed @@ -832,9 +896,7 @@ class FeedSettingsFragment : Fragment() { protected abstract fun onConfirmed(filter: FeedAutoDownloadFilter) private fun toFilterString(words: List?): String { val result = StringBuilder() - for (word in words!!) { - result.append("\"").append(word).append("\" ") - } + for (word in words!!) result.append("\"").append(word).append("\" ") return result.toString() } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index c2107527..f6d2bdb6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -297,7 +297,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { feed.preferences!!.videoModePolicy = VideoMode.WINDOW_VIEW CustomFeedNameDialog(activity as Activity, feed).show() } - R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext()) + R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext(), fullUpdate = true) R.id.toggle_grid_list -> useGrid = if (useGrid == null) !useGridLayout else !useGrid!! else -> return false } @@ -961,7 +961,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { @Composable fun FilterDialog(filter: FeedFilter? = null, onDismissRequest: () -> Unit) { - val filterValues: MutableSet = mutableSetOf() + val filterValues = remember { filter?.properties ?: mutableSetOf() } fun onFilterChanged(newFilterValues: Set) { feedsFilter = StringUtils.join(newFilterValues, ",") @@ -975,7 +975,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { window.setGravity(Gravity.BOTTOM) window.setDimAmount(0f) } - Surface(modifier = Modifier.fillMaxWidth().height(350.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp)) { + Surface(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 10.dp).height(350.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) { val textColor = MaterialTheme.colorScheme.onSurface val scrollState = rememberScrollState() Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b953adc7..b9e502bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -575,6 +575,10 @@ Auto skip Skip introductions and ending credits. Associated + Audio quality + Global generally equals high quality except when prefLowQualityMedia is set for metered network. Quality setting here takes precedence over the setting of prefLowQualityMedia for metered network. + Video quality + Global equals low quality, Quality setting here takes precedence over the setting of prefLowQualityMedia for metered network. Associated queue The queue to which epiosdes in the feed will added by default Skip last diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt index c69aeb33..9dbdb81e 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt @@ -4,6 +4,7 @@ import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus +import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.RemoteMedia @@ -11,7 +12,9 @@ import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import android.annotation.SuppressLint +import android.app.UiModeManager import android.content.Context +import android.content.res.Configuration import android.util.Log import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastContext @@ -19,6 +22,11 @@ import com.google.android.gms.cast.framework.CastState import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.Volatile import kotlin.math.max @@ -194,7 +202,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl * * @see .playMediaObject */ - override fun playMediaObject(playable: Playable, stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { + override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { if (!CastUtils.isCastable(playable, castContext.sessionManager.currentCastSession)) { Logd(TAG, "media provided is not compatible with cast device") EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device")) @@ -202,7 +210,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl do { nextPlayable = callback.getNextInQueue(nextPlayable) } while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession)) - if (nextPlayable != null) playMediaObject(nextPlayable, stream, startWhenPrepared, prepareImmediately, forceReset) + if (nextPlayable != null) playMediaObject(nextPlayable, streaming, startWhenPrepared, prepareImmediately, forceReset) return } @@ -235,6 +243,53 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl this.mediaType = curMedia!!.getMediaType() this.startWhenPrepared.set(startWhenPrepared) setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) + +// val metadata = buildMetadata(curMedia!!) +// try { +// callback.ensureMediaInfoLoaded(curMedia!!) +// callback.onMediaChanged(false) +// setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) +// CoroutineScope(Dispatchers.IO).launch { +// when { +// streaming -> { +// val streamurl = curMedia!!.getStreamUrl() +// if (streamurl != null) { +// val media = curMedia +// if (media is EpisodeMedia) { +// mediaItem = null +// mediaSource = null +// setDataSource(metadata, media) +//// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } +//// if (startWhenPrepared) runBlocking { deferred.await() } +//// val preferences = media.episodeOrFetch()?.feed?.preferences +//// setDataSource(metadata, streamurl, preferences?.username, preferences?.password) +// } else setDataSource(metadata, streamurl, null, null) +// } +// } +// else -> { +// val localMediaurl = curMedia!!.getLocalMediaUrl() +//// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle +//// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) +// if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) +// else throw IOException("Unable to read local file $localMediaurl") +// } +// } +// withContext(Dispatchers.Main) { +// val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager +// if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) +// if (prepareImmediately) prepare() +// } +// } +// } catch (e: IOException) { +// e.printStackTrace() +// setPlayerStatus(PlayerStatus.ERROR, null) +// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) +// } catch (e: IllegalStateException) { +// e.printStackTrace() +// setPlayerStatus(PlayerStatus.ERROR, null) +// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) +// } finally { } + callback.ensureMediaInfoLoaded(curMedia!!) callback.onMediaChanged(true) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) @@ -266,7 +321,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl override fun reinit() { Logd(TAG, "reinit() called") - if (curMedia != null) playMediaObject(curMedia!!, stream = false, startWhenPrepared = startWhenPrepared.get(), prepareImmediately = false, true) + if (curMedia != null) playMediaObject(curMedia!!, streaming = false, startWhenPrepared = startWhenPrepared.get(), prepareImmediately = false, true) else Logd(TAG, "Call to reinit was ignored: media was null") } @@ -342,7 +397,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode) // setting media to null signals to playMediaObject() that we're taking care of post-playback processing curMedia = null - playMediaObject(nextMedia, stream = true, startWhenPrepared = playNextEpisode, prepareImmediately = playNextEpisode, forceReset = false) + playMediaObject(nextMedia, streaming = true, startWhenPrepared = playNextEpisode, prepareImmediately = playNextEpisode, forceReset = false) } } when { diff --git a/changelog.md b/changelog.md index e8603579..029bee8e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,16 @@ +# 6.13.6 + +* created a fast update routine to improve performance + * all updates (swipe or scheduled etc) run the fast update routine + * full update (for which I don't see the reason) can be run from the menu of Subscriptions view +* the full update routine is also enhanced for performance +* enhanced youtube update routines to include infinite new episodes updates +* added update routine for youtube channel tabs +* added audio and video quality settings in Feed Preferences (Youtube feeds only): Global, Low, Medium, High + * these settings take precedence over global situations + * when Global is set, video is a lowest quality, and audio is at highest quality (except when prefLowQualityMedia is set for metered networks) +* fixed mis-behavior of multi-selection filters + # 6.13.5 * hopefully fixed youtube playlists/podcasts no-updating issue diff --git a/fastlane/metadata/android/en-US/changelogs/3020293.txt b/fastlane/metadata/android/en-US/changelogs/3020293.txt new file mode 100644 index 00000000..ec837887 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020293.txt @@ -0,0 +1,12 @@ + Version 6.13.6 + +* created a fast update routine to improve performance + * all updates (swipe or scheduled etc) run the fast update routine + * full update (for which I don't see the reason) can be run from the menu of Subscriptions view +* the full update routine is also enhanced for performance +* enhanced youtube update routines to include infinite new episodes updates +* added update routine for youtube channel tabs +* added audio and video quality settings in Feed Preferences (Youtube feeds only): Global, Low, Medium, High + * these settings take precedence over global situations + * when Global is set, video is a lowest quality, and audio is at highest quality (except when prefLowQualityMedia is set for metered networks) +* fixed mis-behavior of multi-selection filters diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5494385d..9b1bab9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,7 +49,6 @@ rxandroid = "3.0.2" rxjava = "2.2.21" rxjavaVersion = "3.1.8" searchpreference = "v2.5.0" -#stream = "1.2.2" uiToolingPreview = "1.7.5" uiTooling = "1.7.5" viewpager2 = "1.1.0" @@ -115,7 +114,6 @@ rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroi rxjava3-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjavaVersion" } rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" } searchpreference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchpreference" } -#stream = { module = "com.annimon:stream", version.ref = "stream" } vistaguide = { module = "com.github.XilinJia.vistaguide:VistaGuide", version.ref = "vistaguide" } desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } wearable = { module = "com.google.android.wearable:wearable", version.ref = "wearable" }