6.5.8 commit
This commit is contained in:
parent
24cb13cd88
commit
e3f7c31407
|
@ -12,13 +12,11 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
|
|||
[<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/)
|
||||
[<img src="./images/external/amazon.png" alt="Amazon" height="40">](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](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024.
|
||||
This project evolves from a fork of [AntennaPod](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024.
|
||||
|
||||
Compared to AntennaPod this project:
|
||||
|
||||
|
|
|
@ -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 = ""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -54,7 +54,6 @@
|
|||
android:enabled="true"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaLibraryService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<String, String>,
|
||||
val redirectUrl: String)
|
||||
|
||||
companion object {
|
||||
private val TAG: String = FeedHandler::class.simpleName ?: "Anonymous"
|
||||
private const val ATOM_ROOT = "feed"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Int, Int>? = 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<Format>
|
||||
get() {
|
||||
val formats_: MutableList<Format> = 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<MediaSource> = ArrayList()
|
||||
mediaSources.add(vSource)
|
||||
mediaSources.add(aSource)
|
||||
mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray<MediaSource>())
|
||||
// mediaSource = null
|
||||
} else mediaSource = aSource
|
||||
mediaItem = mediaSource?.mediaItem
|
||||
setSourceCredentials(user, password)
|
||||
} catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") }
|
||||
} else {
|
||||
Logd(TAG, "setDataSource1 setting for Podcast source")
|
||||
setDataSource(metadata, url,user, password)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSourceCredentials(user: String?, password: String?) {
|
||||
if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) {
|
||||
if (httpDataSourceFactory == null)
|
||||
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
|
||||
.setUserAgent(ClientConfig.USER_AGENT)
|
||||
|
||||
val requestProperties = HashMap<String, String>()
|
||||
requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
|
||||
httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties)
|
||||
|
||||
val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!)
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA)
|
||||
val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||
|
||||
mediaSource = f.createMediaSource(mediaItem!!)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the two lists of video streams (video_only and normal videos),
|
||||
* and sort them according with default format chosen by the user.
|
||||
*
|
||||
* @param defaultFormat format to give preference
|
||||
* @param showHigherResolutions show >1080p resolutions
|
||||
* @param videoStreams normal videos list
|
||||
* @param videoOnlyStreams video only stream list
|
||||
* @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest
|
||||
* @param preferVideoOnlyStreams if video-only streams should preferred when both video-only
|
||||
* streams and normal video streams are available
|
||||
* @return the sorted list
|
||||
*/
|
||||
private fun getSortedStreamVideosList(videoStreams: List<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
|
||||
preferVideoOnlyStreams: Boolean): List<VideoStream> {
|
||||
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
|
||||
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
|
||||
val comparator = compareBy<VideoStream> { it.resolution.toResolutionValue() }
|
||||
return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) }
|
||||
}
|
||||
|
||||
private fun String.toResolutionValue(): Int {
|
||||
val match = Regex("(\\d+)p|(\\d+)k").find(this)
|
||||
return when {
|
||||
match?.groupValues?.get(1) != null -> match.groupValues[1].toInt()
|
||||
match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFilteredAudioStreams(audioStreams: List<AudioStream>?): List<AudioStream> {
|
||||
if (audioStreams == null) return listOf()
|
||||
val collectedStreams = mutableSetOf<AudioStream>()
|
||||
for (stream in audioStreams) {
|
||||
Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}")
|
||||
if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS))
|
||||
continue
|
||||
collectedStreams.add(stream)
|
||||
}
|
||||
return collectedStreams.toList().sortedWith(compareBy { it.bitrate })
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
|
||||
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
|
||||
* not do anything.
|
||||
* 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<Int, Int>? {
|
||||
if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
|
||||
return videoSize
|
||||
}
|
||||
|
||||
override fun getAudioTracks(): List<String> {
|
||||
val trackNames: MutableList<String> = 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<String>? = null
|
||||
private var bufferingUpdateListener: Consumer<Int>? = 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<MediaCodecInfo> = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder)
|
||||
// val result: MutableList<MediaCodecInfo> = 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
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<String> = HashSet(mutableListOf(
|
||||
|
@ -9,7 +9,6 @@ enum class MediaType {
|
|||
"application/opus",
|
||||
"application/x-flac"
|
||||
))
|
||||
private val VIDEO_APPLICATION_MIME_STRINGS: Set<String> = 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Episode>, 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!!)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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_"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue