6.13.6 commit

This commit is contained in:
Xilin Jia 2024-11-06 20:32:28 +01:00
parent bdf56d58b3
commit b2501d6f2b
20 changed files with 647 additions and 310 deletions

View File

@ -26,8 +26,8 @@ android {
vectorDrawables.useSupportLibrary false vectorDrawables.useSupportLibrary false
vectorDrawables.generatedDensities = [] vectorDrawables.generatedDensities = []
versionCode 3020292 versionCode 3020293
versionName "6.13.5" versionName "6.13.6"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""
@ -239,7 +239,6 @@ dependencies {
implementation libs.searchpreference implementation libs.searchpreference
implementation libs.balloon implementation libs.balloon
// implementation libs.stream
implementation libs.fyydlin implementation libs.fyydlin

View File

@ -131,7 +131,6 @@ class HttpDownloader(request: DownloadRequest) : Downloader(request) {
Logd(TAG, "Starting download") Logd(TAG, "Starting download")
try { try {
while (!cancelled && (connection.read(buffer).also { count = it }) != -1) { while (!cancelled && (connection.read(buffer).also { count = it }) != -1) {
// Log.d(TAG,"buffer: $buffer")
out.write(buffer, 0, count) out.write(buffer, 0, count)
downloadRequest.soFar += count downloadRequest.soFar += count
val progressPercent = (100.0 * downloadRequest.soFar / downloadRequest.size).toInt() val progressPercent = (100.0 * downloadRequest.soFar / downloadRequest.size).toInt()

View File

@ -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.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi
import ac.mdiq.podcini.net.utils.NetworkUtils.networkAvailable 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
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.storage.algorithms.AutoDownloads.autodownloadEpisodeMedia 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.FlowEvent
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ClientConfigurator import ac.mdiq.podcini.util.config.ClientConfigurator
import ac.mdiq.vista.extractor.InfoItem
import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.channel.ChannelInfo import ac.mdiq.vista.extractor.channel.ChannelInfo
import ac.mdiq.vista.extractor.channel.tabs.ChannelTabInfo 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 com.google.common.util.concurrent.ListenableFuture
import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList import io.realm.kotlin.types.RealmList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xml.sax.SAXException import org.xml.sax.SAXException
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -67,6 +71,7 @@ object FeedUpdateManager {
private const val WORK_ID_FEED_UPDATE_MANUAL = "feedUpdateManual" private const val WORK_ID_FEED_UPDATE_MANUAL = "feedUpdateManual"
const val EXTRA_FEED_ID: String = "feed_id" const val EXTRA_FEED_ID: String = "feed_id"
const val EXTRA_NEXT_PAGE: String = "next_page" 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" const val EXTRA_EVEN_ON_MOBILE: String = "even_on_mobile"
private val updateInterval: Long private val updateInterval: Long
@ -93,7 +98,7 @@ object FeedUpdateManager {
@JvmStatic @JvmStatic
@JvmOverloads @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) val workRequest: OneTimeWorkRequest.Builder = OneTimeWorkRequest.Builder(FeedUpdateWorker::class.java)
.setInitialDelay(0L, TimeUnit.MILLISECONDS) .setInitialDelay(0L, TimeUnit.MILLISECONDS)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
@ -102,6 +107,7 @@ object FeedUpdateManager {
val builder = Data.Builder() val builder = Data.Builder()
builder.putBoolean(EXTRA_EVEN_ON_MOBILE, true) builder.putBoolean(EXTRA_EVEN_ON_MOBILE, true)
if (fullUpdate) builder.putBoolean(EXTRA_FULL_UPDATE, true)
if (feed != null) { if (feed != null) {
builder.putLong(EXTRA_FEED_ID, feed.id) builder.putLong(EXTRA_FEED_ID, feed.id)
builder.putBoolean(EXTRA_NEXT_PAGE, nextPage) builder.putBoolean(EXTRA_NEXT_PAGE, nextPage)
@ -112,12 +118,12 @@ object FeedUpdateManager {
@JvmStatic @JvmStatic
@JvmOverloads @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.") Logd(TAG, "Run auto update immediately in background.")
when { 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))) !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) else -> confirmMobileRefresh(context, feed)
} }
} }
@ -169,7 +175,8 @@ object FeedUpdateManager {
return Result.retry() 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) notificationManager.cancel(R.id.notification_updating_feeds)
autodownloadEpisodeMedia(applicationContext, feedsToUpdate.toList()) autodownloadEpisodeMedia(applicationContext, feedsToUpdate.toList())
feedsToUpdate.clear() feedsToUpdate.clear()
@ -197,7 +204,7 @@ object FeedUpdateManager {
return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null))) return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null)))
} }
private fun refreshFeeds(feedsToUpdate: MutableList<Feed>, force: Boolean) { private fun refreshFeeds(feedsToUpdate: MutableList<Feed>, force: Boolean, fullUpdate: Boolean) {
if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext, if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext,
Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling // TODO: Consider calling
@ -222,8 +229,8 @@ object FeedUpdateManager {
Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}") Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}")
when { when {
feed.isLocalFeed -> LocalFeedUpdater.updateFeed(feed, applicationContext, null) feed.isLocalFeed -> LocalFeedUpdater.updateFeed(feed, applicationContext, null)
feed.type == Feed.FeedType.YOUTUBE.name -> refreshYoutubeFeed(feed) feed.type == Feed.FeedType.YOUTUBE.name -> refreshYoutubeFeed(feed, fullUpdate)
else -> refreshFeed(feed, force) else -> refreshFeed(feed, force, fullUpdate)
} }
} catch (e: Exception) { } catch (e: Exception) {
Logd(TAG, "update failed ${e.message}") Logd(TAG, "update failed ${e.message}")
@ -234,23 +241,44 @@ object FeedUpdateManager {
titles.removeAt(0) titles.removeAt(0)
} }
} }
private fun refreshYoutubeFeed(feed: Feed) { private fun refreshYoutubeFeed(feed: Feed, fullUpdate: Boolean) {
if (feed.downloadUrl.isNullOrEmpty()) return if (feed.downloadUrl.isNullOrEmpty()) return
val url = feed.downloadUrl val url = feed.downloadUrl!!
val newestItem = feed.mostRecentItem
val oldestItem = feed.oldestItem
try { try {
val service = try { Vista.getService("YouTube") val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") }
} catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") }
val uURL = URL(url) val uURL = URL(url)
if (uURL.path.startsWith("/channel")) { 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}") Logd(TAG, "refreshYoutubeFeed channelInfo: $channelInfo ${channelInfo.tabs.size}")
if (channelInfo.tabs.isEmpty()) return if (channelInfo.tabs.isEmpty()) return
try { try {
val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first())
Logd(TAG, "refreshYoutubeFeed result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") Logd(TAG, "refreshYoutubeFeed result1: $channelTabInfo ${channelTabInfo.relatedItems.size}")
var infoItems = channelTabInfo.relatedItems
var nextPage = channelTabInfo.nextPage
val eList: RealmList<Episode> = realmListOf() val eList: RealmList<Episode> = 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) val feed_ = Feed(url, null)
feed_.type = Feed.FeedType.YOUTUBE.name feed_.type = Feed.FeedType.YOUTUBE.name
feed_.hasVideoMedia = true feed_.hasVideoMedia = true
@ -260,13 +288,32 @@ object FeedUpdateManager {
feed_.author = channelInfo.parentChannelName feed_.author = channelInfo.parentChannelName
feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
feed_.episodes = eList 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}") } } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed channel error1 ${e.message}") }
} else if (uURL.path.startsWith("/playlist")) { } 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<Episode> = realmListOf() val eList: RealmList<Episode> = realmListOf()
try { 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) val feed_ = Feed(url, null)
feed_.type = Feed.FeedType.YOUTUBE.name feed_.type = Feed.FeedType.YOUTUBE.name
feed_.hasVideoMedia = true feed_.hasVideoMedia = true
@ -276,14 +323,68 @@ object FeedUpdateManager {
feed_.author = playlistInfo.uploaderName feed_.author = playlistInfo.uploaderName
feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null
feed_.episodes = eList 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}") } } 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<Episode> = 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}") } } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed error ${e.message}") }
} }
@Throws(Exception::class) @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) val nextPage = (inputData.getBoolean(EXTRA_NEXT_PAGE, false) && feed.nextPageLink != null)
if (nextPage) feed.pageNr += 1 if (nextPage) feed.pageNr += 1
val builder = create(feed) val builder = create(feed)
@ -300,7 +401,7 @@ object FeedUpdateManager {
return return
} }
val feedUpdateTask = FeedUpdateTask(applicationContext, request) val feedUpdateTask = FeedUpdateTask(applicationContext, request)
val success = feedUpdateTask.run() val success = if (fullUpdate) feedUpdateTask.run() else feedUpdateTask.runSimple()
if (!success) { if (!success) {
Logd(TAG, "update failed: unsuccessful") Logd(TAG, "update failed: unsuccessful")
Feeds.persistFeedLastUpdateFailed(feed, true) Feeds.persistFeedLastUpdateFailed(feed, true)
@ -423,6 +524,14 @@ object FeedUpdateManager {
Feeds.updateFeed(context, feedHandlerResult!!.feed, false) Feeds.updateFeed(context, feedHandlerResult!!.feed, false)
return true return true
} }
fun runSimple(): Boolean {
feedHandlerResult = task.call()
if (!task.isSuccessful) return false
Feeds.updateFeedSimple(feedHandlerResult!!.feed)
return true
}
} }
} }
} }

View File

@ -1,16 +1,13 @@
package ac.mdiq.podcini.playback.base package ac.mdiq.podcini.playback.base
import ac.mdiq.podcini.R 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.download.service.PodciniHttpClient
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence 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.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope 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.FlowEvent.PlayEvent.Action
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ClientConfig 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.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.media.audiofx.LoudnessEnhancer import android.media.audiofx.LoudnessEnhancer
import android.net.Uri
import android.util.Log import android.util.Log
import android.util.Pair import android.util.Pair
import android.view.SurfaceHolder import android.view.SurfaceHolder
@ -37,23 +29,15 @@ import androidx.core.util.Consumer
import androidx.media3.common.* import androidx.media3.common.*
import androidx.media3.common.Player.* import androidx.media3.common.Player.*
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences 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.HttpDataSource.HttpDataSourceException
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters 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
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
import androidx.media3.exoplayer.trackselection.ExoTrackSelection 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.DefaultTrackNameProvider
import androidx.media3.ui.TrackNameProvider import androidx.media3.ui.TrackNameProvider
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -75,8 +59,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
private var seekLatch: CountDownLatch? = null private var seekLatch: CountDownLatch? = null
private val bufferUpdateInterval = 5000L private val bufferUpdateInterval = 5000L
private var mediaSource: MediaSource? = null
private var mediaItem: MediaItem? = null
private var playbackParameters: PlaybackParameters private var playbackParameters: PlaybackParameters
private var bufferedPercentagePrev = 0 private var bufferedPercentagePrev = 0
@ -113,8 +95,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
init { init {
if (httpDataSourceFactory == null) { if (httpDataSourceFactory == null) {
runOnIOScope { runOnIOScope {
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory).setUserAgent(ClientConfig.USER_AGENT)
.setUserAgent(ClientConfig.USER_AGENT)
} }
} }
if (exoPlayer == null) { if (exoPlayer == null) {
@ -164,130 +145,15 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
exoPlayer?.setAudioAttributes(b.build(), true) 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<MediaSource> = ArrayList()
mediaSources.add(vSource)
mediaSources.add(aSource)
mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray<MediaSource>())
// 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<String, String>()
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<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
preferVideoOnlyStreams: Boolean): List<VideoStream> {
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
val comparator = compareBy<VideoStream> { 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<AudioStream>?): List<AudioStream> {
if (audioStreams == null) return listOf()
val collectedStreams = mutableSetOf<AudioStream>()
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 * 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 * 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.
* not do anything.
* Whether playback starts immediately depends on the given parameters. See below for more details. * Whether playback starts immediately depends on the given parameters. See below for more details.
* States: * States:
* During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. * 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 * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state.
* 'startWhenPrepared' is set to true, the method will additionally go into PLAYING 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 * 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.
* will enter the ERROR state.
* This method is executed on an internal executor service. * 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 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 * @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) seekTo(newPosition)
} }
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
exoPlayer?.play() exoPlayer?.play()
// Can't set params when paused - so always set it on start in case they changed // Can't set params when paused - so always set it on start in case they changed
exoPlayer?.playbackParameters = playbackParameters exoPlayer?.playbackParameters = playbackParameters
@ -758,8 +623,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
const val BUFFERING_STARTED: Int = -1 const val BUFFERING_STARTED: Int = -1
const val BUFFERING_ENDED: Int = -2 const val BUFFERING_ENDED: Int = -2
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
private var trackSelector: DefaultTrackSelector? = null private var trackSelector: DefaultTrackSelector? = null
var exoPlayer: ExoPlayer? = null var exoPlayer: ExoPlayer? = null

View File

@ -1,16 +1,22 @@
package ac.mdiq.podcini.playback.base package ac.mdiq.podcini.playback.base
//import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed //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.curMedia
import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.preferences.UserPreferences.Prefs import ac.mdiq.podcini.preferences.UserPreferences.Prefs
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs 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.preferences.UserPreferences.setPlaybackSpeed
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.FeedPreferences import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.vista.extractor.MediaFormat
import ac.mdiq.podcini.util.showStackTrace 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.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
@ -24,6 +30,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata 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.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
@ -44,6 +59,9 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
private var oldStatus: PlayerStatus? = null private var oldStatus: PlayerStatus? = null
internal var prevMedia: Playable? = null internal var prevMedia: Playable? = null
protected var mediaSource: MediaSource? = null
protected var mediaItem: MediaItem? = null
internal var mediaType: MediaType = MediaType.UNKNOWN internal var mediaType: MediaType = MediaType.UNKNOWN
internal val startWhenPrepared = AtomicBoolean(false) internal val startWhenPrepared = AtomicBoolean(false)
@ -96,6 +114,128 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
open fun createMediaPlayer() {} 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<MediaSource> = ArrayList()
mediaSources.add(vSource)
mediaSources.add(aSource)
mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray<MediaSource>())
// 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<String, String>()
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<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
preferVideoOnlyStreams: Boolean): List<VideoStream> {
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
val comparator = compareBy<VideoStream> { 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<AudioStream>?): List<AudioStream> {
if (audioStreams == null) return listOf()
val collectedStreams = mutableSetOf<AudioStream>()
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 * 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 * 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" private val TAG: String = MediaPlayerBase::class.simpleName ?: "Anonymous"
@get:Synchronized @get:Synchronized
// @Volatile
@JvmStatic @JvmStatic
var status by mutableStateOf(PlayerStatus.STOPPED) var status by mutableStateOf(PlayerStatus.STOPPED)
@ -310,6 +449,9 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@JvmField @JvmField
val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20) val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20)
@JvmStatic
var httpDataSourceFactory: OkHttpDataSource.Factory? = null
val prefPlaybackSpeed: Float val prefPlaybackSpeed: Float
get() { get() {
try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat() try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat()

View File

@ -42,6 +42,9 @@ import kotlin.math.min
object Episodes { object Episodes {
private val TAG: String = Episodes::class.simpleName ?: "Anonymous" 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 offset The first episode that should be loaded.
* @param limit The maximum number of episodes 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() 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. * Loads a specific FeedItem from the database.
* @param guid feed episode guid * @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 { fun deleteMediaSync(context: Context, episode: Episode): Episode {
Logd(TAG, "deleteMediaSync called") Logd(TAG, "deleteMediaSync called")
val media = episode.media ?: return episode val media = episode.media ?: return episode
@ -179,7 +169,6 @@ object Episodes {
* Remove the listed episodes and their EpisodeMedia entries. * Remove the listed episodes and their EpisodeMedia entries.
* Deleting media also removes the download log entries. * Deleting media also removes the download log entries.
*/ */
fun deleteEpisodes(context: Context, episodes: List<Episode>) : Job { fun deleteEpisodes(context: Context, episodes: List<Episode>) : Job {
return runOnIOScope { return runOnIOScope {
val removedFromQueue: MutableList<Episode> = mutableListOf() val removedFromQueue: MutableList<Episode> = mutableListOf()
@ -224,7 +213,6 @@ object Episodes {
* @param episodes the FeedItems. * @param episodes the FeedItems.
* @param resetMediaPosition true if this method should also reset the position of the Episode's EpisodeMedia object. * @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 { fun setPlayState(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job {
Logd(TAG, "setPlayState called") Logd(TAG, "setPlayState called")
return runOnIOScope { return runOnIOScope {
@ -253,13 +241,6 @@ object Episodes {
return result 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 { fun episodeFromStreamInfoItem(item: StreamInfoItem): Episode {
val e = Episode() val e = Episode()
e.link = item.url e.link = item.url

View File

@ -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. * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise.
*/ */
@Synchronized @Synchronized
fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? { fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean= false): Feed? {
Logd(TAG, "updateFeed called") Logd(TAG, "updateFeed called")
var resultFeed: Feed? var resultFeed: Feed?
// val unlistedItems: MutableList<Episode> = ArrayList() // val unlistedItems: MutableList<Episode> = ArrayList()
@ -237,8 +237,6 @@ object Feeds {
val priorMostRecent = savedFeed.mostRecentItem val priorMostRecent = savedFeed.mostRecentItem
val priorMostRecentDate: Date? = priorMostRecent?.getPubDate() val priorMostRecentDate: Date? = priorMostRecent?.getPubDate()
var idLong = Feed.newId() var idLong = Feed.newId()
Logd(TAG, "updateFeed building newFeedAssistant")
val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id)
Logd(TAG, "updateFeed building savedFeedAssistant") Logd(TAG, "updateFeed building savedFeedAssistant")
val savedFeedAssistant = FeedAssistant(savedFeed) val savedFeedAssistant = FeedAssistant(savedFeed)
@ -293,7 +291,6 @@ object Feeds {
if (pubDate == null || priorMostRecentDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) { if (pubDate == null || priorMostRecentDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) {
Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate") Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate")
episode.playState = PlayState.NEW.code episode.playState = PlayState.NEW.code
// episode.setNew()
if (savedFeed.preferences?.autoAddNewToQueue == true) { if (savedFeed.preferences?.autoAddNewToQueue == true) {
val q = savedFeed.preferences?.queue val q = savedFeed.preferences?.queue
if (q != null) runOnIOScope { addToQueueSync(episode, q) } if (q != null) runOnIOScope { addToQueueSync(episode, q) }
@ -306,6 +303,8 @@ object Feeds {
val unlistedItems: MutableList<Episode> = ArrayList() val unlistedItems: MutableList<Episode> = ArrayList()
// identify episodes to be removed // identify episodes to be removed
if (removeUnlistedItems) { if (removeUnlistedItems) {
Logd(TAG, "updateFeed building newFeedAssistant")
val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id)
val it = savedFeed.episodes.toMutableList().iterator() val it = savedFeed.episodes.toMutableList().iterator()
while (it.hasNext()) { while (it.hasNext()) {
val feedItem = it.next() val feedItem = it.next()
@ -314,19 +313,17 @@ object Feeds {
it.remove() it.remove()
} }
} }
}
newFeedAssistant.clear() newFeedAssistant.clear()
}
// update attributes // update attributes
savedFeed.lastUpdate = newFeed.lastUpdate savedFeed.lastUpdate = newFeed.lastUpdate
savedFeed.type = newFeed.type savedFeed.type = newFeed.type
savedFeed.lastUpdateFailed = false savedFeed.lastUpdateFailed = false
resultFeed = savedFeed
savedFeed.totleDuration = 0 savedFeed.totleDuration = 0
for (e in savedFeed.episodes) { for (e in savedFeed.episodes) savedFeed.totleDuration += e.media?.duration ?: 0
savedFeed.totleDuration += e.media?.duration ?: 0
} resultFeed = savedFeed
try { try {
upsertBlk(savedFeed) {} upsertBlk(savedFeed) {}
if (removeUnlistedItems && unlistedItems.isNotEmpty()) runBlocking { deleteEpisodes(context, unlistedItems).join() } if (removeUnlistedItems && unlistedItems.isNotEmpty()) runBlocking { deleteEpisodes(context, unlistedItems).join() }
@ -335,6 +332,69 @@ object Feeds {
return resultFeed 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 { fun persistFeedLastUpdateFailed(feed: Feed, lastUpdateFailed: Boolean) : Job {
Logd(TAG, "persistFeedLastUpdateFailed called") Logd(TAG, "persistFeedLastUpdateFailed called")
return runOnIOScope { return runOnIOScope {

View File

@ -40,7 +40,7 @@ object RealmDB {
SubscriptionLog::class, SubscriptionLog::class,
Chapter::class)) Chapter::class))
.name("Podcini.realm") .name("Podcini.realm")
.schemaVersion(28) .schemaVersion(29)
.migration({ mContext -> .migration({ mContext ->
val oldRealm = mContext.oldRealm // old realm using the previous schema val oldRealm = mContext.oldRealm // old realm using the previous schema
val newRealm = mContext.newRealm // new realm using the new schema val newRealm = mContext.newRealm // new realm using the new schema

View File

@ -144,6 +144,10 @@ class Feed : RealmObject {
val mostRecentItem: Episode? 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 @Ignore
var title: String? var title: String?
get() = if (!customTitle.isNullOrEmpty()) customTitle else eigenTitle get() = if (!customTitle.isNullOrEmpty()) customTitle else eigenTitle

View File

@ -48,7 +48,7 @@ class FeedPreferences : EmbeddedRealmObject {
field = value field = value
autoDelete = field.code autoDelete = field.code
} }
var autoDelete: Int = 0 var autoDelete: Int = AutoDeleteAction.GLOBAL.code
@Ignore @Ignore
var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF
@ -57,7 +57,25 @@ class FeedPreferences : EmbeddedRealmObject {
field = value field = value
volumeAdaption = field.toInteger() 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 var prefStreamOverDownload: Boolean = false
@ -132,7 +150,7 @@ class FeedPreferences : EmbeddedRealmObject {
field = value field = value
autoDLPolicyCode = value.code autoDLPolicyCode = value.code
} }
var autoDLPolicyCode: Int = 0 var autoDLPolicyCode: Int = AutoDownloadPolicy.ONLY_NEW.code
constructor() {} 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<AVQuality>().firstOrNull { it.code == code } ?: GLOBAL
}
fun fromTag(tag: String): AVQuality {
return enumValues<AVQuality>().firstOrNull { it.tag == tag } ?: GLOBAL
}
}
}
companion object { companion object {
const val SPEED_USE_GLOBAL: Float = -1f const val SPEED_USE_GLOBAL: Float = -1f
const val TAG_ROOT: String = "#root" const val TAG_ROOT: String = "#root"

View File

@ -2,7 +2,7 @@ package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.R 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), OFF(0, 1.0f, R.string.feed_volume_reduction_off),
LIGHT_REDUCTION(1, 0.5f, R.string.feed_volume_reduction_light), LIGHT_REDUCTION(1, 0.5f, R.string.feed_volume_reduction_light),
HEAVY_REDUCTION(2, 0.2f, R.string.feed_volume_reduction_heavy), HEAVY_REDUCTION(2, 0.2f, R.string.feed_volume_reduction_heavy),

View File

@ -997,14 +997,15 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDi
@Composable @Composable
fun EpisodesFilterDialog(filter: EpisodeFilter? = null, filtersDisabled: MutableSet<EpisodeFilter.EpisodesFilterGroup> = mutableSetOf(), fun EpisodesFilterDialog(filter: EpisodeFilter? = null, filtersDisabled: MutableSet<EpisodeFilter.EpisodesFilterGroup> = mutableSetOf(),
onDismissRequest: () -> Unit, onFilterChanged: (Set<String>) -> Unit) { onDismissRequest: () -> Unit, onFilterChanged: (Set<String>) -> Unit) {
val filterValues: MutableSet<String> = mutableSetOf() val filterValues = remember { filter?.properties ?: mutableSetOf() }
Dialog(properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { onDismissRequest() }) { Dialog(properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { onDismissRequest() }) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window -> dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.BOTTOM) window.setGravity(Gravity.BOTTOM)
window.setDimAmount(0f) 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 textColor = MaterialTheme.colorScheme.onSurface
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { Column(Modifier.fillMaxSize().verticalScroll(scrollState)) {

View File

@ -179,8 +179,8 @@ import java.util.concurrent.Semaphore
runOnIOScope { runOnIOScope {
val feed_ = realm.query(Feed::class, "id == ${feed!!.id}").first().find() val feed_ = realm.query(Feed::class, "id == ${feed!!.id}").first().find()
if (feed_ != null) { if (feed_ != null) {
upsert(feed_) { it.preferences?.filterString = filterValues.joinToString() } feed = upsert(feed_) { it.preferences?.filterString = filterValues.joinToString() }
loadFeed() // loadFeed()
} }
} }
} }

View File

@ -38,23 +38,19 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.* import java.util.*
@ -110,9 +106,7 @@ class FeedSettingsFragment : Fragment() {
Switch(checked = checked, modifier = Modifier.height(24.dp), Switch(checked = checked, modifier = Modifier.height(24.dp),
onCheckedChange = { onCheckedChange = {
checked = it checked = it
feed = upsertBlk(feed!!) { f -> feed = upsertBlk(feed!!) { f -> f.preferences?.keepUpdated = checked }
f.preferences?.keepUpdated = checked
}
} }
) )
} }
@ -142,21 +136,55 @@ class FeedSettingsFragment : Fragment() {
Spacer(modifier = Modifier.width(20.dp)) Spacer(modifier = Modifier.width(20.dp))
Text(text = stringResource(R.string.pref_stream_over_download_title), style = MaterialTheme.typography.titleLarge, color = textColor) Text(text = stringResource(R.string.pref_stream_over_download_title), style = MaterialTheme.typography.titleLarge, color = textColor)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
var checked by remember { var checked by remember { mutableStateOf(feed?.preferences?.prefStreamOverDownload ?: false) }
mutableStateOf(feed?.preferences?.prefStreamOverDownload ?: false)
}
Switch(checked = checked, modifier = Modifier.height(24.dp), Switch(checked = checked, modifier = Modifier.height(24.dp),
onCheckedChange = { onCheckedChange = {
checked = it checked = it
feed = upsertBlk(feed!!) { f -> feed = upsertBlk(feed!!) { f -> f.preferences?.prefStreamOverDownload = checked }
f.preferences?.prefStreamOverDownload = checked
}
} }
) )
} }
Text(text = stringResource(R.string.pref_stream_over_download_sum), style = MaterialTheme.typography.bodyMedium, color = textColor) 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 // associated queue
Column { Column {
curPrefQueue = feed?.preferences?.queueTextExt ?: "Default" curPrefQueue = feed?.preferences?.queueTextExt ?: "Default"
@ -187,9 +215,7 @@ class FeedSettingsFragment : Fragment() {
Switch(checked = checked, modifier = Modifier.height(24.dp), Switch(checked = checked, modifier = Modifier.height(24.dp),
onCheckedChange = { onCheckedChange = {
checked = it checked = it
feed = upsertBlk(feed!!) { f -> feed = upsertBlk(feed!!) { f -> f.preferences?.autoAddNewToQueue = checked }
f.preferences?.autoAddNewToQueue = checked
}
} }
) )
} }
@ -230,10 +256,7 @@ class FeedSettingsFragment : Fragment() {
Icon(ImageVector.vectorResource(id = R.drawable.ic_playback_speed), "", tint = textColor) Icon(ImageVector.vectorResource(id = R.drawable.ic_playback_speed), "", tint = textColor)
Spacer(modifier = Modifier.width(20.dp)) Spacer(modifier = Modifier.width(20.dp))
Text(text = stringResource(R.string.playback_speed), style = MaterialTheme.typography.titleLarge, color = textColor, Text(text = stringResource(R.string.playback_speed), style = MaterialTheme.typography.titleLarge, color = textColor,
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = { PlaybackSpeedDialog().show() }))
PlaybackSpeedDialog().show()
})
)
} }
Text(text = stringResource(R.string.pref_feed_playback_speed_sum), style = MaterialTheme.typography.bodyMedium, color = textColor) Text(text = stringResource(R.string.pref_feed_playback_speed_sum), style = MaterialTheme.typography.bodyMedium, color = textColor)
} }
@ -286,13 +309,11 @@ class FeedSettingsFragment : Fragment() {
onCheckedChange = { onCheckedChange = {
audoDownloadChecked = it audoDownloadChecked = it
feed = upsertBlk(feed!!) { f -> f.preferences?.autoDownload = audoDownloadChecked } 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) Text(text = stringResource(R.string.auto_download_disabled_globally), style = MaterialTheme.typography.bodyMedium, color = textColor)
} }
}
if (audoDownloadChecked) { if (audoDownloadChecked) {
// auto download policy // auto download policy
Column (modifier = Modifier.padding(start = 20.dp)){ Column (modifier = Modifier.padding(start = 20.dp)){
@ -300,10 +321,7 @@ class FeedSettingsFragment : Fragment() {
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
if (showDialog.value) AutoDownloadPolicyDialog(showDialog.value, onDismissRequest = { showDialog.value = 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, Text(text = stringResource(R.string.feed_auto_download_policy), style = MaterialTheme.typography.titleLarge, color = textColor,
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = { showDialog.value = true }))
showDialog.value = true
})
)
} }
} }
// episode cache // episode cache
@ -312,8 +330,7 @@ class FeedSettingsFragment : Fragment() {
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
if (showDialog.value) SetEpisodesCacheDialog(showDialog.value, onDismiss = { showDialog.value = 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, 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) 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()) { Row(Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.pref_auto_download_counting_played_title), style = MaterialTheme.typography.titleLarge, color = textColor) Text(text = stringResource(R.string.pref_auto_download_counting_played_title), style = MaterialTheme.typography.titleLarge, color = textColor)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
var checked by remember { var checked by remember { mutableStateOf(feed?.preferences?.countingPlayed ?: true) }
mutableStateOf(feed?.preferences?.countingPlayed ?: true)
}
Switch(checked = checked, modifier = Modifier.height(24.dp), Switch(checked = checked, modifier = Modifier.height(24.dp),
onCheckedChange = { onCheckedChange = {
checked = it checked = it
feed = upsertBlk(feed!!) { f -> feed = upsertBlk(feed!!) { f -> f.preferences?.countingPlayed = checked }
f.preferences?.countingPlayed = checked
}
} }
) )
} }
@ -341,14 +354,9 @@ class FeedSettingsFragment : Fragment() {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.episode_inclusive_filters_label), style = MaterialTheme.typography.titleLarge, color = textColor, Text(text = stringResource(R.string.episode_inclusive_filters_label), style = MaterialTheme.typography.titleLarge, color = textColor,
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = {
object : AutoDownloadFilterPrefDialog(requireContext(), object : AutoDownloadFilterPrefDialog(requireContext(), feed?.preferences!!.autoDownloadFilter!!, 1) {
feed?.preferences!!.autoDownloadFilter!!,
1) {
override fun onConfirmed(filter: FeedAutoDownloadFilter) { override fun onConfirmed(filter: FeedAutoDownloadFilter) {
feed = upsertBlk(feed!!) { feed = upsertBlk(feed!!) { it.preferences?.autoDownloadFilter = filter }
it.preferences?.autoDownloadFilter = filter
}
} }
}.show() }.show()
}) })
@ -361,14 +369,9 @@ class FeedSettingsFragment : Fragment() {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.episode_exclusive_filters_label), style = MaterialTheme.typography.titleLarge, color = textColor, Text(text = stringResource(R.string.episode_exclusive_filters_label), style = MaterialTheme.typography.titleLarge, color = textColor,
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = {
object : AutoDownloadFilterPrefDialog(requireContext(), object : AutoDownloadFilterPrefDialog(requireContext(), feed?.preferences!!.autoDownloadFilter!!, -1) {
feed?.preferences!!.autoDownloadFilter!!,
-1) {
override fun onConfirmed(filter: FeedAutoDownloadFilter) { override fun onConfirmed(filter: FeedAutoDownloadFilter) {
feed = upsertBlk(feed!!) { feed = upsertBlk(feed!!) { it.preferences?.autoDownloadFilter = filter }
it.preferences?.autoDownloadFilter = filter
}
} }
}.show() }.show()
}) })
@ -543,11 +546,7 @@ class FeedSettingsFragment : Fragment() {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column { Column {
AutoDownloadPolicy.entries.forEach { item -> AutoDownloadPolicy.entries.forEach { item ->
Row(Modifier Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = (item == selectedOption), Checkbox(checked = (item == selectedOption),
onCheckedChange = { onCheckedChange = {
Logd(TAG, "row clicked: $item $selectedOption") Logd(TAG, "row clicked: $item $selectedOption")
@ -577,18 +576,13 @@ class FeedSettingsFragment : Fragment() {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var newCache by remember { mutableStateOf((feed?.preferences?.autoDLMaxEpisodes ?: 1).toString()) } var newCache by remember { mutableStateOf((feed?.preferences?.autoDLMaxEpisodes ?: 1).toString()) }
TextField(value = newCache, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) newCache = it }, TextField(value = newCache, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) newCache = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("Max episodes allowed") })
// visualTransformation = PositiveIntegerTransform(),
label = { Text("Max episodes allowed") }
)
Button(onClick = { Button(onClick = {
if (newCache.isNotEmpty()) { if (newCache.isNotEmpty()) {
feed = upsertBlk(feed!!) { it.preferences?.autoDLMaxEpisodes = newCache.toIntOrNull() ?: 1 } feed = upsertBlk(feed!!) { it.preferences?.autoDLMaxEpisodes = newCache.toIntOrNull() ?: 1 }
onDismiss() 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 @Composable
fun AuthenticationDialog(showDialog: Boolean, onDismiss: () -> Unit) { fun AuthenticationDialog(showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) { if (showDialog) {
@ -669,9 +747,7 @@ class FeedSettingsFragment : Fragment() {
Thread({ runOnce(requireContext(), feed) }, "RefreshAfterCredentialChange").start() Thread({ runOnce(requireContext(), feed) }, "RefreshAfterCredentialChange").start()
onDismiss() 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)) { 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(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var intro by remember { mutableStateOf((feed?.preferences?.introSkip ?: 0).toString()) } var intro by remember { mutableStateOf((feed?.preferences?.introSkip ?: 0).toString()) }
TextField(value = intro, TextField(value = intro, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) intro = it },
onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) intro = it }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("Skip first (seconds)") })
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
// visualTransformation = PositiveIntegerTransform(),
label = { Text("Skip first (seconds)") }
)
var ending by remember { mutableStateOf((feed?.preferences?.endingSkip ?: 0).toString()) } var ending by remember { mutableStateOf((feed?.preferences?.endingSkip ?: 0).toString()) }
TextField(value = ending, TextField(value = ending, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) ending = it },
onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) ending = it }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), label = { Text("Skip last (seconds)") })
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
// visualTransformation = PositiveIntegerTransform(),
label = { Text("Skip last (seconds)") }
)
Button(onClick = { Button(onClick = {
if (intro.isNotEmpty() || ending.isNotEmpty()) { if (intro.isNotEmpty() || ending.isNotEmpty()) {
feed = upsertBlk(feed!!) { feed = upsertBlk(feed!!) {
@ -706,9 +774,7 @@ class FeedSettingsFragment : Fragment() {
} }
onDismiss() onDismiss()
} }
}) { }) { Text("Confirm") }
Text("Confirm")
}
} }
} }
} }
@ -728,9 +794,7 @@ class FeedSettingsFragment : Fragment() {
val speed = feed?.preferences!!.playSpeed val speed = feed?.preferences!!.playSpeed
binding.useGlobalCheckbox.isChecked = speed == FeedPreferences.SPEED_USE_GLOBAL binding.useGlobalCheckbox.isChecked = speed == FeedPreferences.SPEED_USE_GLOBAL
binding.seekBar.updateSpeed(if (speed == FeedPreferences.SPEED_USE_GLOBAL) 1f else speed) binding.seekBar.updateSpeed(if (speed == FeedPreferences.SPEED_USE_GLOBAL) 1f else speed)
return MaterialAlertDialogBuilder(requireContext()) return MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.playback_speed).setView(binding.root)
.setTitle(R.string.playback_speed)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
val newSpeed = if (binding.useGlobalCheckbox.isChecked) FeedPreferences.SPEED_USE_GLOBAL val newSpeed = if (binding.useGlobalCheckbox.isChecked) FeedPreferences.SPEED_USE_GLOBAL
else binding.seekBar.currentSpeed else binding.seekBar.currentSpeed
@ -832,9 +896,7 @@ class FeedSettingsFragment : Fragment() {
protected abstract fun onConfirmed(filter: FeedAutoDownloadFilter) protected abstract fun onConfirmed(filter: FeedAutoDownloadFilter)
private fun toFilterString(words: List<String>?): String { private fun toFilterString(words: List<String>?): String {
val result = StringBuilder() val result = StringBuilder()
for (word in words!!) { for (word in words!!) result.append("\"").append(word).append("\" ")
result.append("\"").append(word).append("\" ")
}
return result.toString() return result.toString()
} }
} }

View File

@ -297,7 +297,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
feed.preferences!!.videoModePolicy = VideoMode.WINDOW_VIEW feed.preferences!!.videoModePolicy = VideoMode.WINDOW_VIEW
CustomFeedNameDialog(activity as Activity, feed).show() 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!! R.id.toggle_grid_list -> useGrid = if (useGrid == null) !useGridLayout else !useGrid!!
else -> return false else -> return false
} }
@ -961,7 +961,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@Composable @Composable
fun FilterDialog(filter: FeedFilter? = null, onDismissRequest: () -> Unit) { fun FilterDialog(filter: FeedFilter? = null, onDismissRequest: () -> Unit) {
val filterValues: MutableSet<String> = mutableSetOf() val filterValues = remember { filter?.properties ?: mutableSetOf() }
fun onFilterChanged(newFilterValues: Set<String>) { fun onFilterChanged(newFilterValues: Set<String>) {
feedsFilter = StringUtils.join(newFilterValues, ",") feedsFilter = StringUtils.join(newFilterValues, ",")
@ -975,7 +975,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
window.setGravity(Gravity.BOTTOM) window.setGravity(Gravity.BOTTOM)
window.setDimAmount(0f) 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 textColor = MaterialTheme.colorScheme.onSurface
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { Column(Modifier.fillMaxSize().verticalScroll(scrollState)) {

View File

@ -575,6 +575,10 @@
<string name="pref_feed_skip">Auto skip</string> <string name="pref_feed_skip">Auto skip</string>
<string name="pref_feed_skip_sum">Skip introductions and ending credits.</string> <string name="pref_feed_skip_sum">Skip introductions and ending credits.</string>
<string name="associated">Associated</string> <string name="associated">Associated</string>
<string name="pref_feed_audio_quality">Audio quality</string>
<string name="pref_feed_audio_quality_sum">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.</string>
<string name="pref_feed_video_quality">Video quality</string>
<string name="pref_feed_video_quality_sum">Global equals low quality, Quality setting here takes precedence over the setting of prefLowQualityMedia for metered network.</string>
<string name="pref_feed_associated_queue">Associated queue</string> <string name="pref_feed_associated_queue">Associated queue</string>
<string name="pref_feed_associated_queue_sum">The queue to which epiosdes in the feed will added by default</string> <string name="pref_feed_associated_queue_sum">The queue to which epiosdes in the feed will added by default</string>
<string name="pref_feed_skip_ending">Skip last</string> <string name="pref_feed_skip_ending">Skip last</string>

View File

@ -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.MediaPlayerBase
import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.PlayerStatus 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.EpisodeMedia
import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia 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.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.util.Log import android.util.Log
import com.google.android.gms.cast.* import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.CastContext 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.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability 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 java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.math.max import kotlin.math.max
@ -194,7 +202,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
* *
* @see .playMediaObject * @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)) { if (!CastUtils.isCastable(playable, castContext.sessionManager.currentCastSession)) {
Logd(TAG, "media provided is not compatible with cast device") Logd(TAG, "media provided is not compatible with cast device")
EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media 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) do { nextPlayable = callback.getNextInQueue(nextPlayable)
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession)) } 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 return
} }
@ -235,6 +243,53 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
this.mediaType = curMedia!!.getMediaType() this.mediaType = curMedia!!.getMediaType()
this.startWhenPrepared.set(startWhenPrepared) this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) 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.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(true) callback.onMediaChanged(true)
setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
@ -266,7 +321,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
override fun reinit() { override fun reinit() {
Logd(TAG, "reinit() called") 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") 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) callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode)
// setting media to null signals to playMediaObject() that we're taking care of post-playback processing // setting media to null signals to playMediaObject() that we're taking care of post-playback processing
curMedia = null curMedia = null
playMediaObject(nextMedia, stream = true, startWhenPrepared = playNextEpisode, prepareImmediately = playNextEpisode, forceReset = false) playMediaObject(nextMedia, streaming = true, startWhenPrepared = playNextEpisode, prepareImmediately = playNextEpisode, forceReset = false)
} }
} }
when { when {

View File

@ -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 # 6.13.5
* hopefully fixed youtube playlists/podcasts no-updating issue * hopefully fixed youtube playlists/podcasts no-updating issue

View File

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

View File

@ -49,7 +49,6 @@ rxandroid = "3.0.2"
rxjava = "2.2.21" rxjava = "2.2.21"
rxjavaVersion = "3.1.8" rxjavaVersion = "3.1.8"
searchpreference = "v2.5.0" searchpreference = "v2.5.0"
#stream = "1.2.2"
uiToolingPreview = "1.7.5" uiToolingPreview = "1.7.5"
uiTooling = "1.7.5" uiTooling = "1.7.5"
viewpager2 = "1.1.0" 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" } rxjava3-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjavaVersion" }
rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" } rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" }
searchpreference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchpreference" } 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" } 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" } 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" } wearable = { module = "com.google.android.wearable:wearable", version.ref = "wearable" }