6.13.6 commit
This commit is contained in:
parent
bdf56d58b3
commit
b2501d6f2b
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
13
changelog.md
13
changelog.md
|
@ -1,3 +1,16 @@
|
||||||
|
# 6.13.6
|
||||||
|
|
||||||
|
* created a fast update routine to improve performance
|
||||||
|
* all updates (swipe or scheduled etc) run the fast update routine
|
||||||
|
* full update (for which I don't see the reason) can be run from the menu of Subscriptions view
|
||||||
|
* the full update routine is also enhanced for performance
|
||||||
|
* enhanced youtube update routines to include infinite new episodes updates
|
||||||
|
* added update routine for youtube channel tabs
|
||||||
|
* added audio and video quality settings in Feed Preferences (Youtube feeds only): Global, Low, Medium, High
|
||||||
|
* these settings take precedence over global situations
|
||||||
|
* when Global is set, video is a lowest quality, and audio is at highest quality (except when prefLowQualityMedia is set for metered networks)
|
||||||
|
* fixed mis-behavior of multi-selection filters
|
||||||
|
|
||||||
# 6.13.5
|
# 6.13.5
|
||||||
|
|
||||||
* hopefully fixed youtube playlists/podcasts no-updating issue
|
* hopefully fixed youtube playlists/podcasts no-updating issue
|
||||||
|
|
|
@ -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
|
|
@ -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" }
|
||||||
|
|
Loading…
Reference in New Issue