6.12.5 commit
This commit is contained in:
parent
25893b79ea
commit
c3bf2e4e8b
|
@ -31,8 +31,8 @@ android {
|
||||||
testApplicationId "ac.mdiq.podcini.tests"
|
testApplicationId "ac.mdiq.podcini.tests"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
versionCode 3020282
|
versionCode 3020283
|
||||||
versionName "6.12.4"
|
versionName "6.12.5"
|
||||||
|
|
||||||
applicationId "ac.mdiq.podcini.R"
|
applicationId "ac.mdiq.podcini.R"
|
||||||
def commit = ""
|
def commit = ""
|
||||||
|
|
|
@ -7,7 +7,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerCallback
|
||||||
/**
|
/**
|
||||||
* Stub implementation of CastPsmp for Free build flavour
|
* Stub implementation of CastPsmp for Free build flavour
|
||||||
*/
|
*/
|
||||||
object CastPsmp {
|
object CastMediaPlayer {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
|
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
|
||||||
return null
|
return null
|
|
@ -0,0 +1,839 @@
|
||||||
|
package ac.mdiq.podcini.playback.base
|
||||||
|
|
||||||
|
import ac.mdiq.podcini.R
|
||||||
|
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
|
||||||
|
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
|
||||||
|
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
|
||||||
|
import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
|
||||||
|
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
|
||||||
|
import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
|
||||||
|
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
|
||||||
|
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
||||||
|
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
|
||||||
|
import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia
|
||||||
|
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
|
||||||
|
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
|
||||||
|
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.model.Playable
|
||||||
|
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.stream.AudioStream
|
||||||
|
import ac.mdiq.vista.extractor.stream.DeliveryMethod
|
||||||
|
import ac.mdiq.vista.extractor.stream.VideoStream
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.media.audiofx.LoudnessEnhancer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Pair
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.media3.common.*
|
||||||
|
import androidx.media3.common.Player.*
|
||||||
|
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||||
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
|
||||||
|
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
|
||||||
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
|
import androidx.media3.extractor.mp3.Mp3Extractor
|
||||||
|
import androidx.media3.ui.DefaultTrackNameProvider
|
||||||
|
import androidx.media3.ui.TrackNameProvider
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the MediaPlayer object of the PlaybackService.
|
||||||
|
*/
|
||||||
|
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 = media.episode!!.streamInfo ?: return
|
||||||
|
val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
|
||||||
|
Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
|
||||||
|
val audioIndex = if (isNetworkRestricted && prefLowQualityMedia) 0 else audioStreamsList.size - 1
|
||||||
|
val audioStream = audioStreamsList[audioIndex]
|
||||||
|
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
|
||||||
|
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
|
||||||
|
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
|
||||||
|
if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
|
||||||
|
Logd(TAG, "setDataSource1 result: $streamInfo")
|
||||||
|
Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}")
|
||||||
|
val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true)
|
||||||
|
val videoIndex = 0
|
||||||
|
val videoStream = videoStreamsList[videoIndex]
|
||||||
|
Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}")
|
||||||
|
val vSource = DefaultMediaSourceFactory(context).createMediaSource(
|
||||||
|
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
|
||||||
|
val mediaSources: MutableList<MediaSource> = ArrayList()
|
||||||
|
mediaSources.add(vSource)
|
||||||
|
mediaSources.add(aSource)
|
||||||
|
mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray<MediaSource>())
|
||||||
|
// mediaSource = null
|
||||||
|
} else mediaSource = aSource
|
||||||
|
mediaItem = mediaSource?.mediaItem
|
||||||
|
setSourceCredentials(user, password)
|
||||||
|
} catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") }
|
||||||
|
} else {
|
||||||
|
Logd(TAG, "setDataSource1 setting for Podcast source")
|
||||||
|
setDataSource(metadata, url,user, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setSourceCredentials(user: String?, password: String?) {
|
||||||
|
if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) {
|
||||||
|
if (httpDataSourceFactory == null)
|
||||||
|
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
|
||||||
|
.setUserAgent(ClientConfig.USER_AGENT)
|
||||||
|
|
||||||
|
val requestProperties = HashMap<String, String>()
|
||||||
|
requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
|
||||||
|
httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties)
|
||||||
|
|
||||||
|
val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!)
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||||
|
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA)
|
||||||
|
val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||||
|
|
||||||
|
mediaSource = f.createMediaSource(mediaItem!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join the two lists of video streams (video_only and normal videos),
|
||||||
|
* and sort them according with default format chosen by the user.
|
||||||
|
*
|
||||||
|
* @param defaultFormat format to give preference
|
||||||
|
* @param showHigherResolutions show >1080p resolutions
|
||||||
|
* @param videoStreams normal videos list
|
||||||
|
* @param videoOnlyStreams video only stream list
|
||||||
|
* @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest
|
||||||
|
* @param preferVideoOnlyStreams if video-only streams should preferred when both video-only
|
||||||
|
* streams and normal video streams are available
|
||||||
|
* @return the sorted list
|
||||||
|
*/
|
||||||
|
private fun getSortedStreamVideosList(videoStreams: List<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
|
||||||
|
preferVideoOnlyStreams: Boolean): List<VideoStream> {
|
||||||
|
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
|
||||||
|
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
|
||||||
|
val comparator = compareBy<VideoStream> { it.resolution.toResolutionValue() }
|
||||||
|
return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toResolutionValue(): Int {
|
||||||
|
val match = Regex("(\\d+)p|(\\d+)k").find(this)
|
||||||
|
return when {
|
||||||
|
match?.groupValues?.get(1) != null -> match.groupValues[1].toInt()
|
||||||
|
match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFilteredAudioStreams(audioStreams: List<AudioStream>?): List<AudioStream> {
|
||||||
|
if (audioStreams == null) return listOf()
|
||||||
|
val collectedStreams = mutableSetOf<AudioStream>()
|
||||||
|
for (stream in audioStreams) {
|
||||||
|
Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}")
|
||||||
|
if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS))
|
||||||
|
continue
|
||||||
|
collectedStreams.add(stream)
|
||||||
|
}
|
||||||
|
return collectedStreams.toList().sortedWith(compareBy { it.bitrate })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
|
||||||
|
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
|
||||||
|
* not do anything.
|
||||||
|
* Whether playback starts immediately depends on the given parameters. See below for more details.
|
||||||
|
* States:
|
||||||
|
* During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
|
||||||
|
* If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
|
||||||
|
* 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
|
||||||
|
* If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
|
||||||
|
* will enter the ERROR state.
|
||||||
|
* This method is executed on an internal executor service.
|
||||||
|
* @param playable The Playable object that is supposed to be played. This parameter must not be null.
|
||||||
|
* @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via
|
||||||
|
* getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
|
||||||
|
* the Android MediaPlayer via getStreamUrl.
|
||||||
|
* @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the
|
||||||
|
* episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared
|
||||||
|
* for playback immediately (see 'prepareImmediately' parameter for more details)
|
||||||
|
* @param prepareImmediately Set to true if the method should also prepare the episode for playback.
|
||||||
|
*/
|
||||||
|
override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
|
||||||
|
Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ")
|
||||||
|
// showStackTrace()
|
||||||
|
if (curMedia != null) {
|
||||||
|
Logd(TAG, "playMediaObject: curMedia exist status=$status")
|
||||||
|
if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) {
|
||||||
|
Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}")
|
||||||
|
// set temporarily to pause in order to update list with current position
|
||||||
|
if (status == PlayerStatus.PLAYING) {
|
||||||
|
val pos = curMedia?.getPosition() ?: -1
|
||||||
|
seekTo(pos)
|
||||||
|
callback.onPlaybackPause(curMedia, pos)
|
||||||
|
}
|
||||||
|
// stop playback of this episode
|
||||||
|
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
|
||||||
|
// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
|
||||||
|
// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
|
||||||
|
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}")
|
||||||
|
curMedia = playable
|
||||||
|
if (curMedia is EpisodeMedia) {
|
||||||
|
val media_ = curMedia as EpisodeMedia
|
||||||
|
val item = media_.episodeOrFetch()
|
||||||
|
val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf()
|
||||||
|
curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id)
|
||||||
|
} else curIndexInQueue = -1
|
||||||
|
|
||||||
|
prevMedia = curMedia
|
||||||
|
this.isStreaming = streaming
|
||||||
|
mediaType = curMedia!!.getMediaType()
|
||||||
|
videoSize = null
|
||||||
|
createMediaPlayer()
|
||||||
|
this.startWhenPrepared.set(startWhenPrepared)
|
||||||
|
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
|
||||||
|
val metadata = buildMetadata(curMedia!!)
|
||||||
|
try {
|
||||||
|
callback.ensureMediaInfoLoaded(curMedia!!)
|
||||||
|
callback.onMediaChanged(false)
|
||||||
|
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
when {
|
||||||
|
streaming -> {
|
||||||
|
val streamurl = curMedia!!.getStreamUrl()
|
||||||
|
if (streamurl != null) {
|
||||||
|
val media = curMedia
|
||||||
|
if (media is EpisodeMedia) {
|
||||||
|
mediaItem = null
|
||||||
|
mediaSource = null
|
||||||
|
setDataSource(metadata, media)
|
||||||
|
// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
|
||||||
|
// if (startWhenPrepared) runBlocking { deferred.await() }
|
||||||
|
// val preferences = media.episodeOrFetch()?.feed?.preferences
|
||||||
|
// setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
|
||||||
|
} else setDataSource(metadata, streamurl, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val localMediaurl = curMedia!!.getLocalMediaUrl()
|
||||||
|
// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle
|
||||||
|
// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null)
|
||||||
|
if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
|
||||||
|
else throw IOException("Unable to read local file $localMediaurl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||||
|
if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
|
||||||
|
if (prepareImmediately) prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
setPlayerStatus(PlayerStatus.ERROR, null)
|
||||||
|
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
setPlayerStatus(PlayerStatus.ERROR, null)
|
||||||
|
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
|
||||||
|
} finally { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resume() {
|
||||||
|
Logd(TAG, "resume(): exoPlayer?.playbackState: ${exoPlayer?.playbackState}")
|
||||||
|
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
|
||||||
|
Logd(TAG, "Resuming/Starting playback")
|
||||||
|
acquireWifiLockIfNecessary()
|
||||||
|
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
|
||||||
|
setVolume(1.0f, 1.0f)
|
||||||
|
if (curMedia != null && status == PlayerStatus.PREPARED && curMedia!!.getPosition() > 0) {
|
||||||
|
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
|
||||||
|
seekTo(newPosition)
|
||||||
|
}
|
||||||
|
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
|
||||||
|
// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
|
||||||
|
exoPlayer?.play()
|
||||||
|
// Can't set params when paused - so always set it on start in case they changed
|
||||||
|
exoPlayer?.playbackParameters = playbackParameters
|
||||||
|
setPlayerStatus(PlayerStatus.PLAYING, curMedia)
|
||||||
|
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!))
|
||||||
|
} else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause(abandonFocus: Boolean, reinit: Boolean) {
|
||||||
|
releaseWifiLockIfNecessary()
|
||||||
|
if (status == PlayerStatus.PLAYING) {
|
||||||
|
Logd(TAG, "Pausing playback $abandonFocus $reinit")
|
||||||
|
exoPlayer?.pause()
|
||||||
|
setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition())
|
||||||
|
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END))
|
||||||
|
if (isStreaming && reinit) reinit()
|
||||||
|
} else Logd(TAG, "Ignoring call to pause: Player is in $status state")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun prepare() {
|
||||||
|
if (status == PlayerStatus.INITIALIZED) {
|
||||||
|
Logd(TAG, "Preparing media player")
|
||||||
|
setPlayerStatus(PlayerStatus.PREPARING, curMedia)
|
||||||
|
prepareWR()
|
||||||
|
// onPrepared(startWhenPrepared.get())
|
||||||
|
if (mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
|
||||||
|
if (curMedia != null) {
|
||||||
|
val pos = curMedia!!.getPosition()
|
||||||
|
if (pos > 0) seekTo(pos)
|
||||||
|
if (curMedia != null && curMedia!!.getDuration() <= 0) {
|
||||||
|
Logd(TAG, "Setting duration of media")
|
||||||
|
curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPlayerStatus(PlayerStatus.PREPARED, curMedia)
|
||||||
|
if (startWhenPrepared.get()) resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reinit() {
|
||||||
|
Logd(TAG, "reinit() called")
|
||||||
|
releaseWifiLockIfNecessary()
|
||||||
|
when {
|
||||||
|
curMedia != null -> playMediaObject(curMedia!!, isStreaming, startWhenPrepared.get(), prepareImmediately = false, true)
|
||||||
|
else -> Logd(TAG, "Call to reinit: media and mediaPlayer were null, ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(t: Int) {
|
||||||
|
var t = t
|
||||||
|
if (t < 0) t = 0
|
||||||
|
Logd(TAG, "seekTo() called $t")
|
||||||
|
|
||||||
|
if (t >= getDuration()) {
|
||||||
|
Logd(TAG, "Seek reached end of file, skipping to next episode")
|
||||||
|
exoPlayer?.seekTo(t.toLong()) // can set curMedia to null
|
||||||
|
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
|
||||||
|
audioSeekCompleteListener?.run()
|
||||||
|
endPlayback(true, wasSkipped = true, true, toStoppedState = true)
|
||||||
|
t = getPosition()
|
||||||
|
// return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (status) {
|
||||||
|
PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
|
||||||
|
Logd(TAG, "seekTo t: $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
|
||||||
|
Logd(TAG, "setVolume: $volumeLeft $volumeRight")
|
||||||
|
val playable = curMedia
|
||||||
|
if (playable is EpisodeMedia) {
|
||||||
|
val preferences = playable.episodeOrFetch()?.feed?.preferences
|
||||||
|
if (preferences != null) {
|
||||||
|
val volumeAdaptionSetting = preferences.volumeAdaptionSetting
|
||||||
|
val adaptionFactor = volumeAdaptionSetting.adaptionFactor
|
||||||
|
volumeLeft *= adaptionFactor
|
||||||
|
volumeRight *= adaptionFactor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logd(TAG, "setVolume 1: $volumeLeft $volumeRight")
|
||||||
|
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")
|
||||||
|
// showStackTrace()
|
||||||
|
|
||||||
|
val currentMedia = curMedia
|
||||||
|
var nextMedia: Playable? = null
|
||||||
|
if (shouldContinue) {
|
||||||
|
// Load next episode if previous episode was in the queue and if there is an episode in the queue left.
|
||||||
|
// Start playback immediately if continuous playback is enabled
|
||||||
|
nextMedia = callback.getNextInQueue(currentMedia)
|
||||||
|
if (nextMedia != null) {
|
||||||
|
Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false")
|
||||||
|
if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
||||||
|
callback.onPlaybackEnded(nextMedia.getMediaType(), false)
|
||||||
|
// setting media to null signals to playMediaObject that we're taking care of post-playback processing
|
||||||
|
curMedia = null
|
||||||
|
playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shouldContinue || toStoppedState -> {
|
||||||
|
if (nextMedia == null) {
|
||||||
|
Logd(TAG, "nextMedia is null. call callback.onPlaybackEnded true")
|
||||||
|
callback.onPlaybackEnded(null, true)
|
||||||
|
curMedia = null
|
||||||
|
exoPlayer?.stop()
|
||||||
|
releaseWifiLockIfNecessary()
|
||||||
|
if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null)
|
||||||
|
else Logd(TAG, "Ignored call to stop: Current player state is: $status")
|
||||||
|
}
|
||||||
|
val hasNext = nextMedia != null
|
||||||
|
if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext)
|
||||||
|
// curMedia = nextMedia
|
||||||
|
}
|
||||||
|
isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldLockWifi(): Boolean {
|
||||||
|
return isStreaming
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMediaPlayerListeners() {
|
||||||
|
if (curMedia == null) return
|
||||||
|
|
||||||
|
audioCompletionListener = Runnable {
|
||||||
|
Logd(TAG, "audioCompletionListener called")
|
||||||
|
endPlayback(hasEnded = true, wasSkipped = false, shouldContinue = true, toStoppedState = true)
|
||||||
|
}
|
||||||
|
audioSeekCompleteListener = Runnable { this.genericSeekCompleteListener() }
|
||||||
|
bufferingUpdateListener = Consumer { percent: Int ->
|
||||||
|
when (percent) {
|
||||||
|
BUFFERING_STARTED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.started())
|
||||||
|
BUFFERING_ENDED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.ended())
|
||||||
|
else -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.progressUpdate(0.01f * percent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioErrorListener = Consumer { message: String ->
|
||||||
|
Log.e(TAG, "PlayerErrorEvent: $message")
|
||||||
|
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearMediaPlayerListeners() {
|
||||||
|
audioCompletionListener = Runnable {}
|
||||||
|
audioSeekCompleteListener = Runnable {}
|
||||||
|
bufferingUpdateListener = Consumer { }
|
||||||
|
audioErrorListener = Consumer {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun genericSeekCompleteListener() {
|
||||||
|
Logd(TAG, "genericSeekCompleteListener $status ${exoPlayer?.isPlaying} $statusBeforeSeeking")
|
||||||
|
seekLatch?.countDown()
|
||||||
|
|
||||||
|
if ((status == PlayerStatus.PLAYING || exoPlayer?.isPlaying != true) && curMedia != null) callback.onPlaybackStart(curMedia!!, getPosition())
|
||||||
|
if (status == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, curMedia, getPosition())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCasting(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPlayerListener() {
|
||||||
|
exoplayerListener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: @State Int) {
|
||||||
|
Logd(TAG, "onPlaybackStateChanged $playbackState")
|
||||||
|
when (playbackState) {
|
||||||
|
STATE_ENDED -> {
|
||||||
|
exoPlayer?.seekTo(C.TIME_UNSET)
|
||||||
|
audioCompletionListener?.run()
|
||||||
|
}
|
||||||
|
STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED)
|
||||||
|
else -> bufferingUpdateListener?.accept(BUFFERING_ENDED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
// val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
|
||||||
|
// TODO: test: changing PAUSED to STOPPED or INDETERMINATE makes resume not possible if interrupted
|
||||||
|
val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
|
||||||
|
setPlayerStatus(stat, curMedia)
|
||||||
|
Logd(TAG, "onIsPlayingChanged $isPlaying")
|
||||||
|
}
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
Log.d(TAG, "onPlayerError ${error.message}")
|
||||||
|
if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked))
|
||||||
|
else {
|
||||||
|
var cause = error.cause
|
||||||
|
if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause
|
||||||
|
if (cause != null && "Source error" == cause.message) cause = cause.cause
|
||||||
|
audioErrorListener?.accept((if (cause != null) cause.message else error.message) ?:"no message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) {
|
||||||
|
Logd(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason")
|
||||||
|
if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run()
|
||||||
|
}
|
||||||
|
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||||
|
Logd(TAG, "onAudioSessionIdChanged $audioSessionId")
|
||||||
|
initLoudnessEnhancer(audioSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG: String = LocalMediaPlayer::class.simpleName ?: "Anonymous"
|
||||||
|
|
||||||
|
const val BUFFERING_STARTED: Int = -1
|
||||||
|
const val BUFFERING_ENDED: Int = -2
|
||||||
|
|
||||||
|
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
|
||||||
|
|
||||||
|
private var trackSelector: DefaultTrackSelector? = null
|
||||||
|
|
||||||
|
var exoPlayer: ExoPlayer? = null
|
||||||
|
|
||||||
|
private var exoplayerListener: Player.Listener? = null
|
||||||
|
private var audioSeekCompleteListener: java.lang.Runnable? = null
|
||||||
|
private var audioCompletionListener: java.lang.Runnable? = null
|
||||||
|
private var audioErrorListener: Consumer<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(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,9 @@
|
||||||
package ac.mdiq.podcini.playback.service
|
package ac.mdiq.podcini.playback.service
|
||||||
|
|
||||||
import ac.mdiq.podcini.R
|
import ac.mdiq.podcini.R
|
||||||
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
|
|
||||||
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
|
|
||||||
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
|
||||||
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileStreaming
|
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileStreaming
|
||||||
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
|
|
||||||
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
|
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
|
||||||
import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
|
|
||||||
import ac.mdiq.podcini.playback.PlaybackServiceStarter
|
import ac.mdiq.podcini.playback.PlaybackServiceStarter
|
||||||
import ac.mdiq.podcini.playback.base.*
|
import ac.mdiq.podcini.playback.base.*
|
||||||
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
|
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
|
||||||
|
@ -20,7 +16,7 @@ import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
|
||||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.buildMediaItem
|
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.buildMediaItem
|
||||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed
|
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed
|
||||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
|
import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo
|
||||||
import ac.mdiq.podcini.playback.cast.CastPsmp
|
import ac.mdiq.podcini.playback.cast.CastMediaPlayer
|
||||||
import ac.mdiq.podcini.playback.cast.CastStateListener
|
import ac.mdiq.podcini.playback.cast.CastStateListener
|
||||||
import ac.mdiq.podcini.preferences.SleepTimerPreferences
|
import ac.mdiq.podcini.preferences.SleepTimerPreferences
|
||||||
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable
|
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable
|
||||||
|
@ -32,12 +28,11 @@ import ac.mdiq.podcini.preferences.UserPreferences
|
||||||
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
|
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
|
||||||
import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs
|
import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs
|
||||||
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
|
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
|
||||||
import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia
|
|
||||||
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
|
import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
|
||||||
import ac.mdiq.podcini.receiver.MediaButtonReceiver
|
import ac.mdiq.podcini.receiver.MediaButtonReceiver
|
||||||
import ac.mdiq.podcini.storage.database.Episodes.setCompletionDate
|
|
||||||
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
|
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
|
||||||
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl
|
import ac.mdiq.podcini.storage.database.Episodes.getEpisodeByGuidOrUrl
|
||||||
|
import ac.mdiq.podcini.storage.database.Episodes.setCompletionDate
|
||||||
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
|
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
|
||||||
import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue
|
import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue
|
||||||
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem
|
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem
|
||||||
|
@ -66,76 +61,44 @@ import ac.mdiq.podcini.util.FlowEvent
|
||||||
import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action
|
import ac.mdiq.podcini.util.FlowEvent.PlayEvent.Action
|
||||||
import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
|
import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
|
||||||
import ac.mdiq.podcini.util.Logd
|
import ac.mdiq.podcini.util.Logd
|
||||||
import ac.mdiq.podcini.util.config.ClientConfig
|
|
||||||
import ac.mdiq.vista.extractor.MediaFormat
|
|
||||||
import ac.mdiq.vista.extractor.stream.AudioStream
|
|
||||||
import ac.mdiq.vista.extractor.stream.DeliveryMethod
|
|
||||||
import ac.mdiq.vista.extractor.stream.VideoStream
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
import android.app.UiModeManager
|
|
||||||
import android.bluetooth.BluetoothA2dp
|
import android.bluetooth.BluetoothA2dp
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.content.Intent.EXTRA_KEY_EVENT
|
import android.content.Intent.EXTRA_KEY_EVENT
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.audiofx.LoudnessEnhancer
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.os.Build.VERSION_CODES
|
import android.os.Build.VERSION_CODES
|
||||||
import android.service.quicksettings.TileService
|
import android.service.quicksettings.TileService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Pair
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
|
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
|
||||||
import android.view.SurfaceHolder
|
|
||||||
import android.view.ViewConfiguration
|
import android.view.ViewConfiguration
|
||||||
import android.webkit.URLUtil
|
import android.webkit.URLUtil
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.util.Consumer
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.*
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.Player.*
|
import androidx.media3.common.Player.STATE_ENDED
|
||||||
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
|
import androidx.media3.common.Player.STATE_IDLE
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DataSource
|
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
|
||||||
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
|
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.exoplayer.SeekParameters
|
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
|
||||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
|
||||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
|
|
||||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
|
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
|
||||||
import androidx.media3.extractor.mp3.Mp3Extractor
|
|
||||||
import androidx.media3.session.*
|
import androidx.media3.session.*
|
||||||
import androidx.media3.ui.DefaultTrackNameProvider
|
|
||||||
import androidx.media3.ui.TrackNameProvider
|
|
||||||
import androidx.work.impl.utils.futures.SettableFuture
|
import androidx.work.impl.utils.futures.SettableFuture
|
||||||
import com.google.common.collect.ImmutableList
|
import com.google.common.collect.ImmutableList
|
||||||
import com.google.common.util.concurrent.Futures
|
import com.google.common.util.concurrent.Futures
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import java.io.IOException
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.ScheduledFuture
|
import java.util.concurrent.ScheduledFuture
|
||||||
import java.util.concurrent.ScheduledThreadPoolExecutor
|
import java.util.concurrent.ScheduledThreadPoolExecutor
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
@ -728,7 +691,7 @@ class PlaybackService : MediaLibraryService() {
|
||||||
mPlayer!!.pause(abandonFocus = true, reinit = false)
|
mPlayer!!.pause(abandonFocus = true, reinit = false)
|
||||||
mPlayer!!.shutdown()
|
mPlayer!!.shutdown()
|
||||||
}
|
}
|
||||||
mPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback)
|
mPlayer = CastMediaPlayer.getInstanceIfConnected(this, mediaPlayerCallback)
|
||||||
if (mPlayer == null) mPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected
|
if (mPlayer == null) mPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected
|
||||||
|
|
||||||
Logd(TAG, "recreateMediaPlayer wasPlaying: $wasPlaying")
|
Logd(TAG, "recreateMediaPlayer wasPlaying: $wasPlaying")
|
||||||
|
@ -1306,782 +1269,6 @@ class PlaybackService : MediaLibraryService() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the MediaPlayer object of the PlaybackService.
|
|
||||||
*/
|
|
||||||
@UnstableApi
|
|
||||||
class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var statusBeforeSeeking: PlayerStatus? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var videoSize: Pair<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 = media.episode!!.streamInfo ?: return
|
|
||||||
val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
|
|
||||||
Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
|
|
||||||
val audioIndex = if (isNetworkRestricted && prefLowQualityMedia) 0 else audioStreamsList.size - 1
|
|
||||||
val audioStream = audioStreamsList[audioIndex]
|
|
||||||
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
|
|
||||||
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
|
|
||||||
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
|
|
||||||
if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
|
|
||||||
Logd(TAG, "setDataSource1 result: $streamInfo")
|
|
||||||
Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}")
|
|
||||||
val videoStreamsList = getSortedStreamVideosList(streamInfo.videoStreams, streamInfo.videoOnlyStreams, true, true)
|
|
||||||
val videoIndex = 0
|
|
||||||
val videoStream = videoStreamsList[videoIndex]
|
|
||||||
Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}")
|
|
||||||
val vSource = DefaultMediaSourceFactory(context).createMediaSource(
|
|
||||||
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
|
|
||||||
val mediaSources: MutableList<MediaSource> = ArrayList()
|
|
||||||
mediaSources.add(vSource)
|
|
||||||
mediaSources.add(aSource)
|
|
||||||
mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray<MediaSource>())
|
|
||||||
// mediaSource = null
|
|
||||||
} else mediaSource = aSource
|
|
||||||
mediaItem = mediaSource?.mediaItem
|
|
||||||
setSourceCredentials(user, password)
|
|
||||||
} catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") }
|
|
||||||
} else {
|
|
||||||
Logd(TAG, "setDataSource1 setting for Podcast source")
|
|
||||||
setDataSource(metadata, url,user, password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setSourceCredentials(user: String?, password: String?) {
|
|
||||||
if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) {
|
|
||||||
if (httpDataSourceFactory == null)
|
|
||||||
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
|
|
||||||
.setUserAgent(ClientConfig.USER_AGENT)
|
|
||||||
|
|
||||||
val requestProperties = HashMap<String, String>()
|
|
||||||
requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
|
|
||||||
httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties)
|
|
||||||
|
|
||||||
val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!)
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
|
||||||
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
|
||||||
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA)
|
|
||||||
val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
|
||||||
|
|
||||||
mediaSource = f.createMediaSource(mediaItem!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Join the two lists of video streams (video_only and normal videos),
|
|
||||||
* and sort them according with default format chosen by the user.
|
|
||||||
*
|
|
||||||
* @param defaultFormat format to give preference
|
|
||||||
* @param showHigherResolutions show >1080p resolutions
|
|
||||||
* @param videoStreams normal videos list
|
|
||||||
* @param videoOnlyStreams video only stream list
|
|
||||||
* @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest
|
|
||||||
* @param preferVideoOnlyStreams if video-only streams should preferred when both video-only
|
|
||||||
* streams and normal video streams are available
|
|
||||||
* @return the sorted list
|
|
||||||
*/
|
|
||||||
private fun getSortedStreamVideosList(videoStreams: List<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
|
|
||||||
preferVideoOnlyStreams: Boolean): List<VideoStream> {
|
|
||||||
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
|
|
||||||
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
|
|
||||||
val comparator = compareBy<VideoStream> { it.resolution.toResolutionValue() }
|
|
||||||
return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toResolutionValue(): Int {
|
|
||||||
val match = Regex("(\\d+)p|(\\d+)k").find(this)
|
|
||||||
return when {
|
|
||||||
match?.groupValues?.get(1) != null -> match.groupValues[1].toInt()
|
|
||||||
match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFilteredAudioStreams(audioStreams: List<AudioStream>?): List<AudioStream> {
|
|
||||||
if (audioStreams == null) return listOf()
|
|
||||||
val collectedStreams = mutableSetOf<AudioStream>()
|
|
||||||
for (stream in audioStreams) {
|
|
||||||
Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}")
|
|
||||||
if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS))
|
|
||||||
continue
|
|
||||||
collectedStreams.add(stream)
|
|
||||||
}
|
|
||||||
return collectedStreams.toList().sortedWith(compareBy { it.bitrate })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
|
|
||||||
* episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
|
|
||||||
* not do anything.
|
|
||||||
* Whether playback starts immediately depends on the given parameters. See below for more details.
|
|
||||||
* States:
|
|
||||||
* During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
|
|
||||||
* If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
|
|
||||||
* 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
|
|
||||||
* If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
|
|
||||||
* will enter the ERROR state.
|
|
||||||
* This method is executed on an internal executor service.
|
|
||||||
* @param playable The Playable object that is supposed to be played. This parameter must not be null.
|
|
||||||
* @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via
|
|
||||||
* getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
|
|
||||||
* the Android MediaPlayer via getStreamUrl.
|
|
||||||
* @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the
|
|
||||||
* episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared
|
|
||||||
* for playback immediately (see 'prepareImmediately' parameter for more details)
|
|
||||||
* @param prepareImmediately Set to true if the method should also prepare the episode for playback.
|
|
||||||
*/
|
|
||||||
override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
|
|
||||||
Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ")
|
|
||||||
// showStackTrace()
|
|
||||||
if (curMedia != null) {
|
|
||||||
Logd(TAG, "playMediaObject: curMedia exist status=$status")
|
|
||||||
if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) {
|
|
||||||
Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}")
|
|
||||||
// set temporarily to pause in order to update list with current position
|
|
||||||
if (status == PlayerStatus.PLAYING) {
|
|
||||||
val pos = curMedia?.getPosition() ?: -1
|
|
||||||
seekTo(pos)
|
|
||||||
callback.onPlaybackPause(curMedia, pos)
|
|
||||||
}
|
|
||||||
// stop playback of this episode
|
|
||||||
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
|
|
||||||
// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
|
|
||||||
// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
|
|
||||||
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}")
|
|
||||||
curMedia = playable
|
|
||||||
if (curMedia is EpisodeMedia) {
|
|
||||||
val media_ = curMedia as EpisodeMedia
|
|
||||||
val item = media_.episodeOrFetch()
|
|
||||||
val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf()
|
|
||||||
curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id)
|
|
||||||
} else curIndexInQueue = -1
|
|
||||||
|
|
||||||
prevMedia = curMedia
|
|
||||||
this.isStreaming = streaming
|
|
||||||
mediaType = curMedia!!.getMediaType()
|
|
||||||
videoSize = null
|
|
||||||
createMediaPlayer()
|
|
||||||
this.startWhenPrepared.set(startWhenPrepared)
|
|
||||||
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
|
|
||||||
val metadata = buildMetadata(curMedia!!)
|
|
||||||
try {
|
|
||||||
callback.ensureMediaInfoLoaded(curMedia!!)
|
|
||||||
callback.onMediaChanged(false)
|
|
||||||
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
when {
|
|
||||||
streaming -> {
|
|
||||||
val streamurl = curMedia!!.getStreamUrl()
|
|
||||||
if (streamurl != null) {
|
|
||||||
val media = curMedia
|
|
||||||
if (media is EpisodeMedia) {
|
|
||||||
mediaItem = null
|
|
||||||
mediaSource = null
|
|
||||||
setDataSource(metadata, media)
|
|
||||||
// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
|
|
||||||
// if (startWhenPrepared) runBlocking { deferred.await() }
|
|
||||||
// val preferences = media.episodeOrFetch()?.feed?.preferences
|
|
||||||
// setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
|
|
||||||
} else setDataSource(metadata, streamurl, null, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val localMediaurl = curMedia!!.getLocalMediaUrl()
|
|
||||||
// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle
|
|
||||||
// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null)
|
|
||||||
if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
|
|
||||||
else throw IOException("Unable to read local file $localMediaurl")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
|
||||||
if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
|
|
||||||
if (prepareImmediately) prepare()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
setPlayerStatus(PlayerStatus.ERROR, null)
|
|
||||||
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
setPlayerStatus(PlayerStatus.ERROR, null)
|
|
||||||
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
|
|
||||||
} finally { }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resume() {
|
|
||||||
Logd(TAG, "resume(): exoPlayer?.playbackState: ${exoPlayer?.playbackState}")
|
|
||||||
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
|
|
||||||
Logd(TAG, "Resuming/Starting playback")
|
|
||||||
acquireWifiLockIfNecessary()
|
|
||||||
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
|
|
||||||
setVolume(1.0f, 1.0f)
|
|
||||||
if (curMedia != null && status == PlayerStatus.PREPARED && curMedia!!.getPosition() > 0) {
|
|
||||||
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
|
|
||||||
seekTo(newPosition)
|
|
||||||
}
|
|
||||||
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
|
|
||||||
// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
|
|
||||||
exoPlayer?.play()
|
|
||||||
// Can't set params when paused - so always set it on start in case they changed
|
|
||||||
exoPlayer?.playbackParameters = playbackParameters
|
|
||||||
setPlayerStatus(PlayerStatus.PLAYING, curMedia)
|
|
||||||
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!))
|
|
||||||
} else Logd(TAG, "Call to resume() was ignored because current state of PSMP object is $status")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pause(abandonFocus: Boolean, reinit: Boolean) {
|
|
||||||
releaseWifiLockIfNecessary()
|
|
||||||
if (status == PlayerStatus.PLAYING) {
|
|
||||||
Logd(TAG, "Pausing playback $abandonFocus $reinit")
|
|
||||||
exoPlayer?.pause()
|
|
||||||
setPlayerStatus(PlayerStatus.PAUSED, curMedia, getPosition())
|
|
||||||
if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, Action.END))
|
|
||||||
if (isStreaming && reinit) reinit()
|
|
||||||
} else Logd(TAG, "Ignoring call to pause: Player is in $status state")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prepare() {
|
|
||||||
if (status == PlayerStatus.INITIALIZED) {
|
|
||||||
Logd(TAG, "Preparing media player")
|
|
||||||
setPlayerStatus(PlayerStatus.PREPARING, curMedia)
|
|
||||||
prepareWR()
|
|
||||||
// onPrepared(startWhenPrepared.get())
|
|
||||||
if (mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight)
|
|
||||||
if (curMedia != null) {
|
|
||||||
val pos = curMedia!!.getPosition()
|
|
||||||
if (pos > 0) seekTo(pos)
|
|
||||||
if (curMedia != null && curMedia!!.getDuration() <= 0) {
|
|
||||||
Logd(TAG, "Setting duration of media")
|
|
||||||
curMedia!!.setDuration(if (exoPlayer?.duration == C.TIME_UNSET) Playable.INVALID_TIME else exoPlayer!!.duration.toInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPlayerStatus(PlayerStatus.PREPARED, curMedia)
|
|
||||||
if (startWhenPrepared.get()) resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reinit() {
|
|
||||||
Logd(TAG, "reinit() called")
|
|
||||||
releaseWifiLockIfNecessary()
|
|
||||||
when {
|
|
||||||
curMedia != null -> playMediaObject(curMedia!!, isStreaming, startWhenPrepared.get(), prepareImmediately = false, true)
|
|
||||||
else -> Logd(TAG, "Call to reinit: media and mediaPlayer were null, ignored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekTo(t: Int) {
|
|
||||||
var t = t
|
|
||||||
if (t < 0) t = 0
|
|
||||||
Logd(TAG, "seekTo() called $t")
|
|
||||||
|
|
||||||
if (t >= getDuration()) {
|
|
||||||
Logd(TAG, "Seek reached end of file, skipping to next episode")
|
|
||||||
exoPlayer?.seekTo(t.toLong()) // can set curMedia to null
|
|
||||||
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
|
|
||||||
audioSeekCompleteListener?.run()
|
|
||||||
endPlayback(true, wasSkipped = true, true, toStoppedState = true)
|
|
||||||
t = getPosition()
|
|
||||||
// return
|
|
||||||
}
|
|
||||||
|
|
||||||
when (status) {
|
|
||||||
PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> {
|
|
||||||
Logd(TAG, "seekTo t: $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
|
|
||||||
Logd(TAG, "setVolume: $volumeLeft $volumeRight")
|
|
||||||
val playable = curMedia
|
|
||||||
if (playable is EpisodeMedia) {
|
|
||||||
val preferences = playable.episodeOrFetch()?.feed?.preferences
|
|
||||||
if (preferences != null) {
|
|
||||||
val volumeAdaptionSetting = preferences.volumeAdaptionSetting
|
|
||||||
val adaptionFactor = volumeAdaptionSetting.adaptionFactor
|
|
||||||
volumeLeft *= adaptionFactor
|
|
||||||
volumeRight *= adaptionFactor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Logd(TAG, "setVolume 1: $volumeLeft $volumeRight")
|
|
||||||
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")
|
|
||||||
// showStackTrace()
|
|
||||||
|
|
||||||
val currentMedia = curMedia
|
|
||||||
var nextMedia: Playable? = null
|
|
||||||
if (shouldContinue) {
|
|
||||||
// Load next episode if previous episode was in the queue and if there is an episode in the queue left.
|
|
||||||
// Start playback immediately if continuous playback is enabled
|
|
||||||
nextMedia = callback.getNextInQueue(currentMedia)
|
|
||||||
if (nextMedia != null) {
|
|
||||||
Logd(TAG, "has nextMedia. call callback.onPlaybackEnded false")
|
|
||||||
if (wasSkipped) setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
|
||||||
callback.onPlaybackEnded(nextMedia.getMediaType(), false)
|
|
||||||
// setting media to null signals to playMediaObject that we're taking care of post-playback processing
|
|
||||||
curMedia = null
|
|
||||||
playMediaObject(nextMedia, !nextMedia.localFileAvailable(), isPlaying, isPlaying)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
shouldContinue || toStoppedState -> {
|
|
||||||
if (nextMedia == null) {
|
|
||||||
Logd(TAG, "nextMedia is null. call callback.onPlaybackEnded true")
|
|
||||||
callback.onPlaybackEnded(null, true)
|
|
||||||
curMedia = null
|
|
||||||
exoPlayer?.stop()
|
|
||||||
releaseWifiLockIfNecessary()
|
|
||||||
if (status == PlayerStatus.INDETERMINATE) setPlayerStatus(PlayerStatus.STOPPED, null)
|
|
||||||
else Logd(TAG, "Ignored call to stop: Current player state is: $status")
|
|
||||||
}
|
|
||||||
val hasNext = nextMedia != null
|
|
||||||
if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext)
|
|
||||||
// curMedia = nextMedia
|
|
||||||
}
|
|
||||||
isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shouldLockWifi(): Boolean {
|
|
||||||
return isStreaming
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setMediaPlayerListeners() {
|
|
||||||
if (curMedia == null) return
|
|
||||||
|
|
||||||
audioCompletionListener = Runnable {
|
|
||||||
Logd(TAG, "audioCompletionListener called")
|
|
||||||
endPlayback(hasEnded = true, wasSkipped = false, shouldContinue = true, toStoppedState = true)
|
|
||||||
}
|
|
||||||
audioSeekCompleteListener = Runnable { this.genericSeekCompleteListener() }
|
|
||||||
bufferingUpdateListener = Consumer { percent: Int ->
|
|
||||||
when (percent) {
|
|
||||||
BUFFERING_STARTED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.started())
|
|
||||||
BUFFERING_ENDED -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.ended())
|
|
||||||
else -> EventFlow.postEvent(FlowEvent.BufferUpdateEvent.progressUpdate(0.01f * percent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
audioErrorListener = Consumer { message: String ->
|
|
||||||
Log.e(TAG, "PlayerErrorEvent: $message")
|
|
||||||
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearMediaPlayerListeners() {
|
|
||||||
audioCompletionListener = Runnable {}
|
|
||||||
audioSeekCompleteListener = Runnable {}
|
|
||||||
bufferingUpdateListener = Consumer { }
|
|
||||||
audioErrorListener = Consumer {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun genericSeekCompleteListener() {
|
|
||||||
Logd(TAG, "genericSeekCompleteListener $status ${exoPlayer?.isPlaying} $statusBeforeSeeking")
|
|
||||||
seekLatch?.countDown()
|
|
||||||
|
|
||||||
if ((status == PlayerStatus.PLAYING || exoPlayer?.isPlaying != true) && curMedia != null) callback.onPlaybackStart(curMedia!!, getPosition())
|
|
||||||
if (status == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, curMedia, getPosition())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isCasting(): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupPlayerListener() {
|
|
||||||
exoplayerListener = object : Player.Listener {
|
|
||||||
override fun onPlaybackStateChanged(playbackState: @State Int) {
|
|
||||||
Logd(TAG, "onPlaybackStateChanged $playbackState")
|
|
||||||
when (playbackState) {
|
|
||||||
STATE_ENDED -> {
|
|
||||||
exoPlayer?.seekTo(C.TIME_UNSET)
|
|
||||||
audioCompletionListener?.run()
|
|
||||||
}
|
|
||||||
STATE_BUFFERING -> bufferingUpdateListener?.accept(BUFFERING_STARTED)
|
|
||||||
else -> bufferingUpdateListener?.accept(BUFFERING_ENDED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
||||||
// val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
|
|
||||||
// TODO: test: changing PAUSED to STOPPED or INDETERMINATE makes resume not possible if interrupted
|
|
||||||
val stat = if (isPlaying) PlayerStatus.PLAYING else PlayerStatus.PAUSED
|
|
||||||
setPlayerStatus(stat, curMedia)
|
|
||||||
Logd(TAG, "onIsPlayingChanged $isPlaying")
|
|
||||||
}
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
|
||||||
Log.d(TAG, "onPlayerError ${error.message}")
|
|
||||||
if (wasDownloadBlocked(error)) audioErrorListener?.accept(context.getString(R.string.download_error_blocked))
|
|
||||||
else {
|
|
||||||
var cause = error.cause
|
|
||||||
if (cause is HttpDataSourceException && cause.cause != null) cause = cause.cause
|
|
||||||
if (cause != null && "Source error" == cause.message) cause = cause.cause
|
|
||||||
audioErrorListener?.accept((if (cause != null) cause.message else error.message) ?:"no message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, reason: @DiscontinuityReason Int) {
|
|
||||||
Logd(TAG, "onPositionDiscontinuity $oldPosition $newPosition $reason")
|
|
||||||
if (reason == DISCONTINUITY_REASON_SEEK) audioSeekCompleteListener?.run()
|
|
||||||
}
|
|
||||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
|
||||||
Logd(TAG, "onAudioSessionIdChanged $audioSessionId")
|
|
||||||
initLoudnessEnhancer(audioSessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG: String = LocalMediaPlayer::class.simpleName ?: "Anonymous"
|
|
||||||
|
|
||||||
const val BUFFERING_STARTED: Int = -1
|
|
||||||
const val BUFFERING_ENDED: Int = -2
|
|
||||||
|
|
||||||
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
|
|
||||||
|
|
||||||
private var trackSelector: DefaultTrackSelector? = null
|
|
||||||
|
|
||||||
var exoPlayer: ExoPlayer? = null
|
|
||||||
|
|
||||||
private var exoplayerListener: Player.Listener? = null
|
|
||||||
private var audioSeekCompleteListener: java.lang.Runnable? = null
|
|
||||||
private var audioCompletionListener: java.lang.Runnable? = null
|
|
||||||
private var audioErrorListener: Consumer<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(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the background tasks of PlaybackSerivce, i.e.
|
* Manages the background tasks of PlaybackSerivce, i.e.
|
||||||
* the sleep timer, the position saver, the widget updater and the queue loader.
|
* the sleep timer, the position saver, the widget updater and the queue loader.
|
||||||
|
|
|
@ -229,7 +229,8 @@ class AudioPlayerFragment : Fragment() {
|
||||||
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_rewind), tint = textColor,
|
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_rewind), tint = textColor,
|
||||||
contentDescription = "rewind",
|
contentDescription = "rewind",
|
||||||
modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = {
|
modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = {
|
||||||
if (controller != null && playbackService?.isServiceReady() == true)
|
// TODO: the check appears not necessary and hurting cast
|
||||||
|
// if (controller != null && playbackService?.isServiceReady() == true)
|
||||||
playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000)
|
playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000)
|
||||||
}, onLongClick = {
|
}, onLongClick = {
|
||||||
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND)
|
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND)
|
||||||
|
@ -251,7 +252,8 @@ class AudioPlayerFragment : Fragment() {
|
||||||
} else playPause()
|
} else playPause()
|
||||||
}
|
}
|
||||||
}, onLongClick = {
|
}, onLongClick = {
|
||||||
if (controller != null && status == PlayerStatus.PLAYING) {
|
// if (controller != null && status == PlayerStatus.PLAYING) {
|
||||||
|
if (status == PlayerStatus.PLAYING) {
|
||||||
val fallbackSpeed = UserPreferences.fallbackSpeed
|
val fallbackSpeed = UserPreferences.fallbackSpeed
|
||||||
if (fallbackSpeed > 0.1f) toggleFallbackSpeed(fallbackSpeed)
|
if (fallbackSpeed > 0.1f) toggleFallbackSpeed(fallbackSpeed)
|
||||||
}
|
}
|
||||||
|
@ -261,7 +263,8 @@ class AudioPlayerFragment : Fragment() {
|
||||||
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_forward), tint = textColor,
|
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_forward), tint = textColor,
|
||||||
contentDescription = "forward",
|
contentDescription = "forward",
|
||||||
modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = {
|
modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = {
|
||||||
if (controller != null && playbackService?.isServiceReady() == true)
|
// TODO: the check appears not necessary and hurting cast
|
||||||
|
// if (controller != null && playbackService?.isServiceReady() == true)
|
||||||
playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000)
|
playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000)
|
||||||
}, onLongClick = {
|
}, onLongClick = {
|
||||||
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD)
|
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD)
|
||||||
|
@ -282,7 +285,8 @@ class AudioPlayerFragment : Fragment() {
|
||||||
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_skip_48dp), tint = textColor,
|
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_skip_48dp), tint = textColor,
|
||||||
contentDescription = "rewind",
|
contentDescription = "rewind",
|
||||||
modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = {
|
modifier = Modifier.width(43.dp).height(43.dp).combinedClickable(onClick = {
|
||||||
if (controller != null && status == PlayerStatus.PLAYING) {
|
// if (controller != null && status == PlayerStatus.PLAYING) {
|
||||||
|
if (status == PlayerStatus.PLAYING) {
|
||||||
val speedForward = UserPreferences.speedforwardSpeed
|
val speedForward = UserPreferences.speedforwardSpeed
|
||||||
if (speedForward > 0.1f) speedForward(speedForward)
|
if (speedForward > 0.1f) speedForward(speedForward)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
package ac.mdiq.podcini.ui.fragment
|
package ac.mdiq.podcini.ui.fragment
|
||||||
|
|
||||||
|
|
||||||
import ac.mdiq.podcini.R
|
import ac.mdiq.podcini.R
|
||||||
import ac.mdiq.podcini.databinding.SearchFragmentBinding
|
import ac.mdiq.podcini.databinding.SearchFragmentBinding
|
||||||
import ac.mdiq.podcini.net.download.DownloadStatus
|
import ac.mdiq.podcini.net.download.DownloadStatus
|
||||||
|
|
|
@ -28,15 +28,15 @@ import kotlin.math.min
|
||||||
* Implementation of MediaPlayerBase suitable for remote playback on Cast Devices.
|
* Implementation of MediaPlayerBase suitable for remote playback on Cast Devices.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("VisibleForTests")
|
@SuppressLint("VisibleForTests")
|
||||||
class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
|
class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
|
||||||
val TAG = this::class.simpleName ?: "Anonymous"
|
val TAG = this::class.simpleName ?: "Anonymous"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var remoteMedia: MediaInfo? = null
|
private var mediaInfo: MediaInfo? = null
|
||||||
@Volatile
|
@Volatile
|
||||||
private var remoteState: Int
|
private var remoteState: Int
|
||||||
private val castContext = CastContext.getSharedInstance(context)
|
private val castContext = CastContext.getSharedInstance(context)
|
||||||
private val remoteMediaClient = castContext.sessionManager.currentCastSession!!.remoteMediaClient
|
private val remoteMediaClient = castContext.sessionManager.currentCastSession?.remoteMediaClient
|
||||||
private val isBuffering: AtomicBoolean
|
private val isBuffering: AtomicBoolean
|
||||||
|
|
||||||
private val remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() {
|
private val remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() {
|
||||||
|
@ -53,12 +53,12 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
onRemoteMediaPlayerStatusUpdated()
|
onRemoteMediaPlayerStatusUpdated()
|
||||||
}
|
}
|
||||||
override fun onMediaError(mediaError: MediaError) {
|
override fun onMediaError(mediaError: MediaError) {
|
||||||
EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason!!))
|
EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason?: "No reason"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
remoteMediaClient!!.registerCallback(remoteMediaClientCallback)
|
remoteMediaClient?.registerCallback(remoteMediaClientCallback)
|
||||||
curMedia = null
|
curMedia = null
|
||||||
isStreaming = true
|
isStreaming = true
|
||||||
isBuffering = AtomicBoolean(false)
|
isBuffering = AtomicBoolean(false)
|
||||||
|
@ -72,17 +72,17 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun localVersion(info: MediaInfo?): Playable? {
|
private fun toPlayable(info: MediaInfo?): Playable? {
|
||||||
if (info == null || info.metadata == null) return null
|
if (info == null || info.metadata == null) return null
|
||||||
if (CastUtils.matches(info, curMedia)) return curMedia
|
if (CastUtils.matches(info, curMedia)) return curMedia
|
||||||
val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL)
|
val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL)
|
||||||
return if (streamUrl == null) CastUtils.makeRemoteMedia(info) else callback.findMedia(streamUrl)
|
return if (streamUrl == null) CastUtils.makeRemoteMedia(info) else callback.findMedia(streamUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remoteVersion(playable: Playable?): MediaInfo? {
|
private fun toMediaInfo(playable: Playable?): MediaInfo? {
|
||||||
return when {
|
return when {
|
||||||
playable == null -> null
|
playable == null -> null
|
||||||
CastUtils.matches(remoteMedia, playable) -> remoteMedia
|
CastUtils.matches(mediaInfo, playable) -> mediaInfo
|
||||||
playable is EpisodeMedia -> MediaInfoCreator.from(playable)
|
playable is EpisodeMedia -> MediaInfoCreator.from(playable)
|
||||||
playable is RemoteMedia -> MediaInfoCreator.from(playable)
|
playable is RemoteMedia -> MediaInfoCreator.from(playable)
|
||||||
// playable is RemoteMedia -> MediaInfoCreator.from(playable)
|
// playable is RemoteMedia -> MediaInfoCreator.from(playable)
|
||||||
|
@ -91,7 +91,7 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRemoteMediaPlayerStatusUpdated() {
|
private fun onRemoteMediaPlayerStatusUpdated() {
|
||||||
val mediaStatus = remoteMediaClient!!.mediaStatus
|
val mediaStatus = remoteMediaClient?.mediaStatus
|
||||||
if (mediaStatus == null) {
|
if (mediaStatus == null) {
|
||||||
Logd(TAG, "Received null MediaStatus")
|
Logd(TAG, "Received null MediaStatus")
|
||||||
return
|
return
|
||||||
|
@ -99,14 +99,14 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
|
|
||||||
var state = mediaStatus.playerState
|
var state = mediaStatus.playerState
|
||||||
val oldState = remoteState
|
val oldState = remoteState
|
||||||
remoteMedia = mediaStatus.mediaInfo
|
mediaInfo = mediaStatus.mediaInfo
|
||||||
val mediaChanged = !CastUtils.matches(remoteMedia, curMedia)
|
val mediaChanged = !CastUtils.matches(mediaInfo, curMedia)
|
||||||
var stateChanged = state != oldState
|
var stateChanged = state != oldState
|
||||||
if (!mediaChanged && !stateChanged) {
|
if (!mediaChanged && !stateChanged) {
|
||||||
Logd(TAG, "Both media and state haven't changed, so nothing to do")
|
Logd(TAG, "Both media and state haven't changed, so nothing to do")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val currentMedia = if (mediaChanged) localVersion(remoteMedia) else curMedia
|
val currentMedia = if (mediaChanged) toPlayable(mediaInfo) else curMedia
|
||||||
val oldMedia = curMedia
|
val oldMedia = curMedia
|
||||||
val position = mediaStatus.streamPosition.toInt()
|
val position = mediaStatus.streamPosition.toInt()
|
||||||
// check for incompatible states
|
// check for incompatible states
|
||||||
|
@ -126,8 +126,8 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
MediaStatus.PLAYER_STATE_PLAYING -> {
|
MediaStatus.PLAYER_STATE_PLAYING -> {
|
||||||
if (!stateChanged) {
|
if (!stateChanged) {
|
||||||
//These steps are necessary because they won't be performed by setPlayerStatus()
|
//These steps are necessary because they won't be performed by setPlayerStatus()
|
||||||
if (position >= 0) currentMedia!!.setPosition(position)
|
if (position >= 0) currentMedia?.setPosition(position)
|
||||||
currentMedia!!.onPlaybackStart()
|
currentMedia?.onPlaybackStart()
|
||||||
}
|
}
|
||||||
setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position)
|
setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position)
|
||||||
}
|
}
|
||||||
|
@ -199,8 +199,7 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
Logd(TAG, "media provided is not compatible with cast device")
|
Logd(TAG, "media provided is not compatible with cast device")
|
||||||
EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device"))
|
EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device"))
|
||||||
var nextPlayable: Playable? = playable
|
var nextPlayable: Playable? = playable
|
||||||
do {
|
do { nextPlayable = callback.getNextInQueue(nextPlayable)
|
||||||
nextPlayable = callback.getNextInQueue(nextPlayable)
|
|
||||||
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession))
|
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession))
|
||||||
|
|
||||||
if (nextPlayable != null) playMediaObject(nextPlayable, stream, startWhenPrepared, prepareImmediately, forceReset)
|
if (nextPlayable != null) playMediaObject(nextPlayable, stream, startWhenPrepared, prepareImmediately, forceReset)
|
||||||
|
@ -214,8 +213,8 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// set temporarily to pause in order to update list with current position
|
// set temporarily to pause in order to update list with current position
|
||||||
val isPlaying = remoteMediaClient!!.isPlaying
|
val isPlaying = remoteMediaClient?.isPlaying ?: false
|
||||||
val position = remoteMediaClient.approximateStreamPosition.toInt()
|
val position = remoteMediaClient?.approximateStreamPosition?.toInt() ?: 0
|
||||||
if (isPlaying) callback.onPlaybackPause(curMedia, position)
|
if (isPlaying) callback.onPlaybackPause(curMedia, position)
|
||||||
if (status == PlayerStatus.PLAYING) {
|
if (status == PlayerStatus.PLAYING) {
|
||||||
val pos = curMedia?.getPosition() ?: -1
|
val pos = curMedia?.getPosition() ?: -1
|
||||||
|
@ -232,7 +231,7 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
}
|
}
|
||||||
|
|
||||||
curMedia = playable
|
curMedia = playable
|
||||||
remoteMedia = remoteVersion(playable)
|
mediaInfo = toMediaInfo(playable)
|
||||||
this.mediaType = curMedia!!.getMediaType()
|
this.mediaType = curMedia!!.getMediaType()
|
||||||
this.startWhenPrepared.set(startWhenPrepared)
|
this.startWhenPrepared.set(startWhenPrepared)
|
||||||
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
|
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
|
||||||
|
@ -245,11 +244,11 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
override fun resume() {
|
override fun resume() {
|
||||||
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
|
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
|
||||||
seekTo(newPosition)
|
seekTo(newPosition)
|
||||||
remoteMediaClient!!.play()
|
remoteMediaClient?.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause(abandonFocus: Boolean, reinit: Boolean) {
|
override fun pause(abandonFocus: Boolean, reinit: Boolean) {
|
||||||
remoteMediaClient!!.pause()
|
remoteMediaClient?.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun prepare() {
|
override fun prepare() {
|
||||||
|
@ -258,8 +257,8 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
setPlayerStatus(PlayerStatus.PREPARING, curMedia)
|
setPlayerStatus(PlayerStatus.PREPARING, curMedia)
|
||||||
var position = curMedia!!.getPosition()
|
var position = curMedia!!.getPosition()
|
||||||
if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime())
|
if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime())
|
||||||
remoteMediaClient!!.load(MediaLoadRequestData.Builder()
|
remoteMediaClient?.load(MediaLoadRequestData.Builder()
|
||||||
.setMediaInfo(remoteMedia)
|
.setMediaInfo(mediaInfo)
|
||||||
.setAutoplay(startWhenPrepared.get())
|
.setAutoplay(startWhenPrepared.get())
|
||||||
.setCurrentTime(position.toLong()).build())
|
.setCurrentTime(position.toLong()).build())
|
||||||
}
|
}
|
||||||
|
@ -272,45 +271,50 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(t: Int) {
|
override fun seekTo(t: Int) {
|
||||||
Exception("Seeking to $t").printStackTrace()
|
// Exception("Seeking to $t").printStackTrace()
|
||||||
remoteMediaClient!!.seek(MediaSeekOptions.Builder().setPosition(t.toLong()).setResumeState(MediaSeekOptions.RESUME_STATE_PLAY).build())
|
remoteMediaClient?.seek(MediaSeekOptions.Builder().setPosition(t.toLong()).setResumeState(MediaSeekOptions.RESUME_STATE_PLAY).build())?.addStatusListener {
|
||||||
|
if (it.isSuccess) {
|
||||||
|
Logd(TAG, "seekTo Seek succeeded to position $t ms")
|
||||||
|
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, t, curMedia!!.getDuration()))
|
||||||
|
} else Log.e(TAG, "Seek failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDuration(): Int {
|
override fun getDuration(): Int {
|
||||||
var retVal = remoteMediaClient!!.streamDuration.toInt()
|
var retVal = remoteMediaClient?.streamDuration?.toInt() ?: 0
|
||||||
if (retVal == Playable.INVALID_TIME && curMedia != null && curMedia!!.getDuration() > 0) retVal = curMedia!!.getDuration()
|
if (retVal == Playable.INVALID_TIME && curMedia != null && curMedia!!.getDuration() > 0) retVal = curMedia!!.getDuration()
|
||||||
return retVal
|
return retVal
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPosition(): Int {
|
override fun getPosition(): Int {
|
||||||
var retVal = remoteMediaClient!!.approximateStreamPosition.toInt()
|
var retVal = remoteMediaClient?.approximateStreamPosition?.toInt() ?: 0
|
||||||
if (retVal <= 0 && curMedia != null && curMedia!!.getPosition() >= 0) retVal = curMedia!!.getPosition()
|
if (retVal <= 0 && curMedia != null && curMedia!!.getPosition() >= 0) retVal = curMedia!!.getPosition()
|
||||||
return retVal
|
return retVal
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setPlaybackParams(speed: Float, skipSilence: Boolean) {
|
override fun setPlaybackParams(speed: Float, skipSilence: Boolean) {
|
||||||
val playbackRate = max(MediaLoadOptions.PLAYBACK_RATE_MIN, min(MediaLoadOptions.PLAYBACK_RATE_MAX, speed.toDouble())).toFloat().toDouble()
|
val playbackRate = max(MediaLoadOptions.PLAYBACK_RATE_MIN, min(MediaLoadOptions.PLAYBACK_RATE_MAX, speed.toDouble())).toFloat().toDouble()
|
||||||
remoteMediaClient!!.setPlaybackRate(playbackRate)
|
remoteMediaClient?.setPlaybackRate(playbackRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPlaybackSpeed(): Float {
|
override fun getPlaybackSpeed(): Float {
|
||||||
val status = remoteMediaClient!!.mediaStatus
|
val status = remoteMediaClient?.mediaStatus
|
||||||
return status?.playbackRate?.toFloat() ?: 1.0f
|
return status?.playbackRate?.toFloat() ?: 1.0f
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setVolume(volumeLeft: Float, volumeRight: Float) {
|
override fun setVolume(volumeLeft: Float, volumeRight: Float) {
|
||||||
Logd(TAG, "Setting the Stream volume on Remote Media Player")
|
Logd(TAG, "Setting the Stream volume on Remote Media Player")
|
||||||
remoteMediaClient!!.setStreamVolume(volumeLeft.toDouble())
|
remoteMediaClient?.setStreamVolume(volumeLeft.toDouble())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun shutdown() {
|
override fun shutdown() {
|
||||||
remoteMediaClient!!.unregisterCallback(remoteMediaClientCallback)
|
remoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setPlayable(playable: Playable?) {
|
override fun setPlayable(playable: Playable?) {
|
||||||
if (playable !== curMedia) {
|
if (playable !== curMedia) {
|
||||||
curMedia = playable
|
curMedia = playable
|
||||||
remoteMedia = remoteVersion(playable)
|
mediaInfo = toMediaInfo(playable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -344,10 +348,10 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
when {
|
when {
|
||||||
shouldContinue || toStoppedState -> {
|
shouldContinue || toStoppedState -> {
|
||||||
if (nextMedia == null) {
|
if (nextMedia == null) {
|
||||||
remoteMediaClient!!.stop()
|
remoteMediaClient?.stop()
|
||||||
// Otherwise we rely on the chromecast callback to tell us the playback has stopped.
|
// Otherwise we rely on the chromecast callback to tell us the playback has stopped.
|
||||||
callback.onPostPlayback(currentMedia!!, hasEnded, wasSkipped, false)
|
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, false)
|
||||||
} else callback.onPostPlayback(currentMedia!!, hasEnded, wasSkipped, true)
|
} else callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, true)
|
||||||
}
|
}
|
||||||
isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia?.getPosition() ?: Playable.INVALID_TIME)
|
isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia?.getPosition() ?: Playable.INVALID_TIME)
|
||||||
}
|
}
|
||||||
|
@ -361,7 +365,7 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
|
||||||
|
|
||||||
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
|
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
|
||||||
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) return null
|
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) return null
|
||||||
try { if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) return CastPsmp(context, callback) } catch (e: Exception) { e.printStackTrace() }
|
try { if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) return CastMediaPlayer(context, callback) } catch (e: Exception) { e.printStackTrace() }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,3 +1,7 @@
|
||||||
|
# 6.12.5
|
||||||
|
|
||||||
|
* fixed a long-standing issue in the play apk where rewind/forward buttons don't work during cast
|
||||||
|
|
||||||
# 6.12.4
|
# 6.12.4
|
||||||
|
|
||||||
* bug fixes and enhancements in filters routines
|
* bug fixes and enhancements in filters routines
|
||||||
|
|
Loading…
Reference in New Issue