6.12.5 commit

This commit is contained in:
Xilin Jia 2024-10-25 22:01:25 +01:00
parent 25893b79ea
commit c3bf2e4e8b
8 changed files with 901 additions and 864 deletions

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020282
versionName "6.12.4"
versionCode 3020283
versionName "6.12.5"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -7,7 +7,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerCallback
/**
* Stub implementation of CastPsmp for Free build flavour
*/
object CastPsmp {
object CastMediaPlayer {
@JvmStatic
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
return null

View File

@ -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
}
}
}

View File

@ -1,13 +1,9 @@
package ac.mdiq.podcini.playback.service
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileStreaming
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.*
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.getCurrentPlaybackSpeed
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.preferences.SleepTimerPreferences
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.fastForwardSecs
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.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.getEpisodeByGuidOrUrl
import ac.mdiq.podcini.storage.database.Episodes.setCompletionDate
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue
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.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.vista.extractor.MediaFormat
import ac.mdiq.vista.extractor.stream.AudioStream
import ac.mdiq.vista.extractor.stream.DeliveryMethod
import ac.mdiq.vista.extractor.stream.VideoStream
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.UiModeManager
import android.bluetooth.BluetoothA2dp
import android.content.*
import android.content.Intent.EXTRA_KEY_EVENT
import android.content.res.Configuration
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.media.AudioManager
import android.media.audiofx.LoudnessEnhancer
import android.net.Uri
import android.os.*
import android.os.Build.VERSION_CODES
import android.service.quicksettings.TileService
import android.util.Log
import android.util.Pair
import android.view.KeyEvent
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
import android.view.SurfaceHolder
import android.view.ViewConfiguration
import android.webkit.URLUtil
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationCompat
import androidx.core.util.Consumer
import androidx.media3.common.*
import androidx.media3.common.Player.*
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player.STATE_ENDED
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mp3.Mp3Extractor
import androidx.media3.session.*
import androidx.media3.ui.DefaultTrackNameProvider
import androidx.media3.ui.TrackNameProvider
import androidx.work.impl.utils.futures.SettableFuture
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import java.io.IOException
import java.util.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.TimeUnit
@ -728,7 +691,7 @@ class PlaybackService : MediaLibraryService() {
mPlayer!!.pause(abandonFocus = true, reinit = false)
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
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.
* the sleep timer, the position saver, the widget updater and the queue loader.

View File

@ -229,7 +229,8 @@ class AudioPlayerFragment : Fragment() {
Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_fast_rewind), tint = textColor,
contentDescription = "rewind",
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)
}, onLongClick = {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND)
@ -251,7 +252,8 @@ class AudioPlayerFragment : Fragment() {
} else playPause()
}
}, onLongClick = {
if (controller != null && status == PlayerStatus.PLAYING) {
// if (controller != null && status == PlayerStatus.PLAYING) {
if (status == PlayerStatus.PLAYING) {
val fallbackSpeed = UserPreferences.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,
contentDescription = "forward",
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)
}, onLongClick = {
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,
contentDescription = "rewind",
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
if (speedForward > 0.1f) speedForward(speedForward)
}

View File

@ -1,6 +1,5 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SearchFragmentBinding
import ac.mdiq.podcini.net.download.DownloadStatus

View File

@ -28,15 +28,15 @@ import kotlin.math.min
* Implementation of MediaPlayerBase suitable for remote playback on Cast Devices.
*/
@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"
@Volatile
private var remoteMedia: MediaInfo? = null
private var mediaInfo: MediaInfo? = null
@Volatile
private var remoteState: Int
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 remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() {
@ -53,12 +53,12 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
onRemoteMediaPlayerStatusUpdated()
}
override fun onMediaError(mediaError: MediaError) {
EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason!!))
EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason?: "No reason"))
}
}
init {
remoteMediaClient!!.registerCallback(remoteMediaClientCallback)
remoteMediaClient?.registerCallback(remoteMediaClientCallback)
curMedia = null
isStreaming = true
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 (CastUtils.matches(info, curMedia)) return curMedia
val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL)
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 {
playable == null -> null
CastUtils.matches(remoteMedia, playable) -> remoteMedia
CastUtils.matches(mediaInfo, playable) -> mediaInfo
playable is EpisodeMedia -> 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() {
val mediaStatus = remoteMediaClient!!.mediaStatus
val mediaStatus = remoteMediaClient?.mediaStatus
if (mediaStatus == null) {
Logd(TAG, "Received null MediaStatus")
return
@ -99,14 +99,14 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
var state = mediaStatus.playerState
val oldState = remoteState
remoteMedia = mediaStatus.mediaInfo
val mediaChanged = !CastUtils.matches(remoteMedia, curMedia)
mediaInfo = mediaStatus.mediaInfo
val mediaChanged = !CastUtils.matches(mediaInfo, curMedia)
var stateChanged = state != oldState
if (!mediaChanged && !stateChanged) {
Logd(TAG, "Both media and state haven't changed, so nothing to do")
return
}
val currentMedia = if (mediaChanged) localVersion(remoteMedia) else curMedia
val currentMedia = if (mediaChanged) toPlayable(mediaInfo) else curMedia
val oldMedia = curMedia
val position = mediaStatus.streamPosition.toInt()
// check for incompatible states
@ -126,8 +126,8 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
MediaStatus.PLAYER_STATE_PLAYING -> {
if (!stateChanged) {
//These steps are necessary because they won't be performed by setPlayerStatus()
if (position >= 0) currentMedia!!.setPosition(position)
currentMedia!!.onPlaybackStart()
if (position >= 0) currentMedia?.setPosition(position)
currentMedia?.onPlaybackStart()
}
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")
EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device"))
var nextPlayable: Playable? = playable
do {
nextPlayable = callback.getNextInQueue(nextPlayable)
do { nextPlayable = callback.getNextInQueue(nextPlayable)
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession))
if (nextPlayable != null) playMediaObject(nextPlayable, stream, startWhenPrepared, prepareImmediately, forceReset)
@ -214,8 +213,8 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
return
} else {
// set temporarily to pause in order to update list with current position
val isPlaying = remoteMediaClient!!.isPlaying
val position = remoteMediaClient.approximateStreamPosition.toInt()
val isPlaying = remoteMediaClient?.isPlaying ?: false
val position = remoteMediaClient?.approximateStreamPosition?.toInt() ?: 0
if (isPlaying) callback.onPlaybackPause(curMedia, position)
if (status == PlayerStatus.PLAYING) {
val pos = curMedia?.getPosition() ?: -1
@ -232,7 +231,7 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
}
curMedia = playable
remoteMedia = remoteVersion(playable)
mediaInfo = toMediaInfo(playable)
this.mediaType = curMedia!!.getMediaType()
this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
@ -245,11 +244,11 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
override fun resume() {
val newPosition = calculatePositionWithRewind(curMedia!!.getPosition(), curMedia!!.getLastPlayedTime())
seekTo(newPosition)
remoteMediaClient!!.play()
remoteMediaClient?.play()
}
override fun pause(abandonFocus: Boolean, reinit: Boolean) {
remoteMediaClient!!.pause()
remoteMediaClient?.pause()
}
override fun prepare() {
@ -258,8 +257,8 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
setPlayerStatus(PlayerStatus.PREPARING, curMedia)
var position = curMedia!!.getPosition()
if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime())
remoteMediaClient!!.load(MediaLoadRequestData.Builder()
.setMediaInfo(remoteMedia)
remoteMediaClient?.load(MediaLoadRequestData.Builder()
.setMediaInfo(mediaInfo)
.setAutoplay(startWhenPrepared.get())
.setCurrentTime(position.toLong()).build())
}
@ -272,45 +271,50 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
}
override fun seekTo(t: Int) {
Exception("Seeking to $t").printStackTrace()
remoteMediaClient!!.seek(MediaSeekOptions.Builder().setPosition(t.toLong()).setResumeState(MediaSeekOptions.RESUME_STATE_PLAY).build())
// Exception("Seeking to $t").printStackTrace()
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 {
var retVal = remoteMediaClient!!.streamDuration.toInt()
var retVal = remoteMediaClient?.streamDuration?.toInt() ?: 0
if (retVal == Playable.INVALID_TIME && curMedia != null && curMedia!!.getDuration() > 0) retVal = curMedia!!.getDuration()
return retVal
}
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()
return retVal
}
override fun setPlaybackParams(speed: Float, skipSilence: Boolean) {
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 {
val status = remoteMediaClient!!.mediaStatus
val status = remoteMediaClient?.mediaStatus
return status?.playbackRate?.toFloat() ?: 1.0f
}
override fun setVolume(volumeLeft: Float, volumeRight: Float) {
Logd(TAG, "Setting the Stream volume on Remote Media Player")
remoteMediaClient!!.setStreamVolume(volumeLeft.toDouble())
remoteMediaClient?.setStreamVolume(volumeLeft.toDouble())
}
override fun shutdown() {
remoteMediaClient!!.unregisterCallback(remoteMediaClientCallback)
remoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
}
override fun setPlayable(playable: Playable?) {
if (playable !== curMedia) {
curMedia = playable
remoteMedia = remoteVersion(playable)
mediaInfo = toMediaInfo(playable)
}
}
@ -344,10 +348,10 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
when {
shouldContinue || toStoppedState -> {
if (nextMedia == null) {
remoteMediaClient!!.stop()
remoteMediaClient?.stop()
// Otherwise we rely on the chromecast callback to tell us the playback has stopped.
callback.onPostPlayback(currentMedia!!, hasEnded, wasSkipped, false)
} else callback.onPostPlayback(currentMedia!!, hasEnded, wasSkipped, true)
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, false)
} else callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, true)
}
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? {
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
}
}

View File

@ -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
* bug fixes and enhancements in filters routines