From e3f7c314074f84f5e9ffeafd88b05156070d3930 Mon Sep 17 00:00:00 2001
From: Xilin Jia <6257601+XilinJia@users.noreply.github.com>
Date: Mon, 9 Sep 2024 22:54:46 +0100
Subject: [PATCH] 6.5.8 commit
---
README.md | 4 +-
app/build.gradle | 4 +-
.../service/playback/MediaPlayerBaseTest.kt | 2 +-
.../service/playback/TaskManagerTest.kt | 4 +-
app/src/main/AndroidManifest.xml | 1 -
.../podcini/net/feed/FeedUpdateManager.kt | 2 +-
.../podcini/net/feed/parser/FeedHandler.kt | 26 +-
.../net/feed/parser/namespace/YouTube.kt | 70 -
.../net/feed/parser/utils/MimeTypeUtils.kt | 2 +-
.../playback/PlaybackServiceStarter.kt | 15 +-
.../podcini/playback/base/MediaPlayerBase.kt | 13 +-
.../playback/service/LocalMediaPlayer.kt | 837 ------------
.../playback/service/PlaybackService.kt | 1178 ++++++++++++++++-
.../podcini/playback/service/TaskManager.kt | 354 -----
.../podcini/preferences/UserPreferences.kt | 16 +-
.../mdiq/podcini/storage/database/Queues.kt | 1 -
.../mdiq/podcini/storage/model/MediaType.kt | 4 +-
.../ac/mdiq/podcini/storage/model/Playable.kt | 10 +-
.../mdiq/podcini/storage/model/RemoteMedia.kt | 30 +-
.../actionbutton/EpisodeActionButton.kt | 4 +-
.../actions/actionbutton/PlayActionButton.kt | 6 +-
.../actionbutton/StreamActionButton.kt | 19 +-
.../mdiq/podcini/ui/activity/MainActivity.kt | 2 +-
.../podcini/ui/adapter/EpisodesAdapter.kt | 25 +-
.../podcini/ui/dialog/VariableSpeedDialog.kt | 41 +-
.../podcini/ui/fragment/OnlineFeedFragment.kt | 3 +-
.../ui/fragment/PlayerDetailsFragment.kt | 30 +-
.../podcini/ui/fragment/QueuesFragment.kt | 36 +-
.../mdiq/podcini/ui/view/EpisodeViewHolder.kt | 29 +-
.../podcini/ui/view/EpisodesRecyclerView.kt | 16 +-
changelog.md | 8 +
.../android/en-US/changelogs/3020242.txt | 7 +
32 files changed, 1292 insertions(+), 1507 deletions(-)
delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt
delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt
delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt
create mode 100644 fastlane/metadata/android/en-US/changelogs/3020242.txt
diff --git a/README.md b/README.md
index 5fdfa1c6..1ef51547 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,11 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
[](https://f-droid.org/packages/ac.mdiq.podcini.R/)
[](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13)
-## Announcement
-
#### Podcini.R version 6.5 as a major step forward brings YouTube channels in the app. They can be searched, subscribed and played from within Podcini. For more see the changelogs
#### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions.
#### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions.
-This project is based on a fork of [AntennaPod]() as of Feb 5 2024.
+This project evolves from a fork of [AntennaPod]() as of Feb 5 2024.
Compared to AntennaPod this project:
diff --git a/app/build.gradle b/app/build.gradle
index 6e77b238..1c512cb9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- versionCode 3020241
- versionName "6.5.7"
+ versionCode 3020242
+ versionName "6.5.8"
applicationId "ac.mdiq.podcini.R"
def commit = ""
diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt
index 3dd6a50a..9be520eb 100644
--- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt
+++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/MediaPlayerBaseTest.kt
@@ -4,7 +4,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.PlayerStatus
-import ac.mdiq.podcini.playback.service.LocalMediaPlayer
+import ac.mdiq.podcini.playback.service.PlaybackService.LocalMediaPlayer
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.VolumeAdaptionSetting
import androidx.test.annotation.UiThreadTest
diff --git a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt
index 45cfdb7d..c4ea6406 100644
--- a/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt
+++ b/app/src/androidTest/kotlin/ac/test/podcini/service/playback/TaskManagerTest.kt
@@ -1,8 +1,8 @@
package de.test.podcini.service.playback
import ac.mdiq.podcini.playback.base.PlayerStatus
-import ac.mdiq.podcini.playback.service.TaskManager
-import ac.mdiq.podcini.playback.service.TaskManager.PSTMCallback
+import ac.mdiq.podcini.playback.service.PlaybackService.TaskManager
+import ac.mdiq.podcini.playback.service.PlaybackService.TaskManager.PSTMCallback
import ac.mdiq.podcini.preferences.SleepTimerPreferences.setShakeToReset
import ac.mdiq.podcini.preferences.SleepTimerPreferences.setVibrate
import ac.mdiq.podcini.storage.model.Feed
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 478cbbe8..38ae72ec 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -54,7 +54,6 @@
android:enabled="true"
android:exported="true"
tools:ignore="ExportedService">
-
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt
index e4744c69..60383b80 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt
@@ -6,7 +6,7 @@ import ac.mdiq.podcini.net.download.service.DefaultDownloaderFactory
import ac.mdiq.podcini.net.download.service.DownloadRequestCreator.create
import ac.mdiq.podcini.net.download.service.DownloadRequest
import ac.mdiq.podcini.net.feed.parser.FeedHandler
-import ac.mdiq.podcini.net.feed.parser.FeedHandlerResult
+import ac.mdiq.podcini.net.feed.parser.FeedHandler.FeedHandlerResult
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileFeedRefresh
import ac.mdiq.podcini.net.utils.NetworkUtils.isFeedRefreshAllowed
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt
index 16b5765e..cdb2b38c 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt
@@ -63,7 +63,6 @@ class FeedHandler {
Logd(TAG, "Recognized type Atom")
val strLang = xpp.getAttributeValue("http://www.w3.org/XML/1998/namespace", "lang")
if (strLang != null) feed.language = strLang
-
return Type.ATOM
}
RSS_ROOT -> {
@@ -113,8 +112,7 @@ class FeedHandler {
e.printStackTrace()
} finally {
if (reader != null) {
- try {
- reader.close()
+ try { reader.close()
} catch (e: IOException) {
Logd(TAG, "IOException: $reader")
e.printStackTrace()
@@ -130,8 +128,7 @@ class FeedHandler {
if (feed.fileUrl == null) return null
val reader: Reader
- try {
- reader = XmlStreamReader(File(feed.fileUrl!!))
+ try { reader = XmlStreamReader(File(feed.fileUrl!!))
} catch (e: FileNotFoundException) {
Logd(TAG, "FileNotFoundException: " + feed.fileUrl)
e.printStackTrace()
@@ -218,10 +215,6 @@ class FeedHandler {
state.namespaces[uri] = Itunes()
Logd(TAG, "Recognized ITunes namespace")
}
-// uri == YouTube.NSURI && prefix == YouTube.NSTAG -> {
-// state.namespaces[uri] = YouTube()
-// Logd(TAG, "Recognized YouTube namespace")
-// }
uri == SimpleChapters.NSURI && prefix.matches(SimpleChapters.NSTAG.toRegex()) -> {
state.namespaces[uri] = SimpleChapters()
Logd(TAG, "Recognized SimpleChapters namespace")
@@ -289,21 +282,16 @@ class FeedHandler {
type = Type.INVALID
}
-// fun getMessage(): String? {
-// return if (message != null) {
-// message!!
-// } else if (type == TypeGetter.Type.INVALID) {
-// "Invalid type"
-// } else {
-// "Type $type not supported"
-// }
-// }
-
companion object {
private const val serialVersionUID = 9105878964928170669L
}
}
+ class FeedHandlerResult(
+ @JvmField val feed: Feed,
+ @JvmField val alternateFeedUrls: Map,
+ val redirectUrl: String)
+
companion object {
private val TAG: String = FeedHandler::class.simpleName ?: "Anonymous"
private const val ATOM_ROOT = "feed"
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt
deleted file mode 100644
index 0e3ba9e6..00000000
--- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package ac.mdiq.podcini.net.feed.parser.namespace
-
-import ac.mdiq.podcini.net.feed.parser.HandlerState
-import android.util.Log
-import androidx.core.text.HtmlCompat
-import ac.mdiq.podcini.net.feed.parser.element.SyndElement
-import ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis
-import org.xml.sax.Attributes
-
-// TODO: this appears not needed
-class YouTube : Namespace() {
- val TAG = this::class.simpleName ?: "Anonymous"
-
- override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement {
-// Logd(TAG, "handleElementStart $localName")
- if (IMAGE == localName) {
- val url: String? = attributes.getValue(IMAGE_HREF)
- if (state.currentItem != null) state.currentItem!!.imageUrl = url
- // this is the feed image, prefer to all other images
- else if (!url.isNullOrEmpty()) state.feed.imageUrl = url
- }
- return SyndElement(localName, this)
- }
-
- override fun handleElementEnd(localName: String, state: HandlerState) {
-// Logd(TAG, "handleElementEnd $localName")
- if (state.contentBuf == null) return
-
- val content = state.contentBuf.toString()
- val contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
- if (content.isEmpty()) return
-
- when {
- AUTHOR == localName && state.tagstack.size <= 3 -> state.feed.author = contentFromHtml
- DURATION == localName -> {
- try {
- val durationMs = inMillis(content)
- state.tempObjects[DURATION] = durationMs.toInt()
- } catch (e: NumberFormatException) { Log.e(NSTAG, String.format("Duration '%s' could not be parsed", content)) }
- }
- SUBTITLE == localName -> {
- when {
- state.currentItem != null && state.currentItem?.description.isNullOrEmpty() -> state.currentItem!!.setDescriptionIfLonger(content)
- state.feed.description.isNullOrEmpty() -> state.feed.description = content
- }
- }
- SUMMARY == localName -> {
- when {
- state.currentItem != null -> state.currentItem!!.setDescriptionIfLonger(content)
- Rss20.CHANNEL == state.secondTag.name -> state.feed.description = content
- }
- }
- NEW_FEED_URL == localName && content.trim { it <= ' ' }.startsWith("http") -> state.redirectUrl = content.trim { it <= ' ' }
- }
- }
-
- companion object {
- const val NSTAG: String = "yt"
- const val NSURI: String = "http://www.youtube.com/xml/schemas/2015"
-
- private const val IMAGE = "thumbnail"
- private const val IMAGE_HREF = "href"
-
- private const val AUTHOR = "author"
- const val DURATION: String = "duration"
- private const val SUBTITLE = "subtitle"
- private const val SUMMARY = "summary"
- private const val NEW_FEED_URL = "new-feed-url"
- }
-}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt
index 559d871a..5ff8d6f4 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/utils/MimeTypeUtils.kt
@@ -31,7 +31,7 @@ object MimeTypeUtils {
fun isMediaFile(type: String?): Boolean {
return if (type == null) false
else type.startsWith("audio/") || type.startsWith("video/") || type == "application/ogg"
- || type == "application/octet-stream" || type == "application/x-shockwave-flash"
+ || type == "application/octet-stream"
}
@JvmStatic
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt
index 53f13ac4..3483229e 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackServiceStarter.kt
@@ -19,6 +19,13 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl
private var shouldStreamThisTime = false
private var callEvenIfRunning = false
+ val intent: Intent
+ get() {
+ val launchIntent = Intent(context, PlaybackService::class.java)
+// launchIntent.putExtra(PlaybackServiceConstants.EXTRA_PLAYABLE, media as Parcelable)
+ launchIntent.putExtra(EXTRA_ALLOW_STREAM_THIS_TIME, shouldStreamThisTime)
+ return launchIntent
+ }
/**
* Default value: false
@@ -33,14 +40,6 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl
return this
}
- val intent: Intent
- get() {
- val launchIntent = Intent(context, PlaybackService::class.java)
-// launchIntent.putExtra(PlaybackServiceConstants.EXTRA_PLAYABLE, media as Parcelable)
- launchIntent.putExtra(EXTRA_ALLOW_STREAM_THIS_TIME, shouldStreamThisTime)
- return launchIntent
- }
-
fun start() {
Logd("PlaybackServiceStarter", "starting PlaybackService")
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END))
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt
index 283573a7..1257a49e 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt
@@ -1,11 +1,11 @@
package ac.mdiq.podcini.playback.base
+//import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.InTheatre.curState
-import ac.mdiq.podcini.preferences.UserPreferences
+import ac.mdiq.podcini.preferences.UserPreferences.Prefs
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.UserPreferences.setPlaybackSpeed
-import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.FeedPreferences
import ac.mdiq.podcini.storage.model.MediaType
@@ -311,7 +311,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
val audioPlaybackSpeed: Float
get() {
- try { return appPrefs.getString(UserPreferences.Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat()
+ try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat()
} catch (e: NumberFormatException) {
Log.e(TAG, Log.getStackTraceString(e))
setPlaybackSpeed(1.0f)
@@ -367,7 +367,6 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@JvmStatic
fun getCurrentPlaybackSpeed(media: Playable?): Float {
var playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL
- val mediaType: MediaType? = media?.getMediaType()
if (media != null) {
playbackSpeed = curState.curTempSpeed
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) {
@@ -375,12 +374,8 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
if (prefs_ != null) playbackSpeed = prefs_.playSpeed
}
}
- if (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = getPlaybackSpeed(mediaType)
+ if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = audioPlaybackSpeed
return playbackSpeed
}
-
- fun getPlaybackSpeed(mediaType: MediaType): Float {
- return if (mediaType == MediaType.VIDEO) videoPlaybackSpeed else audioPlaybackSpeed
- }
}
}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt
deleted file mode 100644
index 4d6da2b3..00000000
--- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt
+++ /dev/null
@@ -1,837 +0,0 @@
-package ac.mdiq.podcini.playback.service
-
-import ac.mdiq.podcini.R
-import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
-import ac.mdiq.podcini.net.download.service.PodciniHttpClient
-import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
-import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
-import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
-import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
-import ac.mdiq.podcini.playback.base.InTheatre.curMedia
-import ac.mdiq.podcini.playback.base.InTheatre.curQueue
-import ac.mdiq.podcini.playback.base.MediaPlayerBase
-import ac.mdiq.podcini.playback.base.MediaPlayerCallback
-import ac.mdiq.podcini.playback.base.PlayerStatus
-import ac.mdiq.podcini.playback.base.VideoMode
-import ac.mdiq.podcini.preferences.UserPreferences
-import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
-import ac.mdiq.podcini.storage.model.*
-import ac.mdiq.podcini.storage.utils.EpisodeUtil
-import ac.mdiq.podcini.util.EventFlow
-import ac.mdiq.podcini.util.FlowEvent
-import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action
-import ac.mdiq.podcini.util.Logd
-import ac.mdiq.podcini.util.config.ClientConfig
-import ac.mdiq.vista.extractor.MediaFormat
-import ac.mdiq.vista.extractor.Vista
-import ac.mdiq.vista.extractor.stream.*
-import android.app.UiModeManager
-import android.content.Context
-import android.content.res.Configuration
-import android.media.audiofx.LoudnessEnhancer
-import android.net.Uri
-import android.util.Log
-import android.util.Pair
-import android.view.SurfaceHolder
-import androidx.core.util.Consumer
-import androidx.media3.common.*
-import androidx.media3.common.Player.*
-import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DefaultDataSource
-import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
-import androidx.media3.datasource.okhttp.OkHttpDataSource
-import androidx.media3.exoplayer.DefaultLoadControl
-import androidx.media3.exoplayer.DefaultRenderersFactory
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.SeekParameters
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
-import androidx.media3.exoplayer.source.MediaSource
-import androidx.media3.exoplayer.source.MergingMediaSource
-import androidx.media3.exoplayer.source.ProgressiveMediaSource
-import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
-import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
-import androidx.media3.exoplayer.trackselection.ExoTrackSelection
-import androidx.media3.extractor.DefaultExtractorsFactory
-import androidx.media3.extractor.mp3.Mp3Extractor
-import androidx.media3.ui.DefaultTrackNameProvider
-import androidx.media3.ui.TrackNameProvider
-import kotlinx.coroutines.*
-import java.io.IOException
-import java.lang.Runnable
-import java.util.*
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
-import kotlin.concurrent.Volatile
-
-/**
- * Manages the MediaPlayer object of the PlaybackService.
- */
-@UnstableApi
-class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
-
- @Volatile
- private var statusBeforeSeeking: PlayerStatus? = null
-
- @Volatile
- private var videoSize: Pair? = null
- private var isShutDown = false
- private var seekLatch: CountDownLatch? = null
-
- private val bufferUpdateInterval = 5000L
- private var mediaSource: MediaSource? = null
- private var mediaItem: MediaItem? = null
- private var playbackParameters: PlaybackParameters
-
- private var bufferedPercentagePrev = 0
-
- private val formats: List
- get() {
- val formats_: MutableList = arrayListOf()
- val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList()
- val trackGroups = trackInfo.getTrackGroups(audioRendererIndex)
- for (i in 0 until trackGroups.length) {
- formats_.add(trackGroups[i].getFormat(0))
- }
- return formats_
- }
-
- private val audioRendererIndex: Int
- get() {
- for (i in 0 until exoPlayer!!.rendererCount) {
- if (exoPlayer?.getRendererType(i) == C.TRACK_TYPE_AUDIO) return i
- }
- return -1
- }
-
- private val videoWidth: Int
- get() {
- return exoPlayer?.videoFormat?.width ?: 0
- }
-
- private val videoHeight: Int
- get() {
- return exoPlayer?.videoFormat?.height ?: 0
- }
-
- init {
- if (httpDataSourceFactory == null) {
- runOnIOScope {
- httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
- .setUserAgent(ClientConfig.USER_AGENT)
- }
- }
- if (exoPlayer == null) {
- setupPlayerListener()
- createStaticPlayer(context)
- }
- playbackParameters = exoPlayer!!.playbackParameters
- val scope = CoroutineScope(Dispatchers.Main)
- scope.launch {
- while (true) {
- delay(bufferUpdateInterval)
- withContext(Dispatchers.Main) {
- if (exoPlayer != null && bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) {
- bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage)
- bufferedPercentagePrev = exoPlayer!!.bufferedPercentage
- }
- }
- }
- }
- }
-
- @Throws(IllegalStateException::class)
- private fun prepareWR() {
- Logd(TAG, "prepareWR() called")
- if (mediaSource == null && mediaItem == null) return
- if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false)
- else exoPlayer?.setMediaItem(mediaItem!!)
- exoPlayer?.prepare()
- }
-
- private fun release() {
- Logd(TAG, "release() called")
- exoPlayer?.stop()
- exoPlayer?.seekTo(0L)
- audioSeekCompleteListener = null
- audioCompletionListener = null
- audioErrorListener = null
- bufferingUpdateListener = null
- }
-
- private fun setAudioStreamType(i: Int) {
- val a = exoPlayer!!.audioAttributes
- val b = AudioAttributes.Builder()
- b.setContentType(i)
- b.setFlags(a.flags)
- b.setUsage(a.usage)
- 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 = StreamInfo.getInfo(vService, url)
- val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
- Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
- val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1
- val audioStream = audioStreamsList[audioIndex]
- Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}")
- val aSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
- if (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().setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
- val mediaSources: MutableList = ArrayList()
- mediaSources.add(vSource)
- mediaSources.add(aSource)
- mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray())
-// mediaSource = null
- } else mediaSource = aSource
- mediaItem = mediaSource?.mediaItem
- setSourceCredentials(user, password)
- } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") }
- } else {
- Logd(TAG, "setDataSource1 setting for Podcast source")
- setDataSource(metadata, url,user, password)
- }
- }
-
- private fun setSourceCredentials(user: String?, password: String?) {
- if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) {
- if (httpDataSourceFactory == null)
- httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
- .setUserAgent(ClientConfig.USER_AGENT)
-
- val requestProperties = HashMap()
- requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
- httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties)
-
- val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!)
- val extractorsFactory = DefaultExtractorsFactory()
- extractorsFactory.setConstantBitrateSeekingEnabled(true)
- extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA)
- val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
-
- mediaSource = f.createMediaSource(mediaItem!!)
- }
- }
-
- /**
- * Join the two lists of video streams (video_only and normal videos),
- * and sort them according with default format chosen by the user.
- *
- * @param defaultFormat format to give preference
- * @param showHigherResolutions show >1080p resolutions
- * @param videoStreams normal videos list
- * @param videoOnlyStreams video only stream list
- * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest
- * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only
- * streams and normal video streams are available
- * @return the sorted list
- */
- private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean,
- preferVideoOnlyStreams: Boolean): List {
- val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
- val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
- val comparator = compareBy { it.resolution.toResolutionValue() }
- return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) }
- }
-
- private fun String.toResolutionValue(): Int {
- val match = Regex("(\\d+)p|(\\d+)k").find(this)
- return when {
- match?.groupValues?.get(1) != null -> match.groupValues[1].toInt()
- match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024
- else -> 0
- }
- }
-
- private fun getFilteredAudioStreams(audioStreams: List?): List {
- if (audioStreams == null) return listOf()
- val collectedStreams = mutableSetOf()
- for (stream in audioStreams) {
- Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}")
- if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS))
- continue
- collectedStreams.add(stream)
- }
- return collectedStreams.toList().sortedWith(compareBy { it.bitrate })
- }
-
- /**
- * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
- * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
- * not do anything.
- * Whether playback starts immediately depends on the given parameters. See below for more details.
- * States:
- * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
- * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
- * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
- * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
- * will enter the ERROR state.
- * This method is executed on an internal executor service.
- * @param playable The Playable object that is supposed to be played. This parameter must not be null.
- * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via
- * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
- * the Android MediaPlayer via getStreamUrl.
- * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the
- * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared
- * for playback immediately (see 'prepareImmediately' parameter for more details)
- * @param prepareImmediately Set to true if the method should also prepare the episode for playback.
- */
- override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
- Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ")
-// showStackTrace()
- if (curMedia != null) {
- Logd(TAG, "playMediaObject: curMedia exist status=$status")
- if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) {
- Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
- return
- }
- Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}")
- // set temporarily to pause in order to update list with current position
- if (status == PlayerStatus.PLAYING) {
- val pos = curMedia?.getPosition() ?: -1
- seekTo(pos)
- callback.onPlaybackPause(curMedia, pos)
- }
- // stop playback of this episode
- if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
-// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
-// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
- setPlayerStatus(PlayerStatus.INDETERMINATE, null)
- }
-
- Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}")
- curMedia = playable
- if (curMedia is EpisodeMedia) {
- val media_ = curMedia as EpisodeMedia
- val item = media_.episodeOrFetch()
- val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf()
- curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id)
- } else curIndexInQueue = -1
-
- prevMedia = curMedia
- this.isStreaming = streaming
- mediaType = curMedia!!.getMediaType()
- videoSize = null
- createMediaPlayer()
- this.startWhenPrepared.set(startWhenPrepared)
- setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
- val metadata = buildMetadata(curMedia!!)
- try {
- callback.ensureMediaInfoLoaded(curMedia!!)
- callback.onMediaChanged(false)
- setPlaybackParams(getCurrentPlaybackSpeed(curMedia), UserPreferences.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 { }
- }
-
- override fun resume() {
- if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
- Logd(TAG, "Resuming/Starting playback")
- acquireWifiLockIfNecessary()
- setPlaybackParams(getCurrentPlaybackSpeed(curMedia), UserPreferences.isSkipSilence)
- setVolume(1.0f, 1.0f)
-
- if (curMedia != null && status == PlayerStatus.PREPARED && curMedia!!.getPosition() > 0) {
- val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
- seekTo(newPosition)
- }
- if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
-// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
- exoPlayer?.play()
- // Can't set params when paused - so always set it on start in case they changed
- exoPlayer?.playbackParameters = playbackParameters
- setPlayerStatus(PlayerStatus.PLAYING, curMedia)
- if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!))
- } else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status")
- }
-
- override fun pause(abandonFocus: Boolean, reinit: Boolean) {
- releaseWifiLockIfNecessary()
- if (status == PlayerStatus.PLAYING) {
- Logd(TAG, "Pausing playback $abandonFocus $reinit")
- exoPlayer?.pause()
- setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition())
- if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END))
- if (isStreaming && reinit) reinit()
- } else Logd(TAG, "Ignoring call to pause: Player is in $status state")
- }
-
- override fun prepare() {
- if (status == PlayerStatus.INITIALIZED) {
- Logd(TAG, "Preparing media player")
- setPlayerStatus(PlayerStatus.PREPARING, curMedia)
- prepareWR()
-// onPrepared(startWhenPrepared.get())
- if (mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
- if (curMedia != null) {
- val pos = curMedia!!.getPosition()
- if (pos > 0) seekTo(pos)
- if (curMedia != null && curMedia!!.getDuration() <= 0) {
- Logd(TAG, "Setting duration of media")
- curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt())
- }
- }
- setPlayerStatus(PlayerStatus.PREPARED, curMedia)
- if (startWhenPrepared.get()) resume()
- }
- }
-
- override fun reinit() {
- Logd(TAG, "reinit() called")
- releaseWifiLockIfNecessary()
- when {
- curMedia != null -> playMediaObject(curMedia!!, isStreaming, startWhenPrepared.get(), prepareImmediately = false, true)
- else -> Logd(TAG, "Call to reinit: media and mediaPlayer were null, ignored")
- }
- }
-
- override fun seekTo(t: Int) {
- var t = t
- if (t < 0) t = 0
- Logd(TAG, "seekTo() called $t")
-
- if (t >= getDuration()) {
- Logd(TAG, "Seek reached end of file, skipping to next episode")
- exoPlayer?.seekTo(t.toLong()) // can set curMedia to null
- if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
- audioSeekCompleteListener?.run()
- endPlayback(true, wasSkipped = true, true, toStoppedState = true)
- t = getPosition()
-// return
- }
-
- when (status) {
- PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
- Logd(TAG, "seekTo() called $t")
- if (seekLatch != null && seekLatch!!.count > 0) {
- try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) }
- }
- seekLatch = CountDownLatch(1)
- statusBeforeSeeking = status
- setPlayerStatus(PlayerStatus.SEEKING, curMedia, t)
- exoPlayer?.seekTo(t.toLong())
- if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
- audioSeekCompleteListener?.run()
- if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t)
- try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) }
- }
- PlayerStatus.INITIALIZED -> {
- curMedia?.setPosition(t)
- startWhenPrepared.set(false)
- prepare()
- }
- else -> {}
- }
- }
-
- override fun getDuration(): Int {
- return curMedia?.getDuration() ?: Playable.INVALID_TIME
- }
-
- override fun getPosition(): Int {
- var retVal = Playable.INVALID_TIME
- if (exoPlayer != null && status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt()
- if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition()
- return retVal
- }
-
- override fun setPlaybackParams(speed: Float, skipSilence: Boolean) {
- EventFlow.postEvent(FlowEvent.SpeedChangedEvent(speed))
- Logd(TAG, "setPlaybackParams speed=$speed pitch=${playbackParameters.pitch} skipSilence=$skipSilence")
- playbackParameters = PlaybackParameters(speed, playbackParameters.pitch)
- exoPlayer!!.skipSilenceEnabled = skipSilence
- exoPlayer!!.playbackParameters = playbackParameters
- }
-
- override fun getPlaybackSpeed(): Float {
- var retVal = 1f
- if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.INITIALIZED || status == PlayerStatus.PREPARED)
- retVal = playbackParameters.speed
- return retVal
- }
-
- override fun setVolume(volumeLeft: Float, volumeRight: Float) {
- var volumeLeft = volumeLeft
- var volumeRight = volumeRight
- val playable = curMedia
- if (playable is EpisodeMedia) {
- val preferences = playable.episodeOrFetch()?.feed?.preferences
- if (preferences != null) {
- val volumeAdaptionSetting = preferences.volumeAdaptionSetting
- if (volumeAdaptionSetting != null) {
- val adaptionFactor = volumeAdaptionSetting.adaptionFactor
- volumeLeft *= adaptionFactor
- volumeRight *= adaptionFactor
- }
- }
- }
- if (volumeLeft > 1) {
- exoPlayer?.volume = 1f
- loudnessEnhancer?.setEnabled(true)
- loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt())
- } else {
- exoPlayer?.volume = volumeLeft
- loudnessEnhancer?.setEnabled(false)
- }
- Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight")
- }
-
- override fun shutdown() {
- Logd(TAG, "shutdown() called")
- try {
- clearMediaPlayerListeners()
-// TODO: should use: exoPlayer!!.playWhenReady ?
- if (exoPlayer?.isPlaying == true) exoPlayer?.stop()
- } catch (e: Exception) {
- e.printStackTrace()
- }
- release()
- status = PlayerStatus.STOPPED
-
- isShutDown = true
- releaseWifiLockIfNecessary()
- }
-
- override fun setVideoSurface(surface: SurfaceHolder?) {
- exoPlayer?.setVideoSurfaceHolder(surface)
- }
-
- override fun resetVideoSurface() {
- if (mediaType == MediaType.VIDEO) {
- Logd(TAG, "Resetting video surface")
- exoPlayer?.setVideoSurfaceHolder(null)
- reinit()
- } else Log.e(TAG, "Resetting video surface for media of Audio type")
- }
-
- /**
- * Return width and height of the currently playing video as a pair.
- * @return Width and height as a Pair or null if the video size could not be determined. The method might still
- * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return
- * invalid values.
- */
- override fun getVideoSize(): Pair? {
- if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
- return videoSize
- }
-
- override fun getAudioTracks(): List {
- val trackNames: MutableList = ArrayList()
- val trackNameProvider: TrackNameProvider = DefaultTrackNameProvider(context.resources)
- for (format in formats) {
- trackNames.add(trackNameProvider.getTrackName(format))
- }
- return trackNames
- }
-
- override fun setAudioTrack(track: Int) {
- val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return
- val trackGroups = trackInfo.getTrackGroups(audioRendererIndex)
- val override = SelectionOverride(track, 0)
- val rendererIndex = audioRendererIndex
- val params = trackSelector!!.buildUponParameters().setSelectionOverride(rendererIndex, trackGroups, override)
- trackSelector!!.setParameters(params)
- }
-
- override fun getSelectedAudioTrack(): Int {
- val trackSelections = exoPlayer!!.currentTrackSelections
- val availableFormats = formats
- Logd(TAG, "selectedAudioTrack called tracks: ${trackSelections.length} formats: ${availableFormats.size}")
- for (i in 0 until trackSelections.length) {
- val track = trackSelections[i] as? ExoTrackSelection ?: continue
- if (availableFormats.contains(track.selectedFormat)) return availableFormats.indexOf(track.selectedFormat)
- }
- return -1
- }
-
- override fun createMediaPlayer() {
- Logd(TAG, "createMediaPlayer()")
- release()
- if (curMedia == null) {
- status = PlayerStatus.STOPPED
- return
- }
- setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH)
- setMediaPlayerListeners()
- }
-
- override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) {
- releaseWifiLockIfNecessary()
- if (curMedia == null) return
-
- val isPlaying = status == PlayerStatus.PLAYING
- // we're relying on the position stored in the Playable object for post-playback processing
- val position = getPosition()
- if (position >= 0) curMedia?.setPosition(position)
- Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState")
-
- val currentMedia = curMedia
- var nextMedia: Playable? = null
- if (shouldContinue) {
- // Load next episode if previous episode was in the queue and if there is an episode in the queue left.
- // Start playback immediately if continuous playback is enabled
- nextMedia = callback.getNextInQueue(currentMedia)
- if (nextMedia != null) {
- Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false")
- if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null)
- callback.onPlaybackEnded(nextMedia.getMediaType(), false)
- // setting media to null signals to playMediaObject that we're taking care of post-playback processing
- curMedia = null
- if(nextMedia != null) playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying)
- }
- }
- when {
- shouldContinue || toStoppedState -> {
- if (nextMedia == null) {
- Logd(TAG, "nextMedia is null. call callback.onPlaybackEnded true")
- callback.onPlaybackEnded(null, true)
- curMedia = null
- exoPlayer?.stop()
- releaseWifiLockIfNecessary()
- if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null)
- else Logd(TAG, "Ignored call to stop: Current player state is: $status")
- }
- val hasNext = nextMedia != null
- if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext)
-// curMedia = nextMedia
- }
- isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition())
- }
- }
-
- override fun shouldLockWifi(): Boolean {
- return isStreaming
- }
-
- private fun setMediaPlayerListeners() {
- if (curMedia == null) return
-
- audioCompletionListener = Runnable {
- Logd(TAG, "audioCompletionListener called")
- endPlayback(hasEnded = true, wasSkipped = false, shouldContinue = true, toStoppedState = true)
- }
- audioSeekCompleteListener = Runnable { this.genericSeekCompleteListener() }
- bufferingUpdateListener = Consumer { percent: Int ->
- when (percent) {
- BUFFERING_STARTED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.started())
- BUFFERING_ENDED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.ended())
- else -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.progressUpdate(0.01f * percent))
- }
- }
- audioErrorListener = Consumer { message: String ->
- Log.e(TAG, "PlayerErrorEvent: $message")
- EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(message))
- }
- }
-
- private fun clearMediaPlayerListeners() {
- audioCompletionListener = Runnable {}
- audioSeekCompleteListener = Runnable {}
- bufferingUpdateListener = Consumer { }
- audioErrorListener = Consumer {}
- }
-
- private fun genericSeekCompleteListener() {
- Logd(TAG, "genericSeekCompleteListener $status ${exoPlayer?.isPlaying} $statusBeforeSeeking")
- seekLatch?.countDown()
-
- if ((status == PlayerStatus.PLAYING || exoPlayer?.isPlaying != true) && curMedia != null) callback.onPlaybackStart(curMedia!!, getPosition())
- if (status == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, curMedia, getPosition())
- }
-
- override fun isCasting(): Boolean {
- return false
- }
-
- private fun setupPlayerListener() {
- exoplayerListener = object : Listener {
- override fun onPlaybackStateChanged(playbackState: @State Int) {
- Logd(TAG, "onPlaybackStateChanged $playbackState")
- when (playbackState) {
- STATE_ENDED -> {
- exoPlayer?.seekTo(C.TIME_UNSET)
- audioCompletionListener?.run()
- }
- STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED)
- else -> bufferingUpdateListener?.accept(BUFFERING_ENDED)
- }
- }
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
- setPlayerStatus(stat, curMedia)
- Logd(TAG, "onIsPlayingChanged $isPlaying")
- }
- override fun onPlayerError(error: PlaybackException) {
- Logd(TAG, "onPlayerError ${error.message}")
- if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked))
- else {
- var cause = error.cause
- if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause
- if (cause != null && "Source error" == cause.message) cause = cause.cause
- audioErrorListener?.accept((if (cause != null) cause.message else error.message) ?:"no message")
- }
- }
- override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) {
- Logd(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason")
- if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run()
- }
- override fun onAudioSessionIdChanged(audioSessionId: Int) {
- Logd(TAG, "onAudioSessionIdChanged $audioSessionId")
- initLoudnessEnhancer(audioSessionId)
- }
- }
- }
-
- companion object {
- private val TAG: String = LocalMediaPlayer::class.simpleName ?: "Anonymous"
-
- const val BUFFERING_STARTED: Int = -1
- const val BUFFERING_ENDED: Int = -2
-
- private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
-
- private var trackSelector: DefaultTrackSelector? = null
-
- var exoPlayer: ExoPlayer? = null
-
- private var exoplayerListener: Listener? = null
- private var audioSeekCompleteListener: Runnable? = null
- private var audioCompletionListener: Runnable? = null
- private var audioErrorListener: Consumer? = null
- private var bufferingUpdateListener: Consumer? = null
- private var loudnessEnhancer: LoudnessEnhancer? = null
-
- fun createStaticPlayer(context: Context) {
- val loadControl = DefaultLoadControl.Builder()
- loadControl.setBufferDurationsMs(30000, 120000, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
- DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
- loadControl.setBackBuffer(UserPreferences.rewindSecs * 1000 + 500, true)
- trackSelector = DefaultTrackSelector(context)
- val audioOffloadPreferences = AudioOffloadPreferences.Builder()
- .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) // Add additional options as needed
- .setIsGaplessSupportRequired(true)
- .setIsSpeedChangeSupportRequired(true)
- .build()
- Logd(TAG, "createStaticPlayer creating exoPlayer_")
-
- val defaultRenderersFactory = DefaultRenderersFactory(context)
-// defaultRenderersFactory.setMediaCodecSelector { mimeType: String?, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean ->
-// val decoderInfos: List = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder)
-// val result: MutableList = ArrayList()
-// for (decoderInfo in decoderInfos) {
-// Logd(TAG, "decoderInfo.name: ${decoderInfo.name}")
-// if (decoderInfo.name == "c2.android.mp3.decoder") {
-// continue
-// }
-// result.add(decoderInfo)
-// }
-// result
-// }
- exoPlayer = ExoPlayer.Builder(context, defaultRenderersFactory)
- .setTrackSelector(trackSelector!!)
- .setLoadControl(loadControl.build())
- .build()
-
- exoPlayer?.setSeekParameters(SeekParameters.EXACT)
- exoPlayer!!.trackSelectionParameters = exoPlayer!!.trackSelectionParameters
- .buildUpon()
- .setAudioOffloadPreferences(audioOffloadPreferences)
- .build()
-
-// if (BuildConfig.DEBUG) exoPlayer!!.addAnalyticsListener(EventLogger())
-
- if (exoplayerListener != null) {
- exoPlayer?.removeListener(exoplayerListener!!)
- exoPlayer?.addListener(exoplayerListener!!)
- }
- initLoudnessEnhancer(exoPlayer!!.audioSessionId)
- }
-
- private fun initLoudnessEnhancer(audioStreamId: Int) {
- runOnIOScope {
- val newEnhancer = LoudnessEnhancer(audioStreamId)
- val oldEnhancer = loudnessEnhancer
- if (oldEnhancer != null) {
- newEnhancer.setEnabled(oldEnhancer.enabled)
- if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt())
- oldEnhancer.release()
- }
- loudnessEnhancer = newEnhancer
- }
- }
-
- fun cleanup() {
- if (exoplayerListener != null) exoPlayer?.removeListener(exoplayerListener!!)
- exoplayerListener = null
- audioSeekCompleteListener = null
- audioCompletionListener = null
- audioErrorListener = null
- bufferingUpdateListener = null
- loudnessEnhancer = null
- httpDataSourceFactory = null
- }
- }
-}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt
index 52c1f420..9a5a0361 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt
@@ -1,11 +1,15 @@
package ac.mdiq.podcini.playback.service
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.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileStreaming
+import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
+import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
import ac.mdiq.podcini.playback.PlaybackServiceStarter
-import ac.mdiq.podcini.playback.base.InTheatre
+import ac.mdiq.podcini.playback.base.*
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
@@ -13,15 +17,12 @@ import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.playback.base.InTheatre.loadPlayableFromPreferences
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
-import ac.mdiq.podcini.playback.base.MediaPlayerBase
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.buildMediaItem
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
-import ac.mdiq.podcini.playback.base.MediaPlayerCallback
-import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.playback.cast.CastPsmp
import ac.mdiq.podcini.playback.cast.CastStateListener
-import ac.mdiq.podcini.playback.service.TaskManager.PSTMCallback
+import ac.mdiq.podcini.preferences.SleepTimerPreferences
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableFrom
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableTo
@@ -50,53 +51,96 @@ import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_OTHER
import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_PAUSED
import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_PLAYING
import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction
+import ac.mdiq.podcini.storage.utils.ChapterUtils
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
import ac.mdiq.podcini.ui.utils.NotificationUtils
+import ac.mdiq.podcini.ui.widget.WidgetUpdater
import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
+import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action
import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd
+import ac.mdiq.podcini.util.config.ClientConfig
+import ac.mdiq.vista.extractor.MediaFormat
import ac.mdiq.vista.extractor.Vista
+import ac.mdiq.vista.extractor.stream.AudioStream
+import ac.mdiq.vista.extractor.stream.DeliveryMethod
import ac.mdiq.vista.extractor.stream.StreamInfo
+import ac.mdiq.vista.extractor.stream.VideoStream
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.app.UiModeManager
import android.bluetooth.BluetoothA2dp
import android.content.*
import android.content.Intent.EXTRA_KEY_EVENT
+import android.content.res.Configuration
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
import android.media.AudioManager
+import android.media.audiofx.LoudnessEnhancer
+import android.net.Uri
import android.os.*
import android.os.Build.VERSION_CODES
import android.service.quicksettings.TileService
import android.util.Log
+import android.util.Pair
import android.view.KeyEvent
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
+import android.view.SurfaceHolder
import android.view.ViewConfiguration
import android.webkit.URLUtil
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationCompat
-import androidx.media3.common.MediaItem
-import androidx.media3.common.MediaMetadata
-import androidx.media3.common.Player.STATE_ENDED
-import androidx.media3.common.Player.STATE_IDLE
+import androidx.core.util.Consumer
+import androidx.media3.common.*
+import androidx.media3.common.Player.*
+import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.DefaultLoadControl
+import androidx.media3.exoplayer.DefaultRenderersFactory
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.SeekParameters
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.exoplayer.source.MediaSource
+import androidx.media3.exoplayer.source.MergingMediaSource
+import androidx.media3.exoplayer.source.ProgressiveMediaSource
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
+import androidx.media3.exoplayer.trackselection.ExoTrackSelection
+import androidx.media3.extractor.DefaultExtractorsFactory
+import androidx.media3.extractor.mp3.Mp3Extractor
import androidx.media3.session.*
+import androidx.media3.ui.DefaultTrackNameProvider
+import androidx.media3.ui.TrackNameProvider
import androidx.work.impl.utils.futures.SettableFuture
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
+import java.io.IOException
import java.util.*
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.TimeUnit
import kotlin.concurrent.Volatile
import kotlin.math.max
+import kotlin.math.sqrt
/**
* Controls the MediaPlayer that plays a EpisodeMedia-file
@@ -213,7 +257,7 @@ class PlaybackService : MediaLibraryService() {
}
}
- private val taskManagerCallback: PSTMCallback = object : PSTMCallback {
+ private val taskManagerCallback: TaskManager.PSTMCallback = object : TaskManager.PSTMCallback {
override fun positionSaverTick() {
if (curPosition != prevPosition) {
// Log.d(TAG, "positionSaverTick currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed")
@@ -404,6 +448,7 @@ class PlaybackService : MediaLibraryService() {
writeNoMediaPlaying()
return null
}
+ EventFlow.postEvent(FlowEvent.PlayEvent(item, Action.END))
if (curIndexInQueue < 0 && item.feed?.preferences?.queue != null) {
Logd(TAG, "getNextInQueue(), curMedia is not in curQueue")
writeNoMediaPlaying()
@@ -441,8 +486,7 @@ class PlaybackService : MediaLibraryService() {
writeNoMediaPlaying()
return null
}
- EventFlow.postEvent(FlowEvent.PlayEvent(item, FlowEvent.PlayEvent.Action.END))
- EventFlow.postEvent(FlowEvent.PlayEvent(nextItem))
+// EventFlow.postEvent(FlowEvent.PlayEvent(nextItem))
return if (nextItem.media == null) null else unmanaged(nextItem.media!!)
}
// only used in test
@@ -630,7 +674,7 @@ class PlaybackService : MediaLibraryService() {
override fun onCreate() {
super.onCreate()
- Logd(TAG, "Service created.")
+ Logd(TAG, "onCreate Service created.")
isRunning = true
playbackService = this
@@ -1013,7 +1057,7 @@ class PlaybackService : MediaLibraryService() {
// is FlowEvent.VolumeAdaptionChangedEvent -> onVolumeAdaptionChanged(event)
is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChanged(event)
// is FlowEvent.SkipIntroEndingChangedEvent -> skipIntroEndingPresetChanged(event)
- is FlowEvent.PlayEvent -> currentitem = event.episode
+ is FlowEvent.PlayEvent -> if (event.action != Action.END) currentitem = event.episode
is FlowEvent.EpisodeMediaEvent -> onEpisodeMediaEvent(event)
else -> {}
}
@@ -1036,10 +1080,10 @@ class PlaybackService : MediaLibraryService() {
private fun onQueueEvent(event: FlowEvent.QueueEvent) {
if (event.action == FlowEvent.QueueEvent.Action.REMOVED) {
- Logd(TAG, "onQueueEvent: ending playback curEpisode ${curEpisode?.title}")
+// Logd(TAG, "onQueueEvent: ending playback curEpisode ${curEpisode?.title}")
notifyCurQueueItemsChanged()
for (e in event.episodes) {
- Logd(TAG, "onQueueEvent: ending playback event ${e.title}")
+ Logd(TAG, "onQueueEvent: queue event removed ${e.title}")
if (e.id == curEpisode?.id) {
mPlayer?.endPlayback(hasEnded = false, wasSkipped = true, shouldContinue = true, toStoppedState = true)
break
@@ -1157,7 +1201,8 @@ class PlaybackService : MediaLibraryService() {
media.playedDuration = (media.playedDurationWhenStarted + media.getPosition() - media.startPosition)
}
}
- EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
+// This appears not too useful
+// EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
}
}
prevPosition = position
@@ -1270,6 +1315,1105 @@ class PlaybackService : MediaLibraryService() {
}
}
+ /**
+ * Manages the MediaPlayer object of the PlaybackService.
+ */
+ @UnstableApi
+ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
+
+ @Volatile
+ private var statusBeforeSeeking: PlayerStatus? = null
+
+ @Volatile
+ private var videoSize: Pair? = null
+ private var isShutDown = false
+ private var seekLatch: CountDownLatch? = null
+
+ private val bufferUpdateInterval = 5000L
+ private var mediaSource: MediaSource? = null
+ private var mediaItem: MediaItem? = null
+ private var playbackParameters: PlaybackParameters
+
+ private var bufferedPercentagePrev = 0
+
+ private val formats: List
+ get() {
+ val formats_: MutableList = arrayListOf()
+ val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList()
+ val trackGroups = trackInfo.getTrackGroups(audioRendererIndex)
+ for (i in 0 until trackGroups.length) {
+ formats_.add(trackGroups[i].getFormat(0))
+ }
+ return formats_
+ }
+
+ private val audioRendererIndex: Int
+ get() {
+ for (i in 0 until exoPlayer!!.rendererCount) {
+ if (exoPlayer?.getRendererType(i) == C.TRACK_TYPE_AUDIO) return i
+ }
+ return -1
+ }
+
+ private val videoWidth: Int
+ get() {
+ return exoPlayer?.videoFormat?.width ?: 0
+ }
+
+ private val videoHeight: Int
+ get() {
+ return exoPlayer?.videoFormat?.height ?: 0
+ }
+
+ init {
+ if (httpDataSourceFactory == null) {
+ runOnIOScope {
+ httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
+ .setUserAgent(ClientConfig.USER_AGENT)
+ }
+ }
+ if (exoPlayer == null) {
+ setupPlayerListener()
+ createStaticPlayer(context)
+ }
+ playbackParameters = exoPlayer!!.playbackParameters
+ val scope = CoroutineScope(Dispatchers.Main)
+ scope.launch {
+ while (true) {
+ delay(bufferUpdateInterval)
+ withContext(Dispatchers.Main) {
+ if (exoPlayer != null && bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) {
+ bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage)
+ bufferedPercentagePrev = exoPlayer!!.bufferedPercentage
+ }
+ }
+ }
+ }
+ }
+
+ @Throws(IllegalStateException::class)
+ private fun prepareWR() {
+ Logd(TAG, "prepareWR() called")
+ if (mediaSource == null && mediaItem == null) return
+ if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false)
+ else exoPlayer?.setMediaItem(mediaItem!!)
+ exoPlayer?.prepare()
+ }
+
+ private fun release() {
+ Logd(TAG, "release() called")
+ exoPlayer?.stop()
+ exoPlayer?.seekTo(0L)
+ audioSeekCompleteListener = null
+ audioCompletionListener = null
+ audioErrorListener = null
+ bufferingUpdateListener = null
+ }
+
+ private fun setAudioStreamType(i: Int) {
+ val a = exoPlayer!!.audioAttributes
+ val b = AudioAttributes.Builder()
+ b.setContentType(i)
+ b.setFlags(a.flags)
+ b.setUsage(a.usage)
+ 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 = StreamInfo.getInfo(vService, url)
+ val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
+ Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
+ val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1
+ val audioStream = audioStreamsList[audioIndex]
+ Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}")
+ val aSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri(
+ Uri.parse(audioStream.content)).build())
+ if (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().setTag(metadata).setUri(
+ Uri.parse(videoStream.content)).build())
+ val mediaSources: MutableList = ArrayList()
+ mediaSources.add(vSource)
+ mediaSources.add(aSource)
+ mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray())
+// mediaSource = null
+ } else mediaSource = aSource
+ mediaItem = mediaSource?.mediaItem
+ setSourceCredentials(user, password)
+ } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") }
+ } else {
+ Logd(TAG, "setDataSource1 setting for Podcast source")
+ setDataSource(metadata, url,user, password)
+ }
+ }
+
+ private fun setSourceCredentials(user: String?, password: String?) {
+ if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) {
+ if (httpDataSourceFactory == null)
+ httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
+ .setUserAgent(ClientConfig.USER_AGENT)
+
+ val requestProperties = HashMap()
+ requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
+ httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties)
+
+ val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!)
+ val extractorsFactory = DefaultExtractorsFactory()
+ extractorsFactory.setConstantBitrateSeekingEnabled(true)
+ extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA)
+ val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
+
+ mediaSource = f.createMediaSource(mediaItem!!)
+ }
+ }
+
+ /**
+ * Join the two lists of video streams (video_only and normal videos),
+ * and sort them according with default format chosen by the user.
+ *
+ * @param defaultFormat format to give preference
+ * @param showHigherResolutions show >1080p resolutions
+ * @param videoStreams normal videos list
+ * @param videoOnlyStreams video only stream list
+ * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest
+ * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only
+ * streams and normal video streams are available
+ * @return the sorted list
+ */
+ private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean,
+ preferVideoOnlyStreams: Boolean): List {
+ val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
+ val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
+ val comparator = compareBy { it.resolution.toResolutionValue() }
+ return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) }
+ }
+
+ private fun String.toResolutionValue(): Int {
+ val match = Regex("(\\d+)p|(\\d+)k").find(this)
+ return when {
+ match?.groupValues?.get(1) != null -> match.groupValues[1].toInt()
+ match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024
+ else -> 0
+ }
+ }
+
+ private fun getFilteredAudioStreams(audioStreams: List?): List {
+ if (audioStreams == null) return listOf()
+ val collectedStreams = mutableSetOf()
+ for (stream in audioStreams) {
+ Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}")
+ if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS))
+ continue
+ collectedStreams.add(stream)
+ }
+ return collectedStreams.toList().sortedWith(compareBy { it.bitrate })
+ }
+
+ /**
+ * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
+ * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
+ * not do anything.
+ * Whether playback starts immediately depends on the given parameters. See below for more details.
+ * States:
+ * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
+ * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
+ * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
+ * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
+ * will enter the ERROR state.
+ * This method is executed on an internal executor service.
+ * @param playable The Playable object that is supposed to be played. This parameter must not be null.
+ * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via
+ * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
+ * the Android MediaPlayer via getStreamUrl.
+ * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the
+ * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared
+ * for playback immediately (see 'prepareImmediately' parameter for more details)
+ * @param prepareImmediately Set to true if the method should also prepare the episode for playback.
+ */
+ override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
+ Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ")
+// showStackTrace()
+ if (curMedia != null) {
+ Logd(TAG, "playMediaObject: curMedia exist status=$status")
+ if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) {
+ Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
+ return
+ }
+ Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}")
+ // set temporarily to pause in order to update list with current position
+ if (status == PlayerStatus.PLAYING) {
+ val pos = curMedia?.getPosition() ?: -1
+ seekTo(pos)
+ callback.onPlaybackPause(curMedia, pos)
+ }
+ // stop playback of this episode
+ if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
+// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
+// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
+ setPlayerStatus(PlayerStatus.INDETERMINATE, null)
+ }
+
+ Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}")
+ curMedia = playable
+ if (curMedia is EpisodeMedia) {
+ val media_ = curMedia as EpisodeMedia
+ val item = media_.episodeOrFetch()
+ val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf()
+ curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id)
+ } else curIndexInQueue = -1
+
+ prevMedia = curMedia
+ this.isStreaming = streaming
+ mediaType = curMedia!!.getMediaType()
+ videoSize = null
+ createMediaPlayer()
+ this.startWhenPrepared.set(startWhenPrepared)
+ setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
+ val metadata = buildMetadata(curMedia!!)
+ try {
+ callback.ensureMediaInfoLoaded(curMedia!!)
+ callback.onMediaChanged(false)
+ setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
+ CoroutineScope(Dispatchers.IO).launch {
+ when {
+ streaming -> {
+ val streamurl = curMedia!!.getStreamUrl()
+ if (streamurl != null) {
+ val media = curMedia
+ if (media is EpisodeMedia) {
+ mediaItem = null
+ mediaSource = null
+ setDataSource(metadata, media)
+// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
+// if (startWhenPrepared) runBlocking { deferred.await() }
+// val preferences = media.episodeOrFetch()?.feed?.preferences
+// setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
+ } else setDataSource(metadata, streamurl, null, null)
+ }
+ }
+ else -> {
+ val localMediaurl = curMedia!!.getLocalMediaUrl()
+// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle
+// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null)
+ if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
+ else throw IOException("Unable to read local file $localMediaurl")
+ }
+ }
+ withContext(Dispatchers.Main) {
+ val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
+ if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
+ if (prepareImmediately) prepare()
+ }
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ setPlayerStatus(PlayerStatus.ERROR, null)
+ EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
+ } catch (e: IllegalStateException) {
+ e.printStackTrace()
+ setPlayerStatus(PlayerStatus.ERROR, null)
+ EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
+ } finally { }
+ }
+
+ override fun resume() {
+ if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
+ Logd(TAG, "Resuming/Starting playback")
+ acquireWifiLockIfNecessary()
+ setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
+ setVolume(1.0f, 1.0f)
+
+ if (curMedia != null && status == PlayerStatus.PREPARED && curMedia!!.getPosition() > 0) {
+ val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
+ seekTo(newPosition)
+ }
+ if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
+// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
+ exoPlayer?.play()
+ // Can't set params when paused - so always set it on start in case they changed
+ exoPlayer?.playbackParameters = playbackParameters
+ setPlayerStatus(PlayerStatus.PLAYING, curMedia)
+ if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!))
+ } else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status")
+ }
+
+ override fun pause(abandonFocus: Boolean, reinit: Boolean) {
+ releaseWifiLockIfNecessary()
+ if (status == PlayerStatus.PLAYING) {
+ Logd(TAG, "Pausing playback $abandonFocus $reinit")
+ exoPlayer?.pause()
+ setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition())
+ if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END))
+ if (isStreaming && reinit) reinit()
+ } else Logd(TAG, "Ignoring call to pause: Player is in $status state")
+ }
+
+ override fun prepare() {
+ if (status == PlayerStatus.INITIALIZED) {
+ Logd(TAG, "Preparing media player")
+ setPlayerStatus(PlayerStatus.PREPARING, curMedia)
+ prepareWR()
+// onPrepared(startWhenPrepared.get())
+ if (mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
+ if (curMedia != null) {
+ val pos = curMedia!!.getPosition()
+ if (pos > 0) seekTo(pos)
+ if (curMedia != null && curMedia!!.getDuration() <= 0) {
+ Logd(TAG, "Setting duration of media")
+ curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt())
+ }
+ }
+ setPlayerStatus(PlayerStatus.PREPARED, curMedia)
+ if (startWhenPrepared.get()) resume()
+ }
+ }
+
+ override fun reinit() {
+ Logd(TAG, "reinit() called")
+ releaseWifiLockIfNecessary()
+ when {
+ curMedia != null -> playMediaObject(curMedia!!, isStreaming, startWhenPrepared.get(), prepareImmediately = false, true)
+ else -> Logd(TAG, "Call to reinit: media and mediaPlayer were null, ignored")
+ }
+ }
+
+ override fun seekTo(t: Int) {
+ var t = t
+ if (t < 0) t = 0
+ Logd(TAG, "seekTo() called $t")
+
+ if (t >= getDuration()) {
+ Logd(TAG, "Seek reached end of file, skipping to next episode")
+ exoPlayer?.seekTo(t.toLong()) // can set curMedia to null
+ if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
+ audioSeekCompleteListener?.run()
+ endPlayback(true, wasSkipped = true, true, toStoppedState = true)
+ t = getPosition()
+// return
+ }
+
+ when (status) {
+ PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
+ Logd(TAG, "seekTo() called $t")
+ if (seekLatch != null && seekLatch!!.count > 0) {
+ try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) }
+ }
+ seekLatch = CountDownLatch(1)
+ statusBeforeSeeking = status
+ setPlayerStatus(PlayerStatus.SEEKING, curMedia, t)
+ exoPlayer?.seekTo(t.toLong())
+ if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
+ audioSeekCompleteListener?.run()
+ if (statusBeforeSeeking == PlayerStatus.PREPARED) curMedia?.setPosition(t)
+ try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) }
+ }
+ PlayerStatus.INITIALIZED -> {
+ curMedia?.setPosition(t)
+ startWhenPrepared.set(false)
+ prepare()
+ }
+ else -> {}
+ }
+ }
+
+ override fun getDuration(): Int {
+ return curMedia?.getDuration() ?: Playable.INVALID_TIME
+ }
+
+ override fun getPosition(): Int {
+ var retVal = Playable.INVALID_TIME
+ if (exoPlayer != null && status.isAtLeast(PlayerStatus.PREPARED)) retVal = exoPlayer!!.currentPosition.toInt()
+ if (retVal <= 0 && curMedia != null) retVal = curMedia!!.getPosition()
+ return retVal
+ }
+
+ override fun setPlaybackParams(speed: Float, skipSilence: Boolean) {
+ EventFlow.postEvent(FlowEvent.SpeedChangedEvent(speed))
+ Logd(TAG, "setPlaybackParams speed=$speed pitch=${playbackParameters.pitch} skipSilence=$skipSilence")
+ playbackParameters = PlaybackParameters(speed, playbackParameters.pitch)
+ exoPlayer!!.skipSilenceEnabled = skipSilence
+ exoPlayer!!.playbackParameters = playbackParameters
+ }
+
+ override fun getPlaybackSpeed(): Float {
+ var retVal = 1f
+ if (status == PlayerStatus.PLAYING || status == PlayerStatus.PAUSED || status == PlayerStatus.INITIALIZED || status == PlayerStatus.PREPARED)
+ retVal = playbackParameters.speed
+ return retVal
+ }
+
+ override fun setVolume(volumeLeft: Float, volumeRight: Float) {
+ var volumeLeft = volumeLeft
+ var volumeRight = volumeRight
+ val playable = curMedia
+ if (playable is EpisodeMedia) {
+ val preferences = playable.episodeOrFetch()?.feed?.preferences
+ if (preferences != null) {
+ val volumeAdaptionSetting = preferences.volumeAdaptionSetting
+ if (volumeAdaptionSetting != null) {
+ val adaptionFactor = volumeAdaptionSetting.adaptionFactor
+ volumeLeft *= adaptionFactor
+ volumeRight *= adaptionFactor
+ }
+ }
+ }
+ if (volumeLeft > 1) {
+ exoPlayer?.volume = 1f
+ loudnessEnhancer?.setEnabled(true)
+ loudnessEnhancer?.setTargetGain((1000 * (volumeLeft - 1)).toInt())
+ } else {
+ exoPlayer?.volume = volumeLeft
+ loudnessEnhancer?.setEnabled(false)
+ }
+ Logd(TAG, "Media player volume was set to $volumeLeft $volumeRight")
+ }
+
+ override fun shutdown() {
+ Logd(TAG, "shutdown() called")
+ try {
+ clearMediaPlayerListeners()
+// TODO: should use: exoPlayer!!.playWhenReady ?
+ if (exoPlayer?.isPlaying == true) exoPlayer?.stop()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ release()
+ status = PlayerStatus.STOPPED
+
+ isShutDown = true
+ releaseWifiLockIfNecessary()
+ }
+
+ override fun setVideoSurface(surface: SurfaceHolder?) {
+ exoPlayer?.setVideoSurfaceHolder(surface)
+ }
+
+ override fun resetVideoSurface() {
+ if (mediaType == MediaType.VIDEO) {
+ Logd(TAG, "Resetting video surface")
+ exoPlayer?.setVideoSurfaceHolder(null)
+ reinit()
+ } else Log.e(TAG, "Resetting video surface for media of Audio type")
+ }
+
+ /**
+ * Return width and height of the currently playing video as a pair.
+ * @return Width and height as a Pair or null if the video size could not be determined. The method might still
+ * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return
+ * invalid values.
+ */
+ override fun getVideoSize(): Pair? {
+ if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
+ return videoSize
+ }
+
+ override fun getAudioTracks(): List {
+ val trackNames: MutableList = ArrayList()
+ val trackNameProvider: TrackNameProvider = DefaultTrackNameProvider(context.resources)
+ for (format in formats) {
+ trackNames.add(trackNameProvider.getTrackName(format))
+ }
+ return trackNames
+ }
+
+ override fun setAudioTrack(track: Int) {
+ val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return
+ val trackGroups = trackInfo.getTrackGroups(audioRendererIndex)
+ val override = SelectionOverride(track, 0)
+ val rendererIndex = audioRendererIndex
+ val params = trackSelector!!.buildUponParameters().setSelectionOverride(rendererIndex, trackGroups, override)
+ trackSelector!!.setParameters(params)
+ }
+
+ override fun getSelectedAudioTrack(): Int {
+ val trackSelections = exoPlayer!!.currentTrackSelections
+ val availableFormats = formats
+ Logd(TAG, "selectedAudioTrack called tracks: ${trackSelections.length} formats: ${availableFormats.size}")
+ for (i in 0 until trackSelections.length) {
+ val track = trackSelections[i] as? ExoTrackSelection ?: continue
+ if (availableFormats.contains(track.selectedFormat)) return availableFormats.indexOf(track.selectedFormat)
+ }
+ return -1
+ }
+
+ override fun createMediaPlayer() {
+ Logd(TAG, "createMediaPlayer()")
+ release()
+ if (curMedia == null) {
+ status = PlayerStatus.STOPPED
+ return
+ }
+ setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH)
+ setMediaPlayerListeners()
+ }
+
+ override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) {
+ releaseWifiLockIfNecessary()
+ if (curMedia == null) return
+
+ val isPlaying = status == PlayerStatus.PLAYING
+ // we're relying on the position stored in the Playable object for post-playback processing
+ val position = getPosition()
+ if (position >= 0) curMedia?.setPosition(position)
+ Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState")
+
+ val currentMedia = curMedia
+ var nextMedia: Playable? = null
+ if (shouldContinue) {
+ // Load next episode if previous episode was in the queue and if there is an episode in the queue left.
+ // Start playback immediately if continuous playback is enabled
+ nextMedia = callback.getNextInQueue(currentMedia)
+ if (nextMedia != null) {
+ Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false")
+ if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null)
+ callback.onPlaybackEnded(nextMedia.getMediaType(), false)
+ // setting media to null signals to playMediaObject that we're taking care of post-playback processing
+ curMedia = null
+ playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying)
+ }
+ }
+ when {
+ shouldContinue || toStoppedState -> {
+ if (nextMedia == null) {
+ Logd(TAG, "nextMedia is null. call callback.onPlaybackEnded true")
+ callback.onPlaybackEnded(null, true)
+ curMedia = null
+ exoPlayer?.stop()
+ releaseWifiLockIfNecessary()
+ if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null)
+ else Logd(TAG, "Ignored call to stop: Current player state is: $status")
+ }
+ val hasNext = nextMedia != null
+ if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext)
+// curMedia = nextMedia
+ }
+ isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition())
+ }
+ }
+
+ override fun shouldLockWifi(): Boolean {
+ return isStreaming
+ }
+
+ private fun setMediaPlayerListeners() {
+ if (curMedia == null) return
+
+ audioCompletionListener = Runnable {
+ Logd(TAG, "audioCompletionListener called")
+ endPlayback(hasEnded = true, wasSkipped = false, shouldContinue = true, toStoppedState = true)
+ }
+ audioSeekCompleteListener = Runnable { this.genericSeekCompleteListener() }
+ bufferingUpdateListener = Consumer { percent: Int ->
+ when (percent) {
+ BUFFERING_STARTED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.started())
+ BUFFERING_ENDED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.ended())
+ else -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.progressUpdate(0.01f * percent))
+ }
+ }
+ audioErrorListener = Consumer { message: String ->
+ Log.e(TAG, "PlayerErrorEvent: $message")
+ EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(message))
+ }
+ }
+
+ private fun clearMediaPlayerListeners() {
+ audioCompletionListener = Runnable {}
+ audioSeekCompleteListener = Runnable {}
+ bufferingUpdateListener = Consumer { }
+ audioErrorListener = Consumer {}
+ }
+
+ private fun genericSeekCompleteListener() {
+ Logd(TAG, "genericSeekCompleteListener $status ${exoPlayer?.isPlaying} $statusBeforeSeeking")
+ seekLatch?.countDown()
+
+ if ((status == PlayerStatus.PLAYING || exoPlayer?.isPlaying != true) && curMedia != null) callback.onPlaybackStart(curMedia!!, getPosition())
+ if (status == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, curMedia, getPosition())
+ }
+
+ override fun isCasting(): Boolean {
+ return false
+ }
+
+ private fun setupPlayerListener() {
+ exoplayerListener = object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: @State Int) {
+ Logd(TAG, "onPlaybackStateChanged $playbackState")
+ when (playbackState) {
+ STATE_ENDED -> {
+ exoPlayer?.seekTo(C.TIME_UNSET)
+ audioCompletionListener?.run()
+ }
+ STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED)
+ else -> bufferingUpdateListener?.accept(BUFFERING_ENDED)
+ }
+ }
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
+ setPlayerStatus(stat, curMedia)
+ Logd(TAG, "onIsPlayingChanged $isPlaying")
+ }
+ override fun onPlayerError(error: PlaybackException) {
+ Logd(TAG, "onPlayerError ${error.message}")
+ if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked))
+ else {
+ var cause = error.cause
+ if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause
+ if (cause != null && "Source error" == cause.message) cause = cause.cause
+ audioErrorListener?.accept((if (cause != null) cause.message else error.message) ?:"no message")
+ }
+ }
+ override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) {
+ Logd(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason")
+ if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run()
+ }
+ override fun onAudioSessionIdChanged(audioSessionId: Int) {
+ Logd(TAG, "onAudioSessionIdChanged $audioSessionId")
+ initLoudnessEnhancer(audioSessionId)
+ }
+ }
+ }
+
+ companion object {
+ private val TAG: String = LocalMediaPlayer::class.simpleName ?: "Anonymous"
+
+ const val BUFFERING_STARTED: Int = -1
+ const val BUFFERING_ENDED: Int = -2
+
+ private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
+
+ private var trackSelector: DefaultTrackSelector? = null
+
+ var exoPlayer: ExoPlayer? = null
+
+ private var exoplayerListener: Player.Listener? = null
+ private var audioSeekCompleteListener: java.lang.Runnable? = null
+ private var audioCompletionListener: java.lang.Runnable? = null
+ private var audioErrorListener: Consumer? = null
+ private var bufferingUpdateListener: Consumer? = null
+ private var loudnessEnhancer: LoudnessEnhancer? = null
+
+ fun createStaticPlayer(context: Context) {
+ val loadControl = DefaultLoadControl.Builder()
+ loadControl.setBufferDurationsMs(30000, 120000, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
+ DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
+ loadControl.setBackBuffer(rewindSecs * 1000 + 500, true)
+ trackSelector = DefaultTrackSelector(context)
+ val audioOffloadPreferences = AudioOffloadPreferences.Builder()
+ .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) // Add additional options as needed
+ .setIsGaplessSupportRequired(true)
+ .setIsSpeedChangeSupportRequired(true)
+ .build()
+ Logd(TAG, "createStaticPlayer creating exoPlayer_")
+
+ val defaultRenderersFactory = DefaultRenderersFactory(context)
+// defaultRenderersFactory.setMediaCodecSelector { mimeType: String?, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean ->
+// val decoderInfos: List = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder)
+// val result: MutableList = ArrayList()
+// for (decoderInfo in decoderInfos) {
+// Logd(TAG, "decoderInfo.name: ${decoderInfo.name}")
+// if (decoderInfo.name == "c2.android.mp3.decoder") {
+// continue
+// }
+// result.add(decoderInfo)
+// }
+// result
+// }
+ exoPlayer = ExoPlayer.Builder(context, defaultRenderersFactory)
+ .setTrackSelector(trackSelector!!)
+ .setLoadControl(loadControl.build())
+ .build()
+
+ exoPlayer?.setSeekParameters(SeekParameters.EXACT)
+ exoPlayer!!.trackSelectionParameters = exoPlayer!!.trackSelectionParameters
+ .buildUpon()
+ .setAudioOffloadPreferences(audioOffloadPreferences)
+ .build()
+
+// if (BuildConfig.DEBUG) exoPlayer!!.addAnalyticsListener(EventLogger())
+
+ if (exoplayerListener != null) {
+ exoPlayer?.removeListener(exoplayerListener!!)
+ exoPlayer?.addListener(exoplayerListener!!)
+ }
+ initLoudnessEnhancer(exoPlayer!!.audioSessionId)
+ }
+
+ private fun initLoudnessEnhancer(audioStreamId: Int) {
+ runOnIOScope {
+ val newEnhancer = LoudnessEnhancer(audioStreamId)
+ val oldEnhancer = loudnessEnhancer
+ if (oldEnhancer != null) {
+ newEnhancer.setEnabled(oldEnhancer.enabled)
+ if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt())
+ oldEnhancer.release()
+ }
+ loudnessEnhancer = newEnhancer
+ }
+ }
+
+ fun cleanup() {
+ if (exoplayerListener != null) exoPlayer?.removeListener(exoplayerListener!!)
+ exoplayerListener = null
+ audioSeekCompleteListener = null
+ audioCompletionListener = null
+ audioErrorListener = null
+ bufferingUpdateListener = null
+ loudnessEnhancer = null
+ httpDataSourceFactory = null
+ }
+ }
+ }
+
+ /**
+ * Manages the background tasks of PlaybackSerivce, i.e.
+ * the sleep timer, the position saver, the widget updater and the queue loader.
+ *
+ * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback)
+ * to notify the PlaybackService about updates from the running tasks.
+ */
+ class TaskManager(private val context: Context, private val callback: PSTMCallback) {
+ private val schedExecutor: ScheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE) { r: java.lang.Runnable? ->
+ val t = Thread(r)
+ t.priority = Thread.MIN_PRIORITY
+ t
+ }
+
+ private var positionSaverFuture: ScheduledFuture<*>? = null
+ private var widgetUpdaterFuture: ScheduledFuture<*>? = null
+ private var sleepTimerFuture: ScheduledFuture<*>? = null
+
+// @Volatile
+// private var chapterLoaderFuture: Disposable? = null
+
+ private var sleepTimer: SleepTimer? = null
+
+ /**
+ * Returns true if the sleep timer is currently active.
+ */
+ @get:Synchronized
+ val isSleepTimerActive: Boolean
+ get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0
+
+ /**
+ * Returns the current sleep timer time or 0 if the sleep timer is not active.
+ */
+ @get:Synchronized
+ val sleepTimerTimeLeft: Long
+ get() = if (isSleepTimerActive) sleepTimer!!.getWaitingTime() else 0
+
+ /**
+ * Returns true if the widget updater is currently running.
+ */
+ @get:Synchronized
+ val isWidgetUpdaterActive: Boolean
+ get() = widgetUpdaterFuture != null && !widgetUpdaterFuture!!.isCancelled && !widgetUpdaterFuture!!.isDone
+
+ /**
+ * Returns true if the position saver is currently running.
+ */
+ @get:Synchronized
+ val isPositionSaverActive: Boolean
+ get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone
+
+ /**
+ * Starts the position saver task. If the position saver is already active, nothing will happen.
+ */
+ @Synchronized
+ fun startPositionSaver() {
+ if (!isPositionSaverActive) {
+ var positionSaver = Runnable { callback.positionSaverTick() }
+ positionSaver = useMainThreadIfNecessary(positionSaver)
+ positionSaverFuture = schedExecutor.scheduleWithFixedDelay(
+ positionSaver, POSITION_SAVER_WAITING_INTERVAL.toLong(), POSITION_SAVER_WAITING_INTERVAL.toLong(), TimeUnit.MILLISECONDS)
+ Logd(TAG, "Started PositionSaver")
+ } else Logd(TAG, "Call to startPositionSaver was ignored.")
+ }
+
+ /**
+ * Cancels the position saver. If the position saver is not running, nothing will happen.
+ */
+ @Synchronized
+ fun cancelPositionSaver() {
+ if (isPositionSaverActive) {
+ positionSaverFuture!!.cancel(false)
+ Logd(TAG, "Cancelled PositionSaver")
+ }
+ }
+
+ /**
+ * Starts the widget updater task. If the widget updater is already active, nothing will happen.
+ */
+ @Synchronized
+ fun startWidgetUpdater() {
+ if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) {
+ var widgetUpdater = Runnable { this.requestWidgetUpdate() }
+ widgetUpdater = useMainThreadIfNecessary(widgetUpdater)
+ widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(
+ widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), TimeUnit.MILLISECONDS)
+ Logd(TAG, "Started WidgetUpdater")
+ }
+ }
+
+ /**
+ * Retrieves information about the widget state in the calling thread and then displays it in a background thread.
+ */
+ @Synchronized
+ fun requestWidgetUpdate() {
+ val state = callback.requestWidgetState()
+ if (!schedExecutor.isShutdown) schedExecutor.execute { WidgetUpdater.updateWidget(context, state) }
+ else Logd(TAG, "Call to requestWidgetUpdate was ignored.")
+ }
+
+ /**
+ * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
+ * cancelled first.
+ * After waitingTime has elapsed, onSleepTimerExpired() will be called.
+ *
+ * @throws java.lang.IllegalArgumentException if waitingTime <= 0
+ */
+ @Synchronized
+ fun setSleepTimer(waitingTime: Long) {
+ require(waitingTime > 0) { "Waiting time <= 0" }
+
+ Logd(TAG, "Setting sleep timer to $waitingTime milliseconds")
+ if (isSleepTimerActive) sleepTimerFuture!!.cancel(true)
+ sleepTimer = SleepTimer(waitingTime)
+ sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS)
+ EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.justEnabled(waitingTime))
+ }
+
+ /**
+ * Disables the sleep timer. If the sleep timer is not active, nothing will happen.
+ */
+ @Synchronized
+ fun disableSleepTimer() {
+ if (isSleepTimerActive) {
+ Logd(TAG, "Disabling sleep timer")
+ sleepTimer!!.cancel()
+ }
+ }
+
+ /**
+ * Restarts the sleep timer. If the sleep timer is not active, nothing will happen.
+ */
+ @Synchronized
+ fun restartSleepTimer() {
+ if (isSleepTimerActive) {
+ Logd(TAG, "Restarting sleep timer")
+ sleepTimer!!.restart()
+ }
+ }
+
+ /**
+ * Cancels the widget updater. If the widget updater is not running, nothing will happen.
+ */
+ @Synchronized
+ fun cancelWidgetUpdater() {
+ if (isWidgetUpdaterActive) {
+ widgetUpdaterFuture!!.cancel(false)
+ Logd(TAG, "Cancelled WidgetUpdater")
+ }
+ }
+
+ /**
+ * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active,
+ * it will be cancelled first.
+ * On completion, the callback's onChapterLoaded method will be called.
+ */
+ @Synchronized
+ fun startChapterLoader(media: Playable) {
+// chapterLoaderFuture?.dispose()
+// chapterLoaderFuture = null
+
+ if (!media.chaptersLoaded()) {
+ val scope = CoroutineScope(Dispatchers.Main)
+ scope.launch(Dispatchers.IO) {
+ try {
+ ChapterUtils.loadChapters(media, context, false)
+ withContext(Dispatchers.Main) { callback.onChapterLoaded(media) }
+ } catch (e: Throwable) {
+ Logd(TAG, "Error loading chapters: ${Log.getStackTraceString(e)}")
+ }
+ }
+ }
+ }
+
+ /**
+ * Cancels all tasks. The PSTM will be in the initial state after execution of this method.
+ */
+ @Synchronized
+ fun cancelAllTasks() {
+ cancelPositionSaver()
+ cancelWidgetUpdater()
+ disableSleepTimer()
+
+// chapterLoaderFuture?.dispose()
+// chapterLoaderFuture = null
+ }
+
+ /**
+ * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after
+ * execution of this method.
+ */
+ fun shutdown() {
+ cancelAllTasks()
+ schedExecutor.shutdownNow()
+ }
+
+ private fun useMainThreadIfNecessary(runnable: java.lang.Runnable): java.lang.Runnable {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ // Called in main thread => ExoPlayer is used
+ // Run on ui thread even if called from schedExecutor
+ val handler = Handler(Looper.getMainLooper())
+ return Runnable { handler.post(runnable) }
+ } else return runnable
+ }
+
+ /**
+ * Sleeps for a given time and then pauses playback.
+ */
+ internal inner class SleepTimer(private val waitingTime: Long) : java.lang.Runnable {
+ private var hasVibrated = false
+ private var timeLeft = waitingTime
+ private var shakeListener: ShakeListener? = null
+
+ override fun run() {
+ Logd(TAG, "Starting SleepTimer")
+ var lastTick = System.currentTimeMillis()
+ EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft))
+ while (timeLeft > 0) {
+ try {
+ Thread.sleep(SLEEP_TIMER_UPDATE_INTERVAL)
+ } catch (e: InterruptedException) {
+ Logd(TAG, "Thread was interrupted while waiting")
+ e.printStackTrace()
+ break
+ }
+
+ val now = System.currentTimeMillis()
+ timeLeft -= now - lastTick
+ lastTick = now
+
+ EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft))
+ if (timeLeft < NOTIFICATION_THRESHOLD) {
+ Logd(TAG, "Sleep timer is about to expire")
+ if (SleepTimerPreferences.vibrate() && !hasVibrated) {
+ val v = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ if (v != null) {
+ v.vibrate(500)
+ hasVibrated = true
+ }
+ }
+ if (shakeListener == null && SleepTimerPreferences.shakeToReset()) shakeListener = ShakeListener(context, this)
+ }
+ if (timeLeft <= 0) {
+ Logd(TAG, "Sleep timer expired")
+ shakeListener?.pause()
+ shakeListener = null
+
+ hasVibrated = false
+ }
+ }
+ }
+ fun getWaitingTime(): Long {
+ return timeLeft
+ }
+ fun restart() {
+ EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled())
+ setSleepTimer(waitingTime)
+ shakeListener?.pause()
+ shakeListener = null
+ }
+ fun cancel() {
+ sleepTimerFuture!!.cancel(true)
+ shakeListener?.pause()
+ EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled())
+ }
+ }
+
+ interface PSTMCallback {
+ fun positionSaverTick()
+ fun requestWidgetState(): WidgetState
+ fun onChapterLoaded(media: Playable?)
+ }
+
+ internal class ShakeListener(private val mContext: Context, private val mSleepTimer: SleepTimer) :
+ SensorEventListener {
+ private var mAccelerometer: Sensor? = null
+ private var mSensorMgr: SensorManager? = null
+
+ init {
+ resume()
+ }
+ private fun resume() {
+ // only a precaution, the user should actually not be able to activate shake to reset
+ // when the accelerometer is not available
+ mSensorMgr = mContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ if (mSensorMgr == null) throw UnsupportedOperationException("Sensors not supported")
+
+ mAccelerometer = mSensorMgr!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ if (!mSensorMgr!!.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_UI)) { // if not supported
+ mSensorMgr!!.unregisterListener(this)
+ throw UnsupportedOperationException("Accelerometer not supported")
+ }
+ }
+ fun pause() {
+ mSensorMgr?.unregisterListener(this)
+ mSensorMgr = null
+ }
+ override fun onSensorChanged(event: SensorEvent) {
+ val gX = event.values[0] / SensorManager.GRAVITY_EARTH
+ val gY = event.values[1] / SensorManager.GRAVITY_EARTH
+ val gZ = event.values[2] / SensorManager.GRAVITY_EARTH
+
+ val gForce = sqrt((gX * gX + gY * gY + gZ * gZ).toDouble())
+ if (gForce > 2.25) {
+ Logd(TAG, "Detected shake $gForce")
+ mSleepTimer.restart()
+ }
+ }
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+ companion object {
+ private val TAG: String = ShakeListener::class.simpleName ?: "Anonymous"
+ }
+ }
+
+ companion object {
+ private val TAG: String = TaskManager::class.simpleName ?: "Anonymous"
+
+ private const val SCHED_EX_POOL_SIZE = 2
+
+ private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds
+ const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000 // in millisoconds
+ const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds
+ const val NOTIFICATION_THRESHOLD: Long = 10000 // in millisoconds
+ }
+ }
+
companion object {
private val TAG: String = PlaybackService::class.simpleName ?: "Anonymous"
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt
deleted file mode 100644
index 267fcff4..00000000
--- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/TaskManager.kt
+++ /dev/null
@@ -1,354 +0,0 @@
-package ac.mdiq.podcini.playback.service
-
-import ac.mdiq.podcini.preferences.SleepTimerPreferences
-import ac.mdiq.podcini.storage.model.Playable
-import ac.mdiq.podcini.ui.widget.WidgetUpdater
-import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState
-import ac.mdiq.podcini.storage.utils.ChapterUtils
-import ac.mdiq.podcini.util.Logd
-import ac.mdiq.podcini.util.EventFlow
-import ac.mdiq.podcini.util.FlowEvent
-import android.content.Context
-import android.hardware.Sensor
-import android.hardware.SensorEvent
-import android.hardware.SensorEventListener
-import android.hardware.SensorManager
-import android.os.Handler
-import android.os.Looper
-import android.os.Vibrator
-import android.util.Log
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.util.concurrent.ScheduledFuture
-import java.util.concurrent.ScheduledThreadPoolExecutor
-import java.util.concurrent.TimeUnit
-import kotlin.math.sqrt
-
-/**
- * Manages the background tasks of PlaybackSerivce, i.e.
- * the sleep timer, the position saver, the widget updater and
- * the queue loader.
- *
- *
- * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback)
- * to notify the PlaybackService about updates from the running tasks.
- */
-class TaskManager(private val context: Context, private val callback: PSTMCallback) {
- private val schedExecutor: ScheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE) { r: Runnable? ->
- val t = Thread(r)
- t.priority = Thread.MIN_PRIORITY
- t
- }
-
- private var positionSaverFuture: ScheduledFuture<*>? = null
- private var widgetUpdaterFuture: ScheduledFuture<*>? = null
- private var sleepTimerFuture: ScheduledFuture<*>? = null
-
-// @Volatile
-// private var chapterLoaderFuture: Disposable? = null
-
- private var sleepTimer: SleepTimer? = null
-
- /**
- * Returns true if the sleep timer is currently active.
- */
- @get:Synchronized
- val isSleepTimerActive: Boolean
- get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0
-
- /**
- * Returns the current sleep timer time or 0 if the sleep timer is not active.
- */
- @get:Synchronized
- val sleepTimerTimeLeft: Long
- get() = if (isSleepTimerActive) sleepTimer!!.getWaitingTime() else 0
-
- /**
- * Returns true if the widget updater is currently running.
- */
- @get:Synchronized
- val isWidgetUpdaterActive: Boolean
- get() = widgetUpdaterFuture != null && !widgetUpdaterFuture!!.isCancelled && !widgetUpdaterFuture!!.isDone
-
- /**
- * Returns true if the position saver is currently running.
- */
- @get:Synchronized
- val isPositionSaverActive: Boolean
- get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone
-
- /**
- * Starts the position saver task. If the position saver is already active, nothing will happen.
- */
- @Synchronized
- fun startPositionSaver() {
- if (!isPositionSaverActive) {
- var positionSaver = Runnable { callback.positionSaverTick() }
- positionSaver = useMainThreadIfNecessary(positionSaver)
- positionSaverFuture = schedExecutor.scheduleWithFixedDelay(
- positionSaver, POSITION_SAVER_WAITING_INTERVAL.toLong(), POSITION_SAVER_WAITING_INTERVAL.toLong(), TimeUnit.MILLISECONDS)
- Logd(TAG, "Started PositionSaver")
- } else Logd(TAG, "Call to startPositionSaver was ignored.")
- }
-
- /**
- * Cancels the position saver. If the position saver is not running, nothing will happen.
- */
- @Synchronized
- fun cancelPositionSaver() {
- if (isPositionSaverActive) {
- positionSaverFuture!!.cancel(false)
- Logd(TAG, "Cancelled PositionSaver")
- }
- }
-
- /**
- * Starts the widget updater task. If the widget updater is already active, nothing will happen.
- */
- @Synchronized
- fun startWidgetUpdater() {
- if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) {
- var widgetUpdater = Runnable { this.requestWidgetUpdate() }
- widgetUpdater = useMainThreadIfNecessary(widgetUpdater)
- widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(
- widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), WIDGET_UPDATER_NOTIFICATION_INTERVAL.toLong(), TimeUnit.MILLISECONDS)
- Logd(TAG, "Started WidgetUpdater")
- }
- }
-
- /**
- * Retrieves information about the widget state in the calling thread and then displays it in a background thread.
- */
- @Synchronized
- fun requestWidgetUpdate() {
- val state = callback.requestWidgetState()
- if (!schedExecutor.isShutdown) schedExecutor.execute { WidgetUpdater.updateWidget(context, state) }
- else Logd(TAG, "Call to requestWidgetUpdate was ignored.")
- }
-
- /**
- * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
- * cancelled first.
- * After waitingTime has elapsed, onSleepTimerExpired() will be called.
- *
- * @throws java.lang.IllegalArgumentException if waitingTime <= 0
- */
- @Synchronized
- fun setSleepTimer(waitingTime: Long) {
- require(waitingTime > 0) { "Waiting time <= 0" }
-
- Logd(TAG, "Setting sleep timer to $waitingTime milliseconds")
- if (isSleepTimerActive) sleepTimerFuture!!.cancel(true)
- sleepTimer = SleepTimer(waitingTime)
- sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS)
- EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.justEnabled(waitingTime))
- }
-
- /**
- * Disables the sleep timer. If the sleep timer is not active, nothing will happen.
- */
- @Synchronized
- fun disableSleepTimer() {
- if (isSleepTimerActive) {
- Logd(TAG, "Disabling sleep timer")
- sleepTimer!!.cancel()
- }
- }
-
- /**
- * Restarts the sleep timer. If the sleep timer is not active, nothing will happen.
- */
- @Synchronized
- fun restartSleepTimer() {
- if (isSleepTimerActive) {
- Logd(TAG, "Restarting sleep timer")
- sleepTimer!!.restart()
- }
- }
-
- /**
- * Cancels the widget updater. If the widget updater is not running, nothing will happen.
- */
- @Synchronized
- fun cancelWidgetUpdater() {
- if (isWidgetUpdaterActive) {
- widgetUpdaterFuture!!.cancel(false)
- Logd(TAG, "Cancelled WidgetUpdater")
- }
- }
-
- /**
- * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active,
- * it will be cancelled first.
- * On completion, the callback's onChapterLoaded method will be called.
- */
- @Synchronized
- fun startChapterLoader(media: Playable) {
-// chapterLoaderFuture?.dispose()
-// chapterLoaderFuture = null
-
- if (!media.chaptersLoaded()) {
- val scope = CoroutineScope(Dispatchers.Main)
- scope.launch(Dispatchers.IO) {
- try {
- ChapterUtils.loadChapters(media, context, false)
- withContext(Dispatchers.Main) { callback.onChapterLoaded(media) }
- } catch (e: Throwable) {
- Logd(TAG, "Error loading chapters: ${Log.getStackTraceString(e)}")
- }
- }
- }
- }
-
- /**
- * Cancels all tasks. The PSTM will be in the initial state after execution of this method.
- */
- @Synchronized
- fun cancelAllTasks() {
- cancelPositionSaver()
- cancelWidgetUpdater()
- disableSleepTimer()
-
-// chapterLoaderFuture?.dispose()
-// chapterLoaderFuture = null
- }
-
- /**
- * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after
- * execution of this method.
- */
- fun shutdown() {
- cancelAllTasks()
- schedExecutor.shutdownNow()
- }
-
- private fun useMainThreadIfNecessary(runnable: Runnable): Runnable {
- if (Looper.myLooper() == Looper.getMainLooper()) {
- // Called in main thread => ExoPlayer is used
- // Run on ui thread even if called from schedExecutor
- val handler = Handler(Looper.getMainLooper())
- return Runnable { handler.post(runnable) }
- } else return runnable
- }
-
- /**
- * Sleeps for a given time and then pauses playback.
- */
- internal inner class SleepTimer(private val waitingTime: Long) : Runnable {
- private var hasVibrated = false
- private var timeLeft = waitingTime
- private var shakeListener: ShakeListener? = null
-
- override fun run() {
- Logd(TAG, "Starting SleepTimer")
- var lastTick = System.currentTimeMillis()
- EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft))
- while (timeLeft > 0) {
- try {
- Thread.sleep(SLEEP_TIMER_UPDATE_INTERVAL)
- } catch (e: InterruptedException) {
- Logd(TAG, "Thread was interrupted while waiting")
- e.printStackTrace()
- break
- }
-
- val now = System.currentTimeMillis()
- timeLeft -= now - lastTick
- lastTick = now
-
- EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft))
- if (timeLeft < NOTIFICATION_THRESHOLD) {
- Logd(TAG, "Sleep timer is about to expire")
- if (SleepTimerPreferences.vibrate() && !hasVibrated) {
- val v = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
- if (v != null) {
- v.vibrate(500)
- hasVibrated = true
- }
- }
- if (shakeListener == null && SleepTimerPreferences.shakeToReset()) shakeListener = ShakeListener(context, this)
- }
- if (timeLeft <= 0) {
- Logd(TAG, "Sleep timer expired")
- shakeListener?.pause()
- shakeListener = null
-
- hasVibrated = false
- }
- }
- }
- fun getWaitingTime(): Long {
- return timeLeft
- }
- fun restart() {
- EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled())
- setSleepTimer(waitingTime)
- shakeListener?.pause()
- shakeListener = null
- }
- fun cancel() {
- sleepTimerFuture!!.cancel(true)
- shakeListener?.pause()
- EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.cancelled())
- }
- }
-
- interface PSTMCallback {
- fun positionSaverTick()
- fun requestWidgetState(): WidgetState
- fun onChapterLoaded(media: Playable?)
- }
-
- internal class ShakeListener(private val mContext: Context, private val mSleepTimer: SleepTimer) : SensorEventListener {
- private var mAccelerometer: Sensor? = null
- private var mSensorMgr: SensorManager? = null
-
- init {
- resume()
- }
- private fun resume() {
- // only a precaution, the user should actually not be able to activate shake to reset
- // when the accelerometer is not available
- mSensorMgr = mContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
- if (mSensorMgr == null) throw UnsupportedOperationException("Sensors not supported")
-
- mAccelerometer = mSensorMgr!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
- if (!mSensorMgr!!.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_UI)) { // if not supported
- mSensorMgr!!.unregisterListener(this)
- throw UnsupportedOperationException("Accelerometer not supported")
- }
- }
- fun pause() {
- mSensorMgr?.unregisterListener(this)
- mSensorMgr = null
- }
- override fun onSensorChanged(event: SensorEvent) {
- val gX = event.values[0] / SensorManager.GRAVITY_EARTH
- val gY = event.values[1] / SensorManager.GRAVITY_EARTH
- val gZ = event.values[2] / SensorManager.GRAVITY_EARTH
-
- val gForce = sqrt((gX * gX + gY * gY + gZ * gZ).toDouble())
- if (gForce > 2.25) {
- Logd(TAG, "Detected shake $gForce")
- mSleepTimer.restart()
- }
- }
- override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
- companion object {
- private val TAG: String = ShakeListener::class.simpleName ?: "Anonymous"
- }
- }
-
- companion object {
- private val TAG: String = TaskManager::class.simpleName ?: "Anonymous"
-
- private const val SCHED_EX_POOL_SIZE = 2
-
- private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds
- const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000 // in millisoconds
- const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds
- const val NOTIFICATION_THRESHOLD: Long = 10000 // in millisoconds
- }
-}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt
index 615dc049..cd28a176 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/UserPreferences.kt
@@ -96,19 +96,6 @@ object UserPreferences {
}
}
- var videoPlaybackSpeed: Float
- get() {
- try { return appPrefs.getString(Prefs.prefVideoPlaybackSpeed.name, "1.00")!!.toFloat()
- } catch (e: NumberFormatException) {
- Log.e(TAG, Log.getStackTraceString(e))
- videoPlaybackSpeed = 1.0f
- return 1.0f
- }
- }
- set(speed) {
- appPrefs.edit().putString(Prefs.prefVideoPlaybackSpeed.name, speed.toString()).apply()
- }
-
var isSkipSilence: Boolean
get() = appPrefs.getBoolean(Prefs.prefSkipSilence.name, false)
set(skipSilence) {
@@ -364,7 +351,6 @@ object UserPreferences {
// Mediaplayer
prefPlaybackSpeed,
- prefVideoPlaybackSpeed,
prefSkipSilence,
prefFastForwardSecs,
prefRewindSecs,
@@ -372,7 +358,7 @@ object UserPreferences {
prefVideoPlaybackMode,
}
- // Constants
+ @Suppress("ClassName")
enum class NOTIFICATION_BUTTON {
REWIND,
FAST_FORWARD,
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt
index a5e54441..09d827ae 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Queues.kt
@@ -262,7 +262,6 @@ object Queues {
}
if (queueNew.id == curQueue.id) {
queueNew.episodes.clear()
-// queueNew.episodes.addAll(qItems)
curQueue = queueNew
}
for (event in events) EventFlow.postEvent(event)
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt
index f3f8cad1..7cc54b3f 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/MediaType.kt
@@ -1,7 +1,7 @@
package ac.mdiq.podcini.storage.model
enum class MediaType {
- AUDIO, VIDEO, FLASH, UNKNOWN;
+ AUDIO, VIDEO, UNKNOWN;
companion object {
private val AUDIO_APPLICATION_MIME_STRINGS: Set = HashSet(mutableListOf(
@@ -9,7 +9,6 @@ enum class MediaType {
"application/opus",
"application/x-flac"
))
- private val VIDEO_APPLICATION_MIME_STRINGS: Set = HashSet(mutableListOf("application/x-shockwave-flash"))
fun fromMimeType(mimeType: String?): MediaType {
return when {
@@ -17,7 +16,6 @@ enum class MediaType {
mimeType.startsWith("audio") -> AUDIO
mimeType.startsWith("video") -> VIDEO
AUDIO_APPLICATION_MIME_STRINGS.contains(mimeType) -> AUDIO
- VIDEO_APPLICATION_MIME_STRINGS.contains(mimeType) -> FLASH
else -> UNKNOWN
}
}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt
index 2ccebc79..9ff20af0 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Playable.kt
@@ -81,7 +81,7 @@ interface Playable : Parcelable, Serializable {
* Returns an url to a local file that can be played or null if this file
* does not exist.
*/
- fun getLocalMediaUrl(): String?
+ fun getLocalMediaUrl(): String? { return null}
/**
* Returns an url to a file that can be streamed by the player or null if
@@ -93,7 +93,7 @@ interface Playable : Parcelable, Serializable {
* Returns true if a local file that can be played is available. getFileUrl
* MUST return a non-null string if this method returns true.
*/
- fun localFileAvailable(): Boolean
+ fun localFileAvailable(): Boolean { return false}
/**
* This method should be called every time playback starts on this object.
@@ -101,7 +101,7 @@ interface Playable : Parcelable, Serializable {
*
* Position held by this Playable should be set accurately before a call to this method is made.
*/
- fun onPlaybackStart()
+ fun onPlaybackStart() {}
/**
* This method should be called every time playback pauses or stops on this object,
@@ -112,13 +112,13 @@ interface Playable : Parcelable, Serializable {
*
* Position held by this Playable should be set accurately before a call to this method is made.
*/
- fun onPlaybackPause(context: Context)
+ fun onPlaybackPause(context: Context) {}
/**
* This method should be called when playback completes for this object.
* @param context
*/
- fun onPlaybackCompleted(context: Context)
+ fun onPlaybackCompleted(context: Context) {}
/**
* Returns an integer that must be unique among all Playable classes. The
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt
index f9840934..697659c3 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/RemoteMedia.kt
@@ -139,13 +139,13 @@ class RemoteMedia : Playable {
return streamUrl
}
- override fun getLocalMediaUrl(): String? {
- return null
- }
+// override fun getLocalMediaUrl(): String? {
+// return null
+// }
- override fun localFileAvailable(): Boolean {
- return false
- }
+// override fun localFileAvailable(): Boolean {
+// return false
+// }
override fun setPosition(newPosition: Int) {
position = newPosition
@@ -159,17 +159,17 @@ class RemoteMedia : Playable {
this.lastPlayedTime = lastPlayedTime
}
- override fun onPlaybackStart() {
- // no-op
- }
+// override fun onPlaybackStart() {
+// // no-op
+// }
- override fun onPlaybackPause(context: Context) {
- // no-op
- }
+// override fun onPlaybackPause(context: Context) {
+// // no-op
+// }
- override fun onPlaybackCompleted(context: Context) {
- // no-op
- }
+// override fun onPlaybackCompleted(context: Context) {
+// // no-op
+// }
override fun getPlayableType(): Int {
return PLAYABLE_TYPE_REMOTE_MEDIA
}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt
index 3c9fb1fe..f600dab0 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt
@@ -34,7 +34,7 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis
icon.setImageResource(getDrawable())
}
- protected fun playVideo(context: Context, media: Playable) {
+ protected fun playVideoIfNeeded(context: Context, media: Playable) {
if (item.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY
&& videoPlayMode != VideoMode.AUDIO_ONLY.code && videoMode != VideoMode.AUDIO_ONLY
&& media.getMediaType() == MediaType.VIDEO)
@@ -50,7 +50,7 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis
}
// Logd("ItemActionButton", "forItem: ${episode.feedId} ${episode.feed?.isLocalFeed} ${media.downloaded} ${isCurrentlyPlaying(media)} ${episode.title} ")
return when {
- media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode)
+// media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode)
isCurrentlyPlaying(media) -> PauseActionButton(episode)
episode.feed != null && episode.feed!!.isLocalFeed -> PlayLocalActionButton(episode)
media.downloaded -> PlayActionButton(episode)
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt
index 5e5e7861..d89592b7 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt
@@ -46,11 +46,7 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) {
EventFlow.postEvent(FlowEvent.PlayEvent(item))
}
-// if (item.feed?.preferences?.videoModePolicy != FeedPreferences.VideomodePolicy.AUDIO_ONLY
-// && videoPlayMode != VideoMode.AUDIO_ONLY.mode && videoMode != VideoMode.AUDIO_ONLY
-// && media.getMediaType() == MediaType.VIDEO)
-// context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO))
- playVideo(context, media)
+ playVideoIfNeeded(context, media)
}
/**
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt
index f36ef13a..3f5691ed 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt
@@ -30,23 +30,14 @@ class StreamActionButton(item: Episode) : EpisodeActionButton(item) {
// Logd("StreamActionButton", "item.feed: ${item.feedId}")
val media = if (item.feedId != null) item.media!! else RemoteMedia(item)
logAction(UsageStatistics.ACTION_STREAM)
-
if (!isStreamingAllowed) {
StreamingConfirmationDialog(context, media).show()
return
}
-
- PlaybackServiceStarter(context, media)
- .shouldStreamThisTime(true)
- .callEvenIfRunning(true)
- .start()
+ PlaybackServiceStarter(context, media).shouldStreamThisTime(true).callEvenIfRunning(true).start()
EventFlow.postEvent(FlowEvent.PlayEvent(item))
-// if (item.feed?.preferences?.videoModePolicy != FeedPreferences.VideomodePolicy.AUDIO_ONLY
-// && videoPlayMode != VideoMode.AUDIO_ONLY.mode && videoMode != VideoMode.AUDIO_ONLY
-// && media.getMediaType() == MediaType.VIDEO)
-// context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO))
- playVideo(context, media)
+ playVideoIfNeeded(context, media)
}
class StreamingConfirmationDialog(private val context: Context, private val playable: Playable) {
@@ -63,13 +54,9 @@ class StreamActionButton(item: Episode) : EpisodeActionButton(item) {
.setNeutralButton(R.string.cancel_label, null)
.show()
}
-
@UnstableApi
private fun stream() {
- PlaybackServiceStarter(context, playable)
- .callEvenIfRunning(true)
- .shouldStreamThisTime(true)
- .start()
+ PlaybackServiceStarter(context, playable).callEvenIfRunning(true).shouldStreamThisTime(true).start()
}
}
}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt
index 7376e185..9307d3ba 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt
@@ -282,7 +282,7 @@ class MainActivity : CastEnabledActivity() {
}
if (downloadUrl == null) continue
- Logd(TAG, "workInfo.state: ${workInfo.state}")
+// Logd(TAG, "workInfo.state: ${workInfo.state}")
var status: Int
status = when (workInfo.state) {
WorkInfo.State.RUNNING -> DownloadStatus.STATE_RUNNING
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt
index aeb63521..4250ee94 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt
@@ -3,8 +3,8 @@ package ac.mdiq.podcini.ui.adapter
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding
import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding
-import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
+import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource
import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed
import ac.mdiq.podcini.playback.base.InTheatre
@@ -19,7 +19,6 @@ import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed
-import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.*
@@ -81,7 +80,6 @@ import okhttp3.Request.Builder
import java.io.File
import java.lang.ref.WeakReference
import java.util.*
-import kotlin.collections.ArrayList
import kotlin.math.max
/**
@@ -121,9 +119,11 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac
@UnstableApi
fun refreshPosCallback(pos: Int, episode: Episode) {
Logd(TAG, "refreshPosCallback: $pos ${episode.title}")
- if (pos >= 0 && pos < episodes.size) episodes[pos] = episode
+ if (pos >= 0 && pos < episodes.size && episodes[pos].id == episode.id) {
+ episodes[pos] = episode
// notifyItemChanged(pos, "foo")
- refreshFragPosCallback?.invoke(pos, episode)
+ refreshFragPosCallback?.invoke(pos, episode)
+ }
}
fun clearData() {
@@ -132,11 +132,6 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac
notifyDataSetChanged()
}
-// fun setDummyViews(dummyViews: Int) {
-// this.dummyViews = dummyViews
-// notifyDataSetChanged()
-// }
-
fun updateItems(items: MutableList, feed_: Feed? = null) {
episodes = items
feed = feed_
@@ -156,12 +151,8 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac
@UnstableApi
override fun onBindViewHolder(holder: EpisodeViewHolder, pos: Int) {
-// Logd(TAG, "onBindViewHolder $pos ${episodes[pos].title}")
+ Logd(TAG, "onBindViewHolder $pos ${episodes[pos].title}")
if (pos >= episodes.size || pos < 0) {
-// beforeBindViewHolder(holder, pos)
-// holder.bindDummy()
-// afterBindViewHolder(holder, pos)
-// holder.hideSeparatorIfNecessary()
Logd(TAG, "onBindViewHolder got invalid pos: $pos of ${episodes.size}")
return
}
@@ -539,14 +530,14 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac
}
if (episode != null) {
actionButton1 = when {
- media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!)
+// media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!)
InTheatre.isCurrentlyPlaying(media) -> PauseActionButton(episode!!)
episode!!.feed != null && episode!!.feed!!.isLocalFeed -> PlayLocalActionButton(episode!!)
media.downloaded -> PlayActionButton(episode!!)
else -> StreamActionButton(episode!!)
}
actionButton2 = when {
- media.getMediaType() == MediaType.FLASH || episode!!.feed?.type == Feed.FeedType.YOUTUBE.name -> VisitWebsiteActionButton(episode!!)
+ episode!!.feed?.type == Feed.FeedType.YOUTUBE.name -> VisitWebsiteActionButton(episode!!)
dls != null && media.downloadUrl != null && dls.isDownloadingEpisode(media.downloadUrl!!) -> CancelDownloadActionButton(episode!!)
!media.downloaded -> DownloadActionButton(episode!!)
else -> DeleteActionButton(episode!!)
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt
index 89d06478..917fb53f 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt
@@ -1,25 +1,23 @@
package ac.mdiq.podcini.ui.dialog
+//import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SpeedSelectDialogBinding
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB
-import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
-import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.EpisodeMedia
-import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.ui.utils.ItemOffsetDecoration
import ac.mdiq.podcini.ui.view.PlaybackSpeedSeekBar
-import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
+import ac.mdiq.podcini.util.Logd
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -165,9 +163,8 @@ open class VariableSpeedDialog : BottomSheetDialogFragment() {
private fun addCurrentSpeed() {
val newSpeed = speedSeekBar.currentSpeed
- if (selectedSpeeds.contains(newSpeed)) {
- Snackbar.make(addCurrentSpeedChip, getString(R.string.preset_already_exists, newSpeed), Snackbar.LENGTH_LONG).show()
- } else {
+ if (selectedSpeeds.contains(newSpeed)) Snackbar.make(addCurrentSpeedChip, getString(R.string.preset_already_exists, newSpeed), Snackbar.LENGTH_LONG).show()
+ else {
selectedSpeeds.add(newSpeed)
selectedSpeeds.sort()
playbackSpeedArray = selectedSpeeds
@@ -248,28 +245,20 @@ open class VariableSpeedDialog : BottomSheetDialogFragment() {
playbackService!!.isSpeedForward = false
playbackService!!.isFallbackSpeed = false
- if (currentMediaType == MediaType.VIDEO) {
- setCurTempSpeed(speed)
- videoPlaybackSpeed = speed
- playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
- } else {
- if (codeArray != null && codeArray.size == 3) {
- Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}")
- if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed)
- if (codeArray[1]) {
- val episode = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: curEpisode
- if (episode?.feed?.preferences != null) {
- upsertBlk(episode.feed!!) { it.preferences!!.playSpeed = speed }
- }
- }
- if (codeArray[0]) {
- setCurTempSpeed(speed)
- playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
- }
- } else {
+ if (codeArray != null && codeArray.size == 3) {
+ Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}")
+ if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed)
+ if (codeArray[1]) {
+ val episode = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: curEpisode
+ if (episode?.feed?.preferences != null) upsertBlk(episode.feed!!) { it.preferences!!.playSpeed = speed }
+ }
+ if (codeArray[0]) {
setCurTempSpeed(speed)
playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
}
+ } else {
+ setCurTempSpeed(speed)
+ playbackService!!.mPlayer?.setPlaybackParams(speed, isSkipSilence)
}
}
else {
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt
index 9cdf9fcd..8cee4e48 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt
@@ -12,7 +12,6 @@ import ac.mdiq.podcini.net.feed.FeedUrlNotFoundException
import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.net.feed.parser.FeedHandler
-import ac.mdiq.podcini.net.feed.parser.FeedHandlerResult
import ac.mdiq.podcini.net.utils.HtmlToPlainText
import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
@@ -394,7 +393,7 @@ class OnlineFeedFragment : Fragment() {
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Throws(Exception::class)
- private fun doParseFeed(destination: String): FeedHandlerResult? {
+ private fun doParseFeed(destination: String): FeedHandler.FeedHandlerResult? {
val destinationFile = File(destination)
return try {
val feed = Feed(selectedDownloadUrl, null)
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt
index 735151ef..c29e8741 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt
@@ -110,17 +110,15 @@ class PlayerDetailsFragment : Fragment() {
return binding.root
}
- override fun onStart() {
- Logd(TAG, "onStart()")
- super.onStart()
-// procFlowEvents()
- }
+// override fun onStart() {
+// Logd(TAG, "onStart()")
+// super.onStart()
+// }
- override fun onStop() {
- Logd(TAG, "onStop()")
- super.onStop()
-// cancelFlowEvents()
- }
+// override fun onStop() {
+// Logd(TAG, "onStop()")
+// super.onStop()
+// }
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
@@ -229,9 +227,8 @@ class PlayerDetailsFragment : Fragment() {
val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!)
binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) }
}
- } else {
- binding.txtvPodcastTitle.setOnClickListener(null)
- }
+ } else binding.txtvPodcastTitle.setOnClickListener(null)
+
binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) }
binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr)
binding.txtvEpisodeTitle.text = currentItem?.title
@@ -256,7 +253,6 @@ class PlayerDetailsFragment : Fragment() {
set.start()
}
}
-
displayedChapterIndex = -1
refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage
updateChapterControlVisibility()
@@ -357,7 +353,6 @@ class PlayerDetailsFragment : Fragment() {
@UnstableApi private fun seekToNextChapter() {
if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= playable!!.getChapters().size) return
-
refreshChapterData(displayedChapterIndex + 1)
seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt())
}
@@ -399,7 +394,6 @@ class PlayerDetailsFragment : Fragment() {
}
Logd(TAG, "reset scroll Position: 0")
binding.itemDescriptionFragment.scrollTo(0, 0)
-
return true
}
}
@@ -414,9 +408,7 @@ class PlayerDetailsFragment : Fragment() {
fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
if (playable?.getIdentifier() != event.media?.getIdentifier()) return
val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position)
- if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) {
- refreshChapterData(newChapterIndex)
- }
+ if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) refreshChapterData(newChapterIndex)
}
fun setItem(item_: Episode) {
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt
index f6b077ec..91d82109 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt
@@ -84,6 +84,7 @@ import com.leinardi.android.speeddial.SpeedDialView
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import java.util.*
+import kotlin.math.max
/**
* Shows all items in the queue.
@@ -153,7 +154,7 @@ import java.util.*
curQueue = upsertBlk(queues[position]) { it.update() }
toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(curQueue.name != "Default")
loadCurQueue(true)
- playbackService?.notifyCurQueueItemsChanged(Math.max(prevQueueSize, curQueue.size()))
+ playbackService?.notifyCurQueueItemsChanged(max(prevQueueSize, curQueue.size()))
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
@@ -231,14 +232,11 @@ import java.util.*
procFlowEvents()
val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java))
browserFuture = MediaBrowser.Builder(requireContext(), sessionToken).buildAsync()
- browserFuture.addListener(
- {
- // here we can get the root of media items tree or we can get also the children if it is an album for example.
- mediaBrowser = browserFuture.get()
- mediaBrowser?.subscribe("CurQueue", null)
- },
- MoreExecutors.directExecutor()
- )
+ browserFuture.addListener({
+ // here we can get the root of media items tree or we can get also the children if it is an album for example.
+ mediaBrowser = browserFuture.get()
+ mediaBrowser?.subscribe("CurQueue", null)
+ }, MoreExecutors.directExecutor())
// if (queueItems.isNotEmpty()) recyclerView.restoreScrollPosition(TAG)
}
@@ -309,7 +307,7 @@ import java.util.*
}
private fun refreshPosCallback(pos: Int, episode: Episode) {
- Logd(TAG, "Queue refreshPosCallback: $pos ${episode.title}")
+// Logd(TAG, "Queue refreshPosCallback: $pos ${episode.title}")
if (isAdded && activity != null) refreshInfoBar()
}
@@ -340,12 +338,15 @@ import java.util.*
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id)
if (pos >= 0) {
Logd(TAG, "removing episode $pos ${queueItems[pos].title} $e")
+// val holder = recyclerView.findViewHolderForItemId(e.id) as? EpisodeViewHolder
val holder = recyclerView.findViewHolderForLayoutPosition(pos) as? EpisodeViewHolder
- holder?.stopDBMonitor()
+ if (holder != null) {
+ holder.stopDBMonitor()
// holder?.unbind()
- queueItems.removeAt(pos)
- adapter?.notifyItemRemoved(pos)
- adapter?.notifyItemRangeChanged(pos, adapter!!.itemCount - pos)
+ queueItems.removeAt(pos)
+ adapter?.notifyItemRemoved(pos)
+// adapter?.notifyItemRangeChanged(pos, adapter!!.itemCount - pos)
+ }
} else {
Log.e(TAG, "Trying to remove item non-existent from queue ${e.id} ${e.title}")
continue
@@ -370,8 +371,8 @@ import java.util.*
}
private fun onPlayEvent(event: FlowEvent.PlayEvent) {
- Logd(TAG, "onPlayEvent ${event.episode.title}")
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, event.episode.id)
+ Logd(TAG, "onPlayEvent action: ${event.action} pos: $pos ${event.episode.title}")
if (pos >= 0) adapter?.notifyItemChangedCompat(pos)
}
@@ -380,9 +381,7 @@ import java.util.*
if (loadItemsRunning) return
for (downloadUrl in event.urls) {
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(queueItems.toList(), downloadUrl)
- if (pos >= 0) {
- adapter?.notifyItemChangedCompat(pos)
- }
+ if (pos >= 0) adapter?.notifyItemChangedCompat(pos)
}
}
@@ -417,7 +416,6 @@ import java.util.*
@SuppressLint("RestrictedApi")
private fun onKeyUp(event: KeyEvent) {
if (!isAdded || !isVisible || !isMenuVisible) return
-
when (event.keyCode) {
KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0)
KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(adapter!!.itemCount - 1)
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt
index f45ac122..6c7a4e9a 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt
@@ -90,7 +90,7 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro
private var mediaMonitor: Job? = null
private var notBond: Boolean = true
- val isCurMedia: Boolean
+ private val isCurMedia: Boolean
get() = InTheatre.isCurMedia(this.episode?.media)
init {
@@ -295,33 +295,6 @@ open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGro
}
}
- fun bindDummy() {
- this.episode = Episode()
- binding.container.alpha = 0.1f
- secondaryActionIcon.setImageDrawable(null)
- isVideo.visibility = View.GONE
- binding.isFavorite.visibility = View.GONE
- isInQueue.visibility = View.GONE
- title.text = "███████"
- pubDate.text = "████"
- duration.text = "████"
- secondaryActionProgress.setPercentage(0f, null)
- secondaryActionProgress.setIndeterminate(false)
- progressBar.visibility = View.GONE
- position.visibility = View.GONE
- dragHandle.visibility = View.GONE
- binding.size.text = ""
- itemView.setBackgroundResource(ThemeUtils.getDrawableFromAttr(activity, androidx.appcompat.R.attr.selectableItemBackground))
- placeholder.text = ""
- if (coverHolder.visibility == View.VISIBLE) {
- CoverLoader(activity)
- .withResource(R.color.medium_gray)
- .withPlaceholderView(placeholder)
- .withCoverView(cover)
- .load()
- }
- }
-
fun updatePlaybackPositionNew(item: Episode) {
Logd(TAG, "updatePlaybackPositionNew called")
this.episode = item
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt
index 68345bcb..fb6267f7 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt
@@ -13,6 +13,14 @@ import android.content.SharedPreferences
class EpisodesRecyclerView : RecyclerView {
private lateinit var layoutManager: LinearLayoutManager
+ val isScrolledToBottom: Boolean
+ get() {
+ val visibleEpisodeCount = childCount
+ val totalEpisodeCount = layoutManager.itemCount
+ val firstVisibleEpisode = layoutManager.findFirstVisibleItemPosition()
+ return (totalEpisodeCount - visibleEpisodeCount) <= (firstVisibleEpisode + 3)
+ }
+
constructor(context: Context) : super(ContextThemeWrapper(context, R.style.FastScrollRecyclerView)) {
setup()
}
@@ -59,14 +67,6 @@ class EpisodesRecyclerView : RecyclerView {
if (position > 0 || offset > 0) layoutManager.scrollToPositionWithOffset(position, offset)
}
- val isScrolledToBottom: Boolean
- get() {
- val visibleEpisodeCount = childCount
- val totalEpisodeCount = layoutManager.itemCount
- val firstVisibleEpisode = layoutManager.findFirstVisibleItemPosition()
- return (totalEpisodeCount - visibleEpisodeCount) <= (firstVisibleEpisode + 3)
- }
-
companion object {
private val TAG: String = EpisodesRecyclerView::class.simpleName ?: "Anonymous"
private const val PREF_PREFIX_SCROLL_POSITION = "scroll_position_"
diff --git a/changelog.md b/changelog.md
index 86391acd..f1fb4b1e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,11 @@
+# 6.5.8
+
+* corrected mis-behavior of speed settings for video media
+* likely fixed issue of duplicates or absence of playing episode seen sometimes in Queues view
+* reduced some unnecessary posting of events
+* removed setting of videoPlaybackSpeed, audio and video speed how handled in the same way
+* removed incomplete handling of flash media previously used to handle youtube media
+
# 6.5.7
* in every feed settings, in case the preferences are not properly set, auto-download is by default disabled
diff --git a/fastlane/metadata/android/en-US/changelogs/3020242.txt b/fastlane/metadata/android/en-US/changelogs/3020242.txt
new file mode 100644
index 00000000..1b1f10c4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3020242.txt
@@ -0,0 +1,7 @@
+ Version 6.5.8 brings several changes:
+
+* corrected mis-behavior of speed settings for video media
+* likely fixed issue of duplicates or absence of playing episode seen sometimes in Queues view
+* reduced some unnecessary posting of events
+* removed setting of videoPlaybackSpeed, audio and video speed how handled in the same way
+* removed incomplete handling of flash media previously used to handle youtube media