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.generatedDensities = []
versionCode 3020292
versionName "6.13.5"
versionCode 3020293
versionName "6.13.6"
applicationId "ac.mdiq.podcini.R"
def commit = ""
@ -239,7 +239,6 @@ dependencies {
implementation libs.searchpreference
implementation libs.balloon
// implementation libs.stream
implementation libs.fyydlin

View File

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

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

View File

@ -1,16 +1,13 @@
package ac.mdiq.podcini.playback.base
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
@ -21,15 +18,10 @@ import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.vista.extractor.MediaFormat
import ac.mdiq.vista.extractor.stream.AudioStream
import ac.mdiq.vista.extractor.stream.DeliveryMethod
import ac.mdiq.vista.extractor.stream.VideoStream
import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import android.media.audiofx.LoudnessEnhancer
import android.net.Uri
import android.util.Log
import android.util.Pair
import android.view.SurfaceHolder
@ -37,23 +29,15 @@ import androidx.core.util.Consumer
import androidx.media3.common.*
import androidx.media3.common.Player.*
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mp3.Mp3Extractor
import androidx.media3.ui.DefaultTrackNameProvider
import androidx.media3.ui.TrackNameProvider
import kotlinx.coroutines.*
@ -75,8 +59,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
private var seekLatch: CountDownLatch? = null
private val bufferUpdateInterval = 5000L
private var mediaSource: MediaSource? = null
private var mediaItem: MediaItem? = null
private var playbackParameters: PlaybackParameters
private var bufferedPercentagePrev = 0
@ -113,8 +95,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
init {
if (httpDataSourceFactory == null) {
runOnIOScope {
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
.setUserAgent(ClientConfig.USER_AGENT)
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory).setUserAgent(ClientConfig.USER_AGENT)
}
}
if (exoPlayer == null) {
@ -164,130 +145,15 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
exoPlayer?.setAudioAttributes(b.build(), true)
}
@Throws(IllegalArgumentException::class, IllegalStateException::class)
private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) {
Logd(TAG, "setDataSource: $mediaUrl")
mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build()
mediaSource = null
setSourceCredentials(user, password)
}
@Throws(IllegalArgumentException::class, IllegalStateException::class)
private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) {
Logd(TAG, "setDataSource1 called")
val url = media.getStreamUrl() ?: return
val preferences = media.episodeOrFetch()?.feed?.preferences
val user = preferences?.username
val password = preferences?.password
if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) {
Logd(TAG, "setDataSource1 setting for YouTube source")
try {
// val vService = Vista.getService(0)
val streamInfo = media.episode!!.streamInfo ?: return
val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
val audioIndex = if (isNetworkRestricted && prefLowQualityMedia) 0 else audioStreamsList.size - 1
val audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
Logd(TAG, "setDataSource1 result: $streamInfo")
Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}")
val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true)
val videoIndex = 0
val videoStream = videoStreamsList[videoIndex]
Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}")
val vSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
val mediaSources: MutableList<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
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
* not do anything.
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will not do anything.
* Whether playback starts immediately depends on the given parameters. See below for more details.
* States:
* During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
* If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
* 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
* If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
* will enter the ERROR state.
* If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state.
* If 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
* If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object will enter the ERROR state.
* This method is executed on an internal executor service.
* @param playable The Playable object that is supposed to be played. This parameter must not be null.
* @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via
@ -397,7 +263,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
seekTo(newPosition)
}
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
exoPlayer?.play()
// Can't set params when paused - so always set it on start in case they changed
exoPlayer?.playbackParameters = playbackParameters
@ -758,8 +623,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
const val BUFFERING_STARTED: Int = -1
const val BUFFERING_ENDED: Int = -2
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
private var trackSelector: DefaultTrackSelector? = null
var exoPlayer: ExoPlayer? = null

View File

@ -1,16 +1,22 @@
package ac.mdiq.podcini.playback.base
//import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.preferences.UserPreferences.Prefs
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia
import ac.mdiq.podcini.preferences.UserPreferences.setPlaybackSpeed
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.util.showStackTrace
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.vista.extractor.MediaFormat
import ac.mdiq.vista.extractor.stream.AudioStream
import ac.mdiq.vista.extractor.stream.DeliveryMethod
import ac.mdiq.vista.extractor.stream.VideoStream
import android.content.Context
import android.media.AudioManager
import android.net.Uri
@ -24,6 +30,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mp3.Mp3Extractor
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.Volatile
@ -44,6 +59,9 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
private var oldStatus: PlayerStatus? = null
internal var prevMedia: Playable? = null
protected var mediaSource: MediaSource? = null
protected var mediaItem: MediaItem? = null
internal var mediaType: MediaType = MediaType.UNKNOWN
internal val startWhenPrepared = AtomicBoolean(false)
@ -96,6 +114,128 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
open fun createMediaPlayer() {}
@Throws(IllegalArgumentException::class, IllegalStateException::class)
protected fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) {
Logd(TAG, "setDataSource: $mediaUrl")
mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build()
mediaSource = null
setSourceCredentials(user, password)
}
@Throws(IllegalArgumentException::class, IllegalStateException::class)
protected fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) {
Logd(TAG, "setDataSource1 called")
val url = media.getStreamUrl() ?: return
val preferences = media.episodeOrFetch()?.feed?.preferences
val user = preferences?.username
val password = preferences?.password
if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) {
Logd(TAG, "setDataSource1 setting for YouTube source")
try {
val streamInfo = media.episode!!.streamInfo ?: return
val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
val audioIndex = if (isNetworkRestricted && prefLowQualityMedia && media.episode?.feed?.preferences?.audioQualitySetting == FeedPreferences.AVQuality.GLOBAL) 0 else {
when (media.episode?.feed?.preferences?.audioQualitySetting) {
FeedPreferences.AVQuality.LOW -> 0
FeedPreferences.AVQuality.MEDIUM -> audioStreamsList.size / 2
FeedPreferences.AVQuality.HIGH -> audioStreamsList.size - 1
else -> audioStreamsList.size - 1
}
}
val audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
Logd(TAG, "setDataSource1 result: $streamInfo")
Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}")
val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true)
val videoIndex = if (isNetworkRestricted && prefLowQualityMedia && media.episode?.feed?.preferences?.videoQualitySetting == FeedPreferences.AVQuality.GLOBAL) 0 else {
when (media.episode?.feed?.preferences?.videoQualitySetting) {
FeedPreferences.AVQuality.LOW -> 0
FeedPreferences.AVQuality.MEDIUM -> videoStreamsList.size / 2
FeedPreferences.AVQuality.HIGH -> videoStreamsList.size - 1
else -> 0
}
}
val videoStream = videoStreamsList[videoIndex]
Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}")
val vSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
val mediaSources: MutableList<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
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
@ -292,7 +432,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
private val TAG: String = MediaPlayerBase::class.simpleName ?: "Anonymous"
@get:Synchronized
// @Volatile
@JvmStatic
var status by mutableStateOf(PlayerStatus.STOPPED)
@ -310,6 +449,9 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@JvmField
val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20)
@JvmStatic
var httpDataSourceFactory: OkHttpDataSource.Factory? = null
val prefPlaybackSpeed: Float
get() {
try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat()

View File

@ -42,6 +42,9 @@ import kotlin.math.min
object Episodes {
private val TAG: String = Episodes::class.simpleName ?: "Anonymous"
val prefRemoveFromQueueMarkedPlayed by lazy { appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true) }
val prefDeleteRemovesFromQueue by lazy { appPrefs.getBoolean(Prefs.prefDeleteRemovesFromQueue.name, false) }
/**
* @param offset The first episode that should be loaded.
* @param limit The maximum number of episodes that should be loaded.
@ -64,13 +67,6 @@ object Episodes {
return realm.query(Episode::class).query(queryString).count().find().toInt()
}
// used in tests only
fun getEpisode(itemId: Long): Episode? {
Logd(TAG, "getFeedItem called with id $itemId")
val it = realm.query(Episode::class).query("id == $0", itemId).first().find()
return if (it != null) realm.copyFromRealm(it) else null
}
/**
* Loads a specific FeedItem from the database.
* @param guid feed episode guid
@ -104,12 +100,6 @@ object Episodes {
}
}
val prefDeleteRemovesFromQueue: Boolean
get() = appPrefs.getBoolean(Prefs.prefDeleteRemovesFromQueue.name, false)
// fun shouldDeleteRemoveFromQueue(): Boolean {
// return appPrefs.getBoolean(Prefs.prefDeleteRemovesFromQueue.name, false)
// }
fun deleteMediaSync(context: Context, episode: Episode): Episode {
Logd(TAG, "deleteMediaSync called")
val media = episode.media ?: return episode
@ -179,7 +169,6 @@ object Episodes {
* Remove the listed episodes and their EpisodeMedia entries.
* Deleting media also removes the download log entries.
*/
fun deleteEpisodes(context: Context, episodes: List<Episode>) : Job {
return runOnIOScope {
val removedFromQueue: MutableList<Episode> = mutableListOf()
@ -224,7 +213,6 @@ object Episodes {
* @param episodes the FeedItems.
* @param resetMediaPosition true if this method should also reset the position of the Episode's EpisodeMedia object.
*/
fun setPlayState(played: Int, resetMediaPosition: Boolean, vararg episodes: Episode) : Job {
Logd(TAG, "setPlayState called")
return runOnIOScope {
@ -253,13 +241,6 @@ object Episodes {
return result
}
val prefRemoveFromQueueMarkedPlayed: Boolean
get() = appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true)
// fun shouldMarkedPlayedRemoveFromQueues(): Boolean {
// return appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true)
// }
fun episodeFromStreamInfoItem(item: StreamInfoItem): Episode {
val e = Episode()
e.link = item.url

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.
*/
@Synchronized
fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? {
fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean= false): Feed? {
Logd(TAG, "updateFeed called")
var resultFeed: Feed?
// val unlistedItems: MutableList<Episode> = ArrayList()
@ -237,8 +237,6 @@ object Feeds {
val priorMostRecent = savedFeed.mostRecentItem
val priorMostRecentDate: Date? = priorMostRecent?.getPubDate()
var idLong = Feed.newId()
Logd(TAG, "updateFeed building newFeedAssistant")
val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id)
Logd(TAG, "updateFeed building savedFeedAssistant")
val savedFeedAssistant = FeedAssistant(savedFeed)
@ -293,7 +291,6 @@ object Feeds {
if (pubDate == null || priorMostRecentDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) {
Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate")
episode.playState = PlayState.NEW.code
// episode.setNew()
if (savedFeed.preferences?.autoAddNewToQueue == true) {
val q = savedFeed.preferences?.queue
if (q != null) runOnIOScope { addToQueueSync(episode, q) }
@ -306,6 +303,8 @@ object Feeds {
val unlistedItems: MutableList<Episode> = ArrayList()
// identify episodes to be removed
if (removeUnlistedItems) {
Logd(TAG, "updateFeed building newFeedAssistant")
val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id)
val it = savedFeed.episodes.toMutableList().iterator()
while (it.hasNext()) {
val feedItem = it.next()
@ -314,19 +313,17 @@ object Feeds {
it.remove()
}
}
}
newFeedAssistant.clear()
}
// update attributes
savedFeed.lastUpdate = newFeed.lastUpdate
savedFeed.type = newFeed.type
savedFeed.lastUpdateFailed = false
resultFeed = savedFeed
savedFeed.totleDuration = 0
for (e in savedFeed.episodes) {
savedFeed.totleDuration += e.media?.duration ?: 0
}
for (e in savedFeed.episodes) savedFeed.totleDuration += e.media?.duration ?: 0
resultFeed = savedFeed
try {
upsertBlk(savedFeed) {}
if (removeUnlistedItems && unlistedItems.isNotEmpty()) runBlocking { deleteEpisodes(context, unlistedItems).join() }
@ -335,6 +332,69 @@ object Feeds {
return resultFeed
}
@Synchronized
fun updateFeedSimple(newFeed: Feed): Feed? {
Logd(TAG, "updateFeedSimple called")
val savedFeed = searchFeedByIdentifyingValueOrID(newFeed, true) ?: return newFeed
Logd(TAG, "Feed with title " + newFeed.title + " already exists. Syncing new with existing one.")
newFeed.episodes.sortWith(EpisodePubdateComparator())
if (newFeed.pageNr == savedFeed.pageNr) {
if (savedFeed.compareWithOther(newFeed)) {
Logd(TAG, "Feed has updated attribute values. Updating old feed's attributes")
savedFeed.updateFromOther(newFeed)
}
} else {
Logd(TAG, "New feed has a higher page number: ${newFeed.nextPageLink}")
savedFeed.nextPageLink = newFeed.nextPageLink
}
val priorMostRecent = savedFeed.mostRecentItem
val priorMostRecentDate: Date = priorMostRecent?.getPubDate() ?: Date(0)
var idLong = Feed.newId()
Logd(TAG, "updateFeedSimple building savedFeedAssistant")
// Look for new or updated Items
for (idx in newFeed.episodes.indices) {
val episode = newFeed.episodes[idx]
if ((episode.getPubDate()?: Date(0)) <= priorMostRecentDate) continue
Logd(TAG, "Found new episode: ${episode.title}")
episode.feed = savedFeed
episode.id = idLong++
episode.feedId = savedFeed.id
if (episode.media != null) {
episode.media!!.id = episode.id
if (!savedFeed.hasVideoMedia && episode.media!!.getMediaType() == MediaType.VIDEO) savedFeed.hasVideoMedia = true
}
if (idx >= savedFeed.episodes.size) savedFeed.episodes.add(episode)
else savedFeed.episodes.add(idx, episode)
val pubDate = episode.getPubDate()
if (pubDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) {
Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate")
episode.playState = PlayState.NEW.code
if (savedFeed.preferences?.autoAddNewToQueue == true) {
val q = savedFeed.preferences?.queue
if (q != null) runOnIOScope { addToQueueSync(episode, q) }
}
}
}
// update attributes
savedFeed.lastUpdate = newFeed.lastUpdate
savedFeed.type = newFeed.type
savedFeed.lastUpdateFailed = false
savedFeed.totleDuration = 0
for (e in savedFeed.episodes) savedFeed.totleDuration += e.media?.duration ?: 0
val resultFeed = savedFeed
try {
upsertBlk(savedFeed) {}
} catch (e: InterruptedException) { e.printStackTrace()
} catch (e: ExecutionException) { e.printStackTrace() }
return resultFeed
}
fun persistFeedLastUpdateFailed(feed: Feed, lastUpdateFailed: Boolean) : Job {
Logd(TAG, "persistFeedLastUpdateFailed called")
return runOnIOScope {

View File

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

View File

@ -144,6 +144,10 @@ class Feed : RealmObject {
val mostRecentItem: Episode?
get() = realm.query(Episode::class).query("feedId == $id SORT (pubDate DESC)").first().find()
@Ignore
val oldestItem: Episode?
get() = realm.query(Episode::class).query("feedId == $id SORT (pubDate ASC)").first().find()
@Ignore
var title: String?
get() = if (!customTitle.isNullOrEmpty()) customTitle else eigenTitle

View File

@ -48,7 +48,7 @@ class FeedPreferences : EmbeddedRealmObject {
field = value
autoDelete = field.code
}
var autoDelete: Int = 0
var autoDelete: Int = AutoDeleteAction.GLOBAL.code
@Ignore
var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF
@ -57,7 +57,25 @@ class FeedPreferences : EmbeddedRealmObject {
field = value
volumeAdaption = field.toInteger()
}
var volumeAdaption: Int = 0
var volumeAdaption: Int = VolumeAdaptionSetting.OFF.value
@Ignore
var audioQualitySetting: AVQuality = AVQuality.GLOBAL
get() = AVQuality.fromCode(audioQuality)
set(value) {
field = value
audioQuality = field.code
}
var audioQuality: Int = AVQuality.GLOBAL.code
@Ignore
var videoQualitySetting: AVQuality = AVQuality.GLOBAL
get() = AVQuality.fromCode(videoQuality)
set(value) {
field = value
videoQuality = field.code
}
var videoQuality: Int = AVQuality.GLOBAL.code
var prefStreamOverDownload: Boolean = false
@ -132,7 +150,7 @@ class FeedPreferences : EmbeddedRealmObject {
field = value
autoDLPolicyCode = value.code
}
var autoDLPolicyCode: Int = 0
var autoDLPolicyCode: Int = AutoDownloadPolicy.ONLY_NEW.code
constructor() {}
@ -197,6 +215,22 @@ class FeedPreferences : EmbeddedRealmObject {
}
}
enum class AVQuality(val code: Int, val tag: String) {
GLOBAL(0, "Global"),
LOW(1, "Low"),
MEDIUM(5, "Medium"),
HIGH(10, "High");
companion object {
fun fromCode(code: Int): AVQuality {
return enumValues<AVQuality>().firstOrNull { it.code == code } ?: GLOBAL
}
fun fromTag(tag: String): AVQuality {
return enumValues<AVQuality>().firstOrNull { it.tag == tag } ?: GLOBAL
}
}
}
companion object {
const val SPEED_USE_GLOBAL: Float = -1f
const val TAG_ROOT: String = "#root"

View File

@ -2,7 +2,7 @@ package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.R
enum class VolumeAdaptionSetting(private val value: Int, @JvmField val adaptionFactor: Float, val resId: Int) {
enum class VolumeAdaptionSetting(val value: Int, @JvmField val adaptionFactor: Float, val resId: Int) {
OFF(0, 1.0f, R.string.feed_volume_reduction_off),
LIGHT_REDUCTION(1, 0.5f, R.string.feed_volume_reduction_light),
HEAVY_REDUCTION(2, 0.2f, R.string.feed_volume_reduction_heavy),

View File

@ -997,14 +997,15 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDi
@Composable
fun EpisodesFilterDialog(filter: EpisodeFilter? = null, filtersDisabled: MutableSet<EpisodeFilter.EpisodesFilterGroup> = mutableSetOf(),
onDismissRequest: () -> Unit, onFilterChanged: (Set<String>) -> Unit) {
val filterValues: MutableSet<String> = mutableSetOf()
val filterValues = remember { filter?.properties ?: mutableSetOf() }
Dialog(properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { onDismissRequest() }) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.BOTTOM)
window.setDimAmount(0f)
}
Surface(modifier = Modifier.fillMaxWidth().height(350.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp)) {
Surface(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 10.dp).height(350.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
val textColor = MaterialTheme.colorScheme.onSurface
val scrollState = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(scrollState)) {

View File

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

View File

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

View File

@ -297,7 +297,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
feed.preferences!!.videoModePolicy = VideoMode.WINDOW_VIEW
CustomFeedNameDialog(activity as Activity, feed).show()
}
R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext())
R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext(), fullUpdate = true)
R.id.toggle_grid_list -> useGrid = if (useGrid == null) !useGridLayout else !useGrid!!
else -> return false
}
@ -961,7 +961,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@Composable
fun FilterDialog(filter: FeedFilter? = null, onDismissRequest: () -> Unit) {
val filterValues: MutableSet<String> = mutableSetOf()
val filterValues = remember { filter?.properties ?: mutableSetOf() }
fun onFilterChanged(newFilterValues: Set<String>) {
feedsFilter = StringUtils.join(newFilterValues, ",")
@ -975,7 +975,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
window.setGravity(Gravity.BOTTOM)
window.setDimAmount(0f)
}
Surface(modifier = Modifier.fillMaxWidth().height(350.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp)) {
Surface(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 10.dp).height(350.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
val textColor = MaterialTheme.colorScheme.onSurface
val scrollState = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(scrollState)) {

View File

@ -575,6 +575,10 @@
<string name="pref_feed_skip">Auto skip</string>
<string name="pref_feed_skip_sum">Skip introductions and ending credits.</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_sum">The queue to which epiosdes in the feed will added by default</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.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia
@ -11,7 +12,9 @@ import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.annotation.SuppressLint
import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.CastContext
@ -19,6 +22,11 @@ import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.Volatile
import kotlin.math.max
@ -194,7 +202,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
*
* @see .playMediaObject
*/
override fun playMediaObject(playable: Playable, stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
if (!CastUtils.isCastable(playable, castContext.sessionManager.currentCastSession)) {
Logd(TAG, "media provided is not compatible with cast device")
EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device"))
@ -202,7 +210,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
do { nextPlayable = callback.getNextInQueue(nextPlayable)
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession))
if (nextPlayable != null) playMediaObject(nextPlayable, stream, startWhenPrepared, prepareImmediately, forceReset)
if (nextPlayable != null) playMediaObject(nextPlayable, streaming, startWhenPrepared, prepareImmediately, forceReset)
return
}
@ -235,6 +243,53 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
this.mediaType = curMedia!!.getMediaType()
this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
// val metadata = buildMetadata(curMedia!!)
// try {
// callback.ensureMediaInfoLoaded(curMedia!!)
// callback.onMediaChanged(false)
// setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
// CoroutineScope(Dispatchers.IO).launch {
// when {
// streaming -> {
// val streamurl = curMedia!!.getStreamUrl()
// if (streamurl != null) {
// val media = curMedia
// if (media is EpisodeMedia) {
// mediaItem = null
// mediaSource = null
// setDataSource(metadata, media)
//// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
//// if (startWhenPrepared) runBlocking { deferred.await() }
//// val preferences = media.episodeOrFetch()?.feed?.preferences
//// setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
// } else setDataSource(metadata, streamurl, null, null)
// }
// }
// else -> {
// val localMediaurl = curMedia!!.getLocalMediaUrl()
//// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle
//// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null)
// if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
// else throw IOException("Unable to read local file $localMediaurl")
// }
// }
// withContext(Dispatchers.Main) {
// val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
// if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
// if (prepareImmediately) prepare()
// }
// }
// } catch (e: IOException) {
// e.printStackTrace()
// setPlayerStatus(PlayerStatus.ERROR, null)
// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
// } catch (e: IllegalStateException) {
// e.printStackTrace()
// setPlayerStatus(PlayerStatus.ERROR, null)
// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
// } finally { }
callback.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(true)
setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
@ -266,7 +321,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
override fun reinit() {
Logd(TAG, "reinit() called")
if (curMedia != null) playMediaObject(curMedia!!, stream = false, startWhenPrepared = startWhenPrepared.get(), prepareImmediately = false, true)
if (curMedia != null) playMediaObject(curMedia!!, streaming = false, startWhenPrepared = startWhenPrepared.get(), prepareImmediately = false, true)
else Logd(TAG, "Call to reinit was ignored: media was null")
}
@ -342,7 +397,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode)
// setting media to null signals to playMediaObject() that we're taking care of post-playback processing
curMedia = null
playMediaObject(nextMedia, stream = true, startWhenPrepared = playNextEpisode, prepareImmediately = playNextEpisode, forceReset = false)
playMediaObject(nextMedia, streaming = true, startWhenPrepared = playNextEpisode, prepareImmediately = playNextEpisode, forceReset = false)
}
}
when {

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
* 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"
rxjavaVersion = "3.1.8"
searchpreference = "v2.5.0"
#stream = "1.2.2"
uiToolingPreview = "1.7.5"
uiTooling = "1.7.5"
viewpager2 = "1.1.0"
@ -115,7 +114,6 @@ rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroi
rxjava3-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjavaVersion" }
rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" }
searchpreference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchpreference" }
#stream = { module = "com.annimon:stream", version.ref = "stream" }
vistaguide = { module = "com.github.XilinJia.vistaguide:VistaGuide", version.ref = "vistaguide" }
desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" }
wearable = { module = "com.google.android.wearable:wearable", version.ref = "wearable" }