diff --git a/app/build.gradle b/app/build.gradle index 0d617aa1..01ace6d8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020282 - versionName "6.12.4" + versionCode 3020283 + versionName "6.12.5" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt b/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt similarity index 93% rename from app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt rename to app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt index f8c866b6..2337fe72 100644 --- a/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt +++ b/app/src/free/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt new file mode 100644 index 00000000..aad9f522 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/LocalMediaPlayer.kt @@ -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? = null + private var isShutDown = false + private var seekLatch: CountDownLatch? = null + + private val bufferUpdateInterval = 5000L + private var mediaSource: MediaSource? = null + private var mediaItem: MediaItem? = null + private var playbackParameters: PlaybackParameters + + private var bufferedPercentagePrev = 0 + + private val formats: List + get() { + val formats_: MutableList = arrayListOf() + val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList() + val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) + for (i in 0 until trackGroups.length) { + formats_.add(trackGroups[i].getFormat(0)) + } + return formats_ + } + + private val audioRendererIndex: Int + get() { + for (i in 0 until exoPlayer!!.rendererCount) { + if (exoPlayer?.getRendererType(i) == C.TRACK_TYPE_AUDIO) return i + } + return -1 + } + + private val videoWidth: Int + get() { + return exoPlayer?.videoFormat?.width ?: 0 + } + + private val videoHeight: Int + get() { + return exoPlayer?.videoFormat?.height ?: 0 + } + + init { + if (httpDataSourceFactory == null) { + runOnIOScope { + httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) + .setUserAgent(ClientConfig.USER_AGENT) + } + } + if (exoPlayer == null) { + setupPlayerListener() + createStaticPlayer(context) + } + playbackParameters = exoPlayer!!.playbackParameters + val scope = CoroutineScope(Dispatchers.Main) + scope.launch { + while (true) { + delay(bufferUpdateInterval) + withContext(Dispatchers.Main) { + if (exoPlayer != null && bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) { + bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage) + bufferedPercentagePrev = exoPlayer!!.bufferedPercentage + } + } + } + } + } + + @Throws(IllegalStateException::class) + private fun prepareWR() { + Logd(TAG, "prepareWR() called") + if (mediaSource == null && mediaItem == null) return + if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false) + else exoPlayer?.setMediaItem(mediaItem!!) + exoPlayer?.prepare() + } + + private fun release() { + Logd(TAG, "release() called") + exoPlayer?.stop() + exoPlayer?.seekTo(0L) + audioSeekCompleteListener = null + audioCompletionListener = null + audioErrorListener = null + bufferingUpdateListener = null + } + + private fun setAudioStreamType(i: Int) { + val a = exoPlayer!!.audioAttributes + val b = AudioAttributes.Builder() + b.setContentType(i) + b.setFlags(a.flags) + b.setUsage(a.usage) + exoPlayer?.setAudioAttributes(b.build(), true) + } + + @Throws(IllegalArgumentException::class, IllegalStateException::class) + private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { + Logd(TAG, "setDataSource: $mediaUrl") + mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build() + mediaSource = null + setSourceCredentials(user, password) + } + + @Throws(IllegalArgumentException::class, IllegalStateException::class) + private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { + Logd(TAG, "setDataSource1 called") + val url = media.getStreamUrl() ?: return + val preferences = media.episodeOrFetch()?.feed?.preferences + val user = preferences?.username + val password = preferences?.password + if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) { + Logd(TAG, "setDataSource1 setting for YouTube source") + try { +// val vService = Vista.getService(0) + val streamInfo = 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 = ArrayList() + mediaSources.add(vSource) + mediaSources.add(aSource) + mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray()) +// mediaSource = null + } else mediaSource = aSource + mediaItem = mediaSource?.mediaItem + setSourceCredentials(user, password) + } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") } + } else { + Logd(TAG, "setDataSource1 setting for Podcast source") + setDataSource(metadata, url,user, password) + } + } + + private fun setSourceCredentials(user: String?, password: String?) { + if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) { + if (httpDataSourceFactory == null) + httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) + .setUserAgent(ClientConfig.USER_AGENT) + + val requestProperties = HashMap() + requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1") + httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties) + + val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!) + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA) + val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + + mediaSource = f.createMediaSource(mediaItem!!) + } + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according with default format chosen by the user. + * + * @param defaultFormat format to give preference + * @param showHigherResolutions show >1080p resolutions + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only + * streams and normal video streams are available + * @return the sorted list + */ + private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean): List { + val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams) + val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList() + val comparator = compareBy { it.resolution.toResolutionValue() } + return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) } + } + + private fun String.toResolutionValue(): Int { + val match = Regex("(\\d+)p|(\\d+)k").find(this) + return when { + match?.groupValues?.get(1) != null -> match.groupValues[1].toInt() + match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024 + else -> 0 + } + } + + private fun getFilteredAudioStreams(audioStreams: List?): List { + if (audioStreams == null) return listOf() + val collectedStreams = mutableSetOf() + for (stream in audioStreams) { + Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}") + if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS)) + continue + collectedStreams.add(stream) + } + return collectedStreams.toList().sortedWith(compareBy { it.bitrate }) + } + + /** + * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing + * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will + * not do anything. + * Whether playback starts immediately depends on the given parameters. See below for more details. + * States: + * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. + * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If + * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. + * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object + * will enter the ERROR state. + * This method is executed on an internal executor service. + * @param playable The Playable object that is supposed to be played. This parameter must not be null. + * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via + * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by + * the Android MediaPlayer via getStreamUrl. + * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the + * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared + * for playback immediately (see 'prepareImmediately' parameter for more details) + * @param prepareImmediately Set to true if the method should also prepare the episode for playback. + */ + override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { + Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ") +// showStackTrace() + if (curMedia != null) { + Logd(TAG, "playMediaObject: curMedia exist status=$status") + if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { + Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.") + return + } + Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}") + // set temporarily to pause in order to update list with current position + if (status == PlayerStatus.PLAYING) { + val pos = curMedia?.getPosition() ?: -1 + seekTo(pos) + callback.onPlaybackPause(curMedia, pos) + } + // stop playback of this episode + if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop() +// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) +// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true) + setPlayerStatus(PlayerStatus.INDETERMINATE, null) + } + + Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}") + curMedia = playable + if (curMedia is EpisodeMedia) { + val media_ = curMedia as EpisodeMedia + val item = media_.episodeOrFetch() + val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf() + curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id) + } else curIndexInQueue = -1 + + prevMedia = curMedia + this.isStreaming = streaming + mediaType = curMedia!!.getMediaType() + videoSize = null + createMediaPlayer() + this.startWhenPrepared.set(startWhenPrepared) + setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) + val metadata = buildMetadata(curMedia!!) + try { + callback.ensureMediaInfoLoaded(curMedia!!) + callback.onMediaChanged(false) + setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) + CoroutineScope(Dispatchers.IO).launch { + when { + streaming -> { + val streamurl = curMedia!!.getStreamUrl() + if (streamurl != null) { + val media = curMedia + if (media is EpisodeMedia) { + mediaItem = null + mediaSource = null + setDataSource(metadata, media) +// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } +// if (startWhenPrepared) runBlocking { deferred.await() } +// val preferences = media.episodeOrFetch()?.feed?.preferences +// setDataSource(metadata, streamurl, preferences?.username, preferences?.password) + } else setDataSource(metadata, streamurl, null, null) + } + } + else -> { + val localMediaurl = curMedia!!.getLocalMediaUrl() +// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle +// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) + if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) + else throw IOException("Unable to read local file $localMediaurl") + } + } + withContext(Dispatchers.Main) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) + if (prepareImmediately) prepare() + } + } + } catch (e: IOException) { + e.printStackTrace() + setPlayerStatus(PlayerStatus.ERROR, null) + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) + } catch (e: IllegalStateException) { + e.printStackTrace() + setPlayerStatus(PlayerStatus.ERROR, null) + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) + } finally { } + } + + override fun resume() { + 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? { + if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight) + return videoSize + } + + override fun getAudioTracks(): List { + val trackNames: MutableList = ArrayList() + val trackNameProvider: TrackNameProvider = DefaultTrackNameProvider(context.resources) + for (format in formats) { + trackNames.add(trackNameProvider.getTrackName(format)) + } + return trackNames + } + + override fun setAudioTrack(track: Int) { + val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return + val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) + val override = SelectionOverride(track, 0) + val rendererIndex = audioRendererIndex + val params = trackSelector!!.buildUponParameters().setSelectionOverride(rendererIndex, trackGroups, override) + trackSelector!!.setParameters(params) + } + + override fun getSelectedAudioTrack(): Int { + val trackSelections = exoPlayer!!.currentTrackSelections + val availableFormats = formats + Logd(TAG, "selectedAudioTrack called tracks: ${trackSelections.length} formats: ${availableFormats.size}") + for (i in 0 until trackSelections.length) { + val track = trackSelections[i] as? ExoTrackSelection ?: continue + if (availableFormats.contains(track.selectedFormat)) return availableFormats.indexOf(track.selectedFormat) + } + return -1 + } + + override fun createMediaPlayer() { + Logd(TAG, "createMediaPlayer()") + release() + if (curMedia == null) { + status = PlayerStatus.STOPPED + return + } + setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH) + setMediaPlayerListeners() + } + + override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) { + releaseWifiLockIfNecessary() + if (curMedia == null) return + + val isPlaying = status == PlayerStatus.PLAYING + // we're relying on the position stored in the Playable object for post-playback processing + val position = getPosition() + if (position >= 0) curMedia?.setPosition(position) + Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState") +// 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? = null + private var bufferingUpdateListener: Consumer? = null + private var loudnessEnhancer: LoudnessEnhancer? = null + + fun createStaticPlayer(context: Context) { + val loadControl = DefaultLoadControl.Builder() + loadControl.setBufferDurationsMs(30000, 120000, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + loadControl.setBackBuffer(rewindSecs * 1000 + 500, true) + trackSelector = DefaultTrackSelector(context) + val audioOffloadPreferences = AudioOffloadPreferences.Builder() + .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) // Add additional options as needed + .setIsGaplessSupportRequired(true) + .setIsSpeedChangeSupportRequired(true) + .build() + Logd(TAG, "createStaticPlayer creating exoPlayer_") + + val defaultRenderersFactory = DefaultRenderersFactory(context) +// defaultRenderersFactory.setMediaCodecSelector { mimeType: String?, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean -> +// val decoderInfos: List = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder) +// val result: MutableList = ArrayList() +// for (decoderInfo in decoderInfos) { +// Logd(TAG, "decoderInfo.name: ${decoderInfo.name}") +// if (decoderInfo.name == "c2.android.mp3.decoder") { +// continue +// } +// result.add(decoderInfo) +// } +// result +// } + exoPlayer = ExoPlayer.Builder(context, defaultRenderersFactory) + .setTrackSelector(trackSelector!!) + .setLoadControl(loadControl.build()) + .build() + + exoPlayer?.setSeekParameters(SeekParameters.EXACT) + exoPlayer!!.trackSelectionParameters = exoPlayer!!.trackSelectionParameters + .buildUpon() + .setAudioOffloadPreferences(audioOffloadPreferences) + .build() + +// if (BuildConfig.DEBUG) exoPlayer!!.addAnalyticsListener(EventLogger()) + + if (exoplayerListener != null) { + exoPlayer?.removeListener(exoplayerListener!!) + exoPlayer?.addListener(exoplayerListener!!) + } + initLoudnessEnhancer(exoPlayer!!.audioSessionId) + } + + private fun initLoudnessEnhancer(audioStreamId: Int) { + runOnIOScope { + val newEnhancer = LoudnessEnhancer(audioStreamId) + val oldEnhancer = loudnessEnhancer + if (oldEnhancer != null) { + newEnhancer.setEnabled(oldEnhancer.enabled) + if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt()) + oldEnhancer.release() + } + loudnessEnhancer = newEnhancer + } + } + + fun cleanup() { + if (exoplayerListener != null) exoPlayer?.removeListener(exoplayerListener!!) + exoplayerListener = null + audioSeekCompleteListener = null + audioCompletionListener = null + audioErrorListener = null + bufferingUpdateListener = null + loudnessEnhancer = null + httpDataSourceFactory = null + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index 167cbb70..14b1e1b1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -1,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? = null - private var isShutDown = false - private var seekLatch: CountDownLatch? = null - - private val bufferUpdateInterval = 5000L - private var mediaSource: MediaSource? = null - private var mediaItem: MediaItem? = null - private var playbackParameters: PlaybackParameters - - private var bufferedPercentagePrev = 0 - - private val formats: List - get() { - val formats_: MutableList = arrayListOf() - val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return emptyList() - val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) - for (i in 0 until trackGroups.length) { - formats_.add(trackGroups[i].getFormat(0)) - } - return formats_ - } - - private val audioRendererIndex: Int - get() { - for (i in 0 until exoPlayer!!.rendererCount) { - if (exoPlayer?.getRendererType(i) == C.TRACK_TYPE_AUDIO) return i - } - return -1 - } - - private val videoWidth: Int - get() { - return exoPlayer?.videoFormat?.width ?: 0 - } - - private val videoHeight: Int - get() { - return exoPlayer?.videoFormat?.height ?: 0 - } - - init { - if (httpDataSourceFactory == null) { - runOnIOScope { - httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) - .setUserAgent(ClientConfig.USER_AGENT) - } - } - if (exoPlayer == null) { - setupPlayerListener() - createStaticPlayer(context) - } - playbackParameters = exoPlayer!!.playbackParameters - val scope = CoroutineScope(Dispatchers.Main) - scope.launch { - while (true) { - delay(bufferUpdateInterval) - withContext(Dispatchers.Main) { - if (exoPlayer != null && bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) { - bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage) - bufferedPercentagePrev = exoPlayer!!.bufferedPercentage - } - } - } - } - } - - @Throws(IllegalStateException::class) - private fun prepareWR() { - Logd(TAG, "prepareWR() called") - if (mediaSource == null && mediaItem == null) return - if (mediaSource != null) exoPlayer?.setMediaSource(mediaSource!!, false) - else exoPlayer?.setMediaItem(mediaItem!!) - exoPlayer?.prepare() - } - - private fun release() { - Logd(TAG, "release() called") - exoPlayer?.stop() - exoPlayer?.seekTo(0L) - audioSeekCompleteListener = null - audioCompletionListener = null - audioErrorListener = null - bufferingUpdateListener = null - } - - private fun setAudioStreamType(i: Int) { - val a = exoPlayer!!.audioAttributes - val b = AudioAttributes.Builder() - b.setContentType(i) - b.setFlags(a.flags) - b.setUsage(a.usage) - exoPlayer?.setAudioAttributes(b.build(), true) - } - - @Throws(IllegalArgumentException::class, IllegalStateException::class) - private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { - Logd(TAG, "setDataSource: $mediaUrl") - mediaItem = MediaItem.Builder().setUri(Uri.parse(mediaUrl)).setMediaMetadata(metadata).build() - mediaSource = null - setSourceCredentials(user, password) - } - - @Throws(IllegalArgumentException::class, IllegalStateException::class) - private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { - Logd(TAG, "setDataSource1 called") - val url = media.getStreamUrl() ?: return - val preferences = media.episodeOrFetch()?.feed?.preferences - val user = preferences?.username - val password = preferences?.password - if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) { - Logd(TAG, "setDataSource1 setting for YouTube source") - try { -// val vService = Vista.getService(0) - val streamInfo = 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 = ArrayList() - mediaSources.add(vSource) - mediaSources.add(aSource) - mediaSource = MergingMediaSource(true, *mediaSources.toTypedArray()) -// mediaSource = null - } else mediaSource = aSource - mediaItem = mediaSource?.mediaItem - setSourceCredentials(user, password) - } catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") } - } else { - Logd(TAG, "setDataSource1 setting for Podcast source") - setDataSource(metadata, url,user, password) - } - } - - private fun setSourceCredentials(user: String?, password: String?) { - if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) { - if (httpDataSourceFactory == null) - httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory) - .setUserAgent(ClientConfig.USER_AGENT) - - val requestProperties = HashMap() - requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1") - httpDataSourceFactory!!.setDefaultRequestProperties(requestProperties) - - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpDataSourceFactory!!) - val extractorsFactory = DefaultExtractorsFactory() - extractorsFactory.setConstantBitrateSeekingEnabled(true) - extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA) - val f = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) - - mediaSource = f.createMediaSource(mediaItem!!) - } - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param defaultFormat format to give preference - * @param showHigherResolutions show >1080p resolutions - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - private fun getSortedStreamVideosList(videoStreams: List?, videoOnlyStreams: List?, ascendingOrder: Boolean, - preferVideoOnlyStreams: Boolean): List { - val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams) - val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList() - val comparator = compareBy { it.resolution.toResolutionValue() } - return if (ascendingOrder) allInitialStreams.sortedWith(comparator) else { allInitialStreams.sortedWith(comparator.reversed()) } - } - - private fun String.toResolutionValue(): Int { - val match = Regex("(\\d+)p|(\\d+)k").find(this) - return when { - match?.groupValues?.get(1) != null -> match.groupValues[1].toInt() - match?.groupValues?.get(2) != null -> match.groupValues[2].toInt() * 1024 - else -> 0 - } - } - - private fun getFilteredAudioStreams(audioStreams: List?): List { - if (audioStreams == null) return listOf() - val collectedStreams = mutableSetOf() - for (stream in audioStreams) { - Logd(TAG, "getFilteredAudioStreams stream: ${stream.audioTrackId} ${stream.bitrate} ${stream.deliveryMethod} ${stream.format}") - if (stream.deliveryMethod == DeliveryMethod.TORRENT || (stream.deliveryMethod == DeliveryMethod.HLS && stream.format == MediaFormat.OPUS)) - continue - collectedStreams.add(stream) - } - return collectedStreams.toList().sortedWith(compareBy { it.bitrate }) - } - - /** - * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing - * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will - * not do anything. - * Whether playback starts immediately depends on the given parameters. See below for more details. - * States: - * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. - * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If - * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. - * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object - * will enter the ERROR state. - * This method is executed on an internal executor service. - * @param playable The Playable object that is supposed to be played. This parameter must not be null. - * @param streaming The type of playback. If false, the Playable object MUST provide access to a locally available file via - * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by - * the Android MediaPlayer via getStreamUrl. - * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the - * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared - * for playback immediately (see 'prepareImmediately' parameter for more details) - * @param prepareImmediately Set to true if the method should also prepare the episode for playback. - */ - override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { - Logd(TAG, "playMediaObject status=$status stream=$streaming startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ") -// showStackTrace() - if (curMedia != null) { - Logd(TAG, "playMediaObject: curMedia exist status=$status") - if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) { - Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.") - return - } - Logd(TAG, "playMediaObject starts new playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}") - // set temporarily to pause in order to update list with current position - if (status == PlayerStatus.PLAYING) { - val pos = curMedia?.getPosition() ?: -1 - seekTo(pos) - callback.onPlaybackPause(curMedia, pos) - } - // stop playback of this episode - if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop() -// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) -// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true) - setPlayerStatus(PlayerStatus.INDETERMINATE, null) - } - - Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}") - curMedia = playable - if (curMedia is EpisodeMedia) { - val media_ = curMedia as EpisodeMedia - val item = media_.episodeOrFetch() - val eList = if (item?.feed?.preferences?.queue != null) curQueue.episodes else item?.feed?.getVirtualQueueItems() ?: listOf() - curIndexInQueue = EpisodeUtil.indexOfItemWithId(eList, media_.id) - } else curIndexInQueue = -1 - - prevMedia = curMedia - this.isStreaming = streaming - mediaType = curMedia!!.getMediaType() - videoSize = null - createMediaPlayer() - this.startWhenPrepared.set(startWhenPrepared) - setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) - val metadata = buildMetadata(curMedia!!) - try { - callback.ensureMediaInfoLoaded(curMedia!!) - callback.onMediaChanged(false) - setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) - CoroutineScope(Dispatchers.IO).launch { - when { - streaming -> { - val streamurl = curMedia!!.getStreamUrl() - if (streamurl != null) { - val media = curMedia - if (media is EpisodeMedia) { - mediaItem = null - mediaSource = null - setDataSource(metadata, media) -// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } -// if (startWhenPrepared) runBlocking { deferred.await() } -// val preferences = media.episodeOrFetch()?.feed?.preferences -// setDataSource(metadata, streamurl, preferences?.username, preferences?.password) - } else setDataSource(metadata, streamurl, null, null) - } - } - else -> { - val localMediaurl = curMedia!!.getLocalMediaUrl() -// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle -// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) - if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) - else throw IOException("Unable to read local file $localMediaurl") - } - } - withContext(Dispatchers.Main) { - val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) - if (prepareImmediately) prepare() - } - } - } catch (e: IOException) { - e.printStackTrace() - setPlayerStatus(PlayerStatus.ERROR, null) - EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) - } catch (e: IllegalStateException) { - e.printStackTrace() - setPlayerStatus(PlayerStatus.ERROR, null) - EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) - } finally { } - } - - override fun resume() { - 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? { - if (status != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(videoWidth, videoHeight) - return videoSize - } - - override fun getAudioTracks(): List { - val trackNames: MutableList = ArrayList() - val trackNameProvider: TrackNameProvider = DefaultTrackNameProvider(context.resources) - for (format in formats) { - trackNames.add(trackNameProvider.getTrackName(format)) - } - return trackNames - } - - override fun setAudioTrack(track: Int) { - val trackInfo = trackSelector!!.currentMappedTrackInfo ?: return - val trackGroups = trackInfo.getTrackGroups(audioRendererIndex) - val override = SelectionOverride(track, 0) - val rendererIndex = audioRendererIndex - val params = trackSelector!!.buildUponParameters().setSelectionOverride(rendererIndex, trackGroups, override) - trackSelector!!.setParameters(params) - } - - override fun getSelectedAudioTrack(): Int { - val trackSelections = exoPlayer!!.currentTrackSelections - val availableFormats = formats - Logd(TAG, "selectedAudioTrack called tracks: ${trackSelections.length} formats: ${availableFormats.size}") - for (i in 0 until trackSelections.length) { - val track = trackSelections[i] as? ExoTrackSelection ?: continue - if (availableFormats.contains(track.selectedFormat)) return availableFormats.indexOf(track.selectedFormat) - } - return -1 - } - - override fun createMediaPlayer() { - Logd(TAG, "createMediaPlayer()") - release() - if (curMedia == null) { - status = PlayerStatus.STOPPED - return - } - setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH) - setMediaPlayerListeners() - } - - override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) { - releaseWifiLockIfNecessary() - if (curMedia == null) return - - val isPlaying = status == PlayerStatus.PLAYING - // we're relying on the position stored in the Playable object for post-playback processing - val position = getPosition() - if (position >= 0) curMedia?.setPosition(position) - Logd(TAG, "endPlayback hasEnded=$hasEnded wasSkipped=$wasSkipped shouldContinue=$shouldContinue toStoppedState=$toStoppedState") -// 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? = null - private var bufferingUpdateListener: Consumer? = null - private var loudnessEnhancer: LoudnessEnhancer? = null - - fun createStaticPlayer(context: Context) { - val loadControl = DefaultLoadControl.Builder() - loadControl.setBufferDurationsMs(30000, 120000, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) - loadControl.setBackBuffer(rewindSecs * 1000 + 500, true) - trackSelector = DefaultTrackSelector(context) - val audioOffloadPreferences = AudioOffloadPreferences.Builder() - .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) // Add additional options as needed - .setIsGaplessSupportRequired(true) - .setIsSpeedChangeSupportRequired(true) - .build() - Logd(TAG, "createStaticPlayer creating exoPlayer_") - - val defaultRenderersFactory = DefaultRenderersFactory(context) -// defaultRenderersFactory.setMediaCodecSelector { mimeType: String?, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean -> -// val decoderInfos: List = MediaCodecUtil.getDecoderInfos(mimeType!!, requiresSecureDecoder, requiresTunnelingDecoder) -// val result: MutableList = ArrayList() -// for (decoderInfo in decoderInfos) { -// Logd(TAG, "decoderInfo.name: ${decoderInfo.name}") -// if (decoderInfo.name == "c2.android.mp3.decoder") { -// continue -// } -// result.add(decoderInfo) -// } -// result -// } - exoPlayer = ExoPlayer.Builder(context, defaultRenderersFactory) - .setTrackSelector(trackSelector!!) - .setLoadControl(loadControl.build()) - .build() - - exoPlayer?.setSeekParameters(SeekParameters.EXACT) - exoPlayer!!.trackSelectionParameters = exoPlayer!!.trackSelectionParameters - .buildUpon() - .setAudioOffloadPreferences(audioOffloadPreferences) - .build() - -// if (BuildConfig.DEBUG) exoPlayer!!.addAnalyticsListener(EventLogger()) - - if (exoplayerListener != null) { - exoPlayer?.removeListener(exoplayerListener!!) - exoPlayer?.addListener(exoplayerListener!!) - } - initLoudnessEnhancer(exoPlayer!!.audioSessionId) - } - - private fun initLoudnessEnhancer(audioStreamId: Int) { - runOnIOScope { - val newEnhancer = LoudnessEnhancer(audioStreamId) - val oldEnhancer = loudnessEnhancer - if (oldEnhancer != null) { - newEnhancer.setEnabled(oldEnhancer.enabled) - if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt()) - oldEnhancer.release() - } - loudnessEnhancer = newEnhancer - } - } - - fun cleanup() { - if (exoplayerListener != null) exoPlayer?.removeListener(exoplayerListener!!) - exoplayerListener = null - audioSeekCompleteListener = null - audioCompletionListener = null - audioErrorListener = null - bufferingUpdateListener = null - loudnessEnhancer = null - httpDataSourceFactory = null - } - } - } - /** * Manages the background tasks of PlaybackSerivce, i.e. * the sleep timer, the position saver, the widget updater and the queue loader. diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index cd42ac02..23cfa5c8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -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) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index 3b54f32b..a4edfe1e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -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 diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt similarity index 85% rename from app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt rename to app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt index 262bbd07..c69aeb33 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastPsmp.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt @@ -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 } } diff --git a/changelog.md b/changelog.md index 751a9244..bebf4dd2 100644 --- a/changelog.md +++ b/changelog.md @@ -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