From ceabe0ece3101d000cfc17887bea1ef2ca36eb1c Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:12:19 +0100 Subject: [PATCH] 6.4.0 commit --- app/build.gradle | 18 +- .../ac/test/podcini/playback/PlaybackTest.kt | 6 +- app/src/main/AndroidManifest.xml | 2 +- .../podcini/playback/PlaybackController.kt | 394 ------------------ .../podcini/playback/ServiceStatusHandler.kt | 220 ++++++++++ .../podcini/playback/base/MediaPlayerBase.kt | 29 +- .../playback/service/LocalMediaPlayer.kt | 12 +- .../playback/service/PlaybackService.kt | 372 ++++++++++++----- .../actions/actionbutton/PlayActionButton.kt | 6 +- .../actionbutton/PlayLocalActionButton.kt | 4 +- .../actionbutton/StreamActionButton.kt | 2 +- .../mdiq/podcini/ui/activity/MainActivity.kt | 27 +- .../ui/activity/VideoplayerActivity.kt | 25 +- .../podcini/ui/dialog/SleepTimerDialog.kt | 7 +- .../podcini/ui/dialog/VariableSpeedDialog.kt | 6 +- .../ui/fragment/AudioPlayerFragment.kt | 96 +++-- .../podcini/ui/fragment/ChaptersFragment.kt | 15 +- .../ui/fragment/EpisodeInfoFragment.kt | 2 +- .../ui/fragment/PlayerDetailsFragment.kt | 10 +- .../podcini/ui/fragment/QueuesFragment.kt | 38 +- .../ui/fragment/VideoEpisodeFragment.kt | 49 +-- build.gradle | 2 +- changelog.md | 7 + .../android/en-US/changelogs/3020231.txt | 10 +- .../android/en-US/changelogs/3020232.txt | 6 + gradle/libs.versions.toml | 2 +- 26 files changed, 720 insertions(+), 647 deletions(-) delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/3020232.txt diff --git a/app/build.gradle b/app/build.gradle index 2f828647..d5fb2968 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 3020231 - versionName "6.3.7" + versionCode 3020232 + versionName "6.4.0" applicationId "ac.mdiq.podcini.R" def commit = "" @@ -125,6 +125,15 @@ android { } } + splits { + abi { + enable true + reset() + include "arm64-v8a" // Specify the ABI you want to split. + universalApk true // This will generate a universal APK that includes all ABIs. + } + } + buildTypes { release { proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard.cfg" @@ -156,7 +165,7 @@ android { } dependencies { - def composeBom = platform('androidx.compose:compose-bom:2024.06.00') + def composeBom = platform('androidx.compose:compose-bom:2024.08.00') implementation composeBom androidTestImplementation composeBom @@ -215,6 +224,7 @@ dependencies { // implementation "io.reactivex.rxjava2:rxandroid:2.1.1" implementation "io.reactivex.rxjava2:rxjava:2.2.21" +// 5.5.0-b01 is newer than 5.5.0-compose01 implementation 'com.mikepenz:iconics-core:5.5.0-b01' implementation 'com.mikepenz:iconics-views:5.5.0-b01' implementation 'com.mikepenz:google-material-typeface:4.0.0.3-kotlin@aar' @@ -243,7 +253,7 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" androidTestImplementation "androidx.test.espresso:espresso-contrib:3.6.1" androidTestImplementation "androidx.test.espresso:espresso-intents:3.6.1" - androidTestImplementation "androidx.test:runner:1.6.1" + androidTestImplementation "androidx.test:runner:1.6.2" androidTestImplementation "androidx.test:rules:1.6.1" androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'org.awaitility:awaitility:4.2.1' diff --git a/app/src/androidTest/kotlin/ac/test/podcini/playback/PlaybackTest.kt b/app/src/androidTest/kotlin/ac/test/podcini/playback/PlaybackTest.kt index 244936a9..afe00e9b 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/playback/PlaybackTest.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/playback/PlaybackTest.kt @@ -1,7 +1,7 @@ package de.test.podcini.playback import ac.mdiq.podcini.R -import ac.mdiq.podcini.playback.PlaybackController +import ac.mdiq.podcini.playback.ServiceStatusHandler import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.PlayerStatus @@ -46,7 +46,7 @@ class PlaybackTest { private var uiTestUtils: UITestUtils? = null protected lateinit var context: Context - private var controller: PlaybackController? = null + private var controller: ServiceStatusHandler? = null @Before @Throws(Exception::class) @@ -71,7 +71,7 @@ class PlaybackTest { } private fun setupPlaybackController() { - controller = object : PlaybackController(activityTestRule.activity) { + controller = object : ServiceStatusHandler(activityTestRule.activity) { override fun loadMediaInfo() { // Do nothing } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e2488ccf..dd65545c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,7 +56,7 @@ tools:ignore="ExportedService"> - + diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt deleted file mode 100644 index 7e0ad7ec..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/PlaybackController.kt +++ /dev/null @@ -1,394 +0,0 @@ -package ac.mdiq.podcini.playback - -import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.base.InTheatre.curState -import ac.mdiq.podcini.playback.base.MediaPlayerBase -import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed -import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo -import ac.mdiq.podcini.playback.base.PlayerStatus -import ac.mdiq.podcini.playback.service.PlaybackService -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isCasting -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isRunning -import ac.mdiq.podcini.playback.service.PlaybackService.LocalBinder -import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence -import ac.mdiq.podcini.storage.model.Playable -import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter -import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.event.EventFlow -import ac.mdiq.podcini.util.event.FlowEvent -import android.content.* -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.util.UnstableApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -/** - * Communicates with the playback service. GUI classes should use this class to - * control playback instead of communicating with the PlaybackService directly. - */ -@UnstableApi -abstract class PlaybackController(private val activity: FragmentActivity) { - - private var mediaInfoLoaded = false - private var loadedFeedMediaId: Long = -1 - private var released = false - private var initialized = false - private var eventsRegistered = false - - private val mConnection: ServiceConnection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - if (service is LocalBinder) { - playbackService = service.service - onPlaybackServiceConnected() - if (!released) { - queryService() - Logd(TAG, "Connection to Service established") - } else Logd(TAG, "Connection to playback service has been established, but controller has already been released") - } - } - - override fun onServiceDisconnected(name: ComponentName) { - playbackService = null - initialized = false - Logd(TAG, "Disconnected from Service") - } - } - - private var prevStatus = PlayerStatus.STOPPED - private val statusUpdate: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "statusUpdate onReceive called with action: ${intent.action}") - if (playbackService != null && mPlayerInfo != null) { - val info = mPlayerInfo!! - Logd(TAG, "statusUpdate onReceive $prevStatus ${MediaPlayerBase.status} ${info.playerStatus} ${curMedia?.getIdentifier()} ${info.playable?.getIdentifier()}.") - if (prevStatus != info.playerStatus || curMedia == null || curMedia!!.getIdentifier() != info.playable?.getIdentifier()) { - MediaPlayerBase.status = info.playerStatus - prevStatus = MediaPlayerBase.status - curMedia = info.playable - handleStatus() - } - } else { - Logd(TAG, "statusUpdate onReceive: Couldn't receive status update: playbackService was null") - if (isRunning) bindToService() - else { - MediaPlayerBase.status = PlayerStatus.STOPPED - handleStatus() - } - } - } - } - - private val notificationReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "notificationReceiver onReceive called with action: ${intent.action}") - val type = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_TYPE, -1) - val code = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_CODE, -1) - if (code == -1 || type == -1) { - Logd(TAG, "Bad arguments. Won't handle intent") - return - } - when (type) { - PlaybackService.NOTIFICATION_TYPE_RELOAD -> { - if (playbackService == null && isRunning) { - bindToService() - return - } - mediaInfoLoaded = false - queryService() - } - PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END -> onPlaybackEnd() - } - } - } - - @Synchronized - fun init() { - Logd(TAG, "controller init") - if (!eventsRegistered) { - procFlowEvents() - eventsRegistered = true - } - if (isRunning) initServiceRunning() - else updatePlayButtonShowsPlay(true) - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = activity.lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.PlaybackServiceEvent -> if (event.action == FlowEvent.PlaybackServiceEvent.Action.SERVICE_STARTED) init() - else -> {} - } - } - } - } - - @Synchronized - private fun initServiceRunning() { - if (initialized) return - initialized = true - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity.registerReceiver(statusUpdate, IntentFilter(PlaybackService.ACTION_PLAYER_STATUS_CHANGED), Context.RECEIVER_NOT_EXPORTED) - activity.registerReceiver(notificationReceiver, IntentFilter(PlaybackService.ACTION_PLAYER_NOTIFICATION), Context.RECEIVER_NOT_EXPORTED) - } else { - activity.registerReceiver(statusUpdate, IntentFilter(PlaybackService.ACTION_PLAYER_STATUS_CHANGED)) - activity.registerReceiver(notificationReceiver, IntentFilter(PlaybackService.ACTION_PLAYER_NOTIFICATION)) - } - -// TODO: java.lang.IllegalStateException: Can't call init() after release() has been called -// at ac.mdiq.podcini.playback.PlaybackController.initServiceRunning(SourceFile:104) - if (!released) { - bindToService() - } else { - released = false - bindToService() - Logd(TAG, "Testing bindToService if released") -// throw IllegalStateException("Can't call init() after release() has been called") - } - checkMediaInfoLoaded() - } - - /** - * Should be called if the PlaybackController is no longer needed, for - * example in the activity's onStop() method. - */ - fun release() { - Logd(TAG, "Releasing PlaybackController") - try { - activity.unregisterReceiver(statusUpdate) - activity.unregisterReceiver(notificationReceiver) - } catch (e: IllegalArgumentException) { - // ignore - } - unbind() - cancelFlowEvents() - released = true - eventsRegistered = false - } - - private fun unbind() { - try { activity.unbindService(mConnection) } catch (e: IllegalArgumentException) { } - initialized = false - } - - /** - * Should be called in the activity's onPause() method. - */ - fun pause() { -// TODO: why set it to false -// mediaInfoLoaded = false - Logd(TAG, "pause() does nothing") - } - - /** - * Tries to establish a connection to the PlaybackService. If it isn't - * running, the PlaybackService will be started with the last played media - * as the arguments of the launch intent. - */ - private fun bindToService() { - Logd(TAG, "Trying to connect to service") - check(isRunning) { "Trying to bind but service is not running" } - val bound = activity.bindService(Intent(activity, PlaybackService::class.java), mConnection, 0) - Logd(TAG, "Result for service binding: $bound") - } - - open fun onPlaybackEnd() {} - - /** - * Is called whenever the PlaybackService changes its status. This method - * should be used to update the GUI or start/cancel background threads. - */ - private fun handleStatus() { - Log.d(TAG, "handleStatus() called status: ${MediaPlayerBase.status}") - checkMediaInfoLoaded() - - when (MediaPlayerBase.status) { - PlayerStatus.PLAYING -> updatePlayButtonShowsPlay(false) - PlayerStatus.PREPARING -> updatePlayButtonShowsPlay(!isStartWhenPrepared) - PlayerStatus.FALLBACK, PlayerStatus.PAUSED, PlayerStatus.PREPARED, PlayerStatus.STOPPED, PlayerStatus.INITIALIZED -> - updatePlayButtonShowsPlay(true) - else -> {} - } - } - - private fun checkMediaInfoLoaded() { - if (!mediaInfoLoaded || loadedFeedMediaId != curState.curMediaId) { - loadedFeedMediaId = curState.curMediaId - Logd(TAG, "checkMediaInfoLoaded: $loadedFeedMediaId") - loadMediaInfo() - } - mediaInfoLoaded = true - } - - protected open fun updatePlayButtonShowsPlay(showPlay: Boolean) {} - - abstract fun loadMediaInfo() - - open fun onPlaybackServiceConnected() { } - - /** - * Called when connection to playback service has been established or - * information has to be refreshed - */ - private fun queryService() { - Logd(TAG, "Querying service info") - if (playbackService != null && mPlayerInfo != null) { - MediaPlayerBase.status = mPlayerInfo!!.playerStatus - curMedia = mPlayerInfo!!.playable - // make sure that new media is loaded if it's available - mediaInfoLoaded = false - handleStatus() - } else { - Log.e(TAG, "queryService() was called without an existing connection to playbackservice") - } - } - - fun ensureService() { - if (curMedia == null) return - if (playbackService == null) { - PlaybackServiceStarter(activity, curMedia!!).start() -// Log.w(TAG, "playbackservice was null, restarted!") - } - } - - fun playPause() { - if (curMedia == null) return - if (playbackService == null) { - PlaybackServiceStarter(activity, curMedia!!).start() - Logd(TAG, "playbackservice was null, restarted!") - return - } - when (MediaPlayerBase.status) { - PlayerStatus.FALLBACK -> fallbackSpeed(1.0f) - PlayerStatus.PLAYING -> { - playbackService?.mPlayer?.pause(true, reinit = false) - playbackService?.isSpeedForward = false - playbackService?.isFallbackSpeed = false -// if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!, FlowEvent.PlayEvent.Action.END)) - } - PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { - playbackService?.mPlayer?.resume() - playbackService?.taskManager?.restartSleepTimer() -// if (curEpisode != null) EventFlow.postEvent(FlowEvent.PlayEvent(curEpisode!!)) - } - PlayerStatus.PREPARING -> isStartWhenPrepared = !isStartWhenPrepared - PlayerStatus.INITIALIZED -> { - if (playbackService != null) isStartWhenPrepared = true - playbackService?.mPlayer?.prepare() - playbackService?.taskManager?.restartSleepTimer() - } - else -> { - PlaybackServiceStarter(activity, curMedia!!).callEvenIfRunning(true).start() - Log.w(TAG, "Play/Pause button was pressed and PlaybackService state was unknown") - } - } - } - - companion object { - private val TAG: String = PlaybackController::class.simpleName ?: "Anonymous" - - var playbackService: PlaybackService? = null - - val curPosition: Int - get() = playbackService?.curPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME - - val duration: Int - get() = playbackService?.curDuration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME - - val curSpeedMultiplier: Float - get() = playbackService?.curSpeed ?: getCurrentPlaybackSpeed(curMedia) - - val isPlayingVideoLocally: Boolean - get() = when { - isCasting -> false - playbackService != null -> currentMediaType == MediaType.VIDEO - else -> curMedia?.getMediaType() == MediaType.VIDEO - } - - private var isStartWhenPrepared: Boolean - get() = playbackService?.mPlayer?.startWhenPrepared?.get() ?: false - set(s) { - playbackService?.mPlayer?.startWhenPrepared?.set(s) - } - - private val mPlayerInfo: MediaPlayerInfo? - get() = playbackService?.mPlayer?.playerInfo - - fun seekTo(time: Int) { - if (playbackService != null) { - playbackService!!.mPlayer?.seekTo(time) -// if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, time, duration)) - } - } - - fun fallbackSpeed(speed: Float) { - if (playbackService != null) { - when (MediaPlayerBase.status) { - PlayerStatus.PLAYING -> { - MediaPlayerBase.status = PlayerStatus.FALLBACK - setToFallback(speed) - } - PlayerStatus.FALLBACK -> { - MediaPlayerBase.status = PlayerStatus.PLAYING - setToFallback(speed) - } - else -> {} - } - } - } - - private fun setToFallback(speed: Float) { - if (playbackService?.mPlayer == null || playbackService!!.isSpeedForward) return - - if (!playbackService!!.isFallbackSpeed) { - playbackService!!.normalSpeed = playbackService!!.mPlayer!!.getPlaybackSpeed() - playbackService!!.mPlayer!!.setPlaybackParams(speed, isSkipSilence) - } else playbackService!!.mPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) - - playbackService!!.isFallbackSpeed = !playbackService!!.isFallbackSpeed - } - - fun sleepTimerActive(): Boolean { - return playbackService?.taskManager?.isSleepTimerActive ?: false - } - - /** - * Returns an intent which starts an audio- or videoplayer, depending on the - * type of media that is being played. If the playbackservice is not - * running, the type of the last played media will be looked up. - */ - @JvmStatic - fun getPlayerActivityIntent(context: Context): Intent { - val showVideoPlayer = if (isRunning) currentMediaType == MediaType.VIDEO && !isCasting - else curState.curIsVideo - return if (showVideoPlayer) VideoPlayerActivityStarter(context).intent - else MainActivityStarter(context).withOpenPlayer().getIntent() - } - - /** - * Same as [.getPlayerActivityIntent], but here the type of activity - * depends on the medaitype that is provided as an argument. - */ - @JvmStatic - fun getPlayerActivityIntent(context: Context, mediaType: MediaType?): Intent { - return if (mediaType == MediaType.VIDEO && !isCasting) VideoPlayerActivityStarter(context).intent - else MainActivityStarter(context).withOpenPlayer().getIntent() - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt new file mode 100644 index 00000000..da59e16a --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt @@ -0,0 +1,220 @@ +package ac.mdiq.podcini.playback + +import ac.mdiq.podcini.playback.base.InTheatre.curMedia +import ac.mdiq.podcini.playback.base.InTheatre.curState +import ac.mdiq.podcini.playback.base.MediaPlayerBase +import ac.mdiq.podcini.playback.base.PlayerStatus +import ac.mdiq.podcini.playback.service.PlaybackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isCasting +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isRunning +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService +import ac.mdiq.podcini.storage.model.MediaType +import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter +import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.event.EventFlow +import ac.mdiq.podcini.util.event.FlowEvent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.util.Log +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Communicates with the playback service. GUI classes should use this class to + * control playback instead of communicating with the PlaybackService directly. + */ +@UnstableApi +abstract class ServiceStatusHandler(private val activity: FragmentActivity) { + + private var mediaInfoLoaded = false + private var loadedFeedMediaId: Long = -1 + private var initialized = false + + private var prevStatus = PlayerStatus.STOPPED + private val statusUpdate: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "statusUpdate onReceive called with action: ${intent.action}") + if (playbackService != null && PlaybackService.mPlayerInfo != null) { + val info = PlaybackService.mPlayerInfo!! +// Logd(TAG, "statusUpdate onReceive $prevStatus ${MediaPlayerBase.status} ${info.playerStatus} ${curMedia?.getIdentifier()} ${info.playable?.getIdentifier()}.") + if (prevStatus != info.playerStatus || curMedia == null || curMedia!!.getIdentifier() != info.playable?.getIdentifier()) { + Logd(TAG, "statusUpdate onReceive doing updates") + MediaPlayerBase.status = info.playerStatus + prevStatus = MediaPlayerBase.status + curMedia = info.playable + handleStatus() + } + } else { + Logd(TAG, "statusUpdate onReceive: Couldn't receive status update: playbackService was null") + if (!isRunning) { + MediaPlayerBase.status = PlayerStatus.STOPPED + handleStatus() + } + } + } + } + + private val notificationReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "notificationReceiver onReceive called with action: ${intent.action}") + val type = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_TYPE, -1) + val code = intent.getIntExtra(PlaybackService.EXTRA_NOTIFICATION_CODE, -1) + if (code == -1 || type == -1) { + Logd(TAG, "Bad arguments. Won't handle intent") + return + } + when (type) { + PlaybackService.NOTIFICATION_TYPE_RELOAD -> { + if (playbackService == null && isRunning) return + mediaInfoLoaded = false + updateStatus() + } + PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END -> onPlaybackEnd() + } + } + } + + @Synchronized + fun init() { + Logd(TAG, "controller init") + procFlowEvents() + if (isRunning) initServiceRunning() + else updatePlayButton(true) + } + + private var eventSink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + } + private fun procFlowEvents() { + if (eventSink != null) return + eventSink = activity.lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.PlaybackServiceEvent -> { + if (event.action == FlowEvent.PlaybackServiceEvent.Action.SERVICE_STARTED) { + init() + updateStatus() + } + } + else -> {} + } + } + } + } + + @Synchronized + private fun initServiceRunning() { + if (initialized) return + initialized = true + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.registerReceiver(statusUpdate, IntentFilter(PlaybackService.ACTION_PLAYER_STATUS_CHANGED), Context.RECEIVER_NOT_EXPORTED) + activity.registerReceiver(notificationReceiver, IntentFilter(PlaybackService.ACTION_PLAYER_NOTIFICATION), Context.RECEIVER_NOT_EXPORTED) + } else { + activity.registerReceiver(statusUpdate, IntentFilter(PlaybackService.ACTION_PLAYER_STATUS_CHANGED)) + activity.registerReceiver(notificationReceiver, IntentFilter(PlaybackService.ACTION_PLAYER_NOTIFICATION)) + } + checkMediaInfoLoaded() + } + + /** + * Should be called if the PlaybackController is no longer needed, for + * example in the activity's onStop() method. + */ + fun release() { + Logd(TAG, "Releasing PlaybackController") + try { + activity.unregisterReceiver(statusUpdate) + activity.unregisterReceiver(notificationReceiver) + } catch (e: IllegalArgumentException) { + // ignore + } + initialized = false + cancelFlowEvents() + } + + open fun onPlaybackEnd() {} + + /** + * Is called whenever the PlaybackService changes its status. This method + * should be used to update the GUI or start/cancel background threads. + */ + private fun handleStatus() { + Log.d(TAG, "handleStatus() called status: ${MediaPlayerBase.status}") + checkMediaInfoLoaded() + when (MediaPlayerBase.status) { + PlayerStatus.PLAYING -> updatePlayButton(false) + PlayerStatus.PREPARING -> updatePlayButton(!PlaybackService.isStartWhenPrepared) + PlayerStatus.FALLBACK, PlayerStatus.PAUSED, PlayerStatus.PREPARED, PlayerStatus.STOPPED, PlayerStatus.INITIALIZED -> + updatePlayButton(true) + else -> {} + } + } + + private fun checkMediaInfoLoaded() { + if (!mediaInfoLoaded || loadedFeedMediaId != curState.curMediaId) { + loadedFeedMediaId = curState.curMediaId + Logd(TAG, "checkMediaInfoLoaded: $loadedFeedMediaId") + loadMediaInfo() + } + mediaInfoLoaded = true + } + + protected open fun updatePlayButton(showPlay: Boolean) {} + + abstract fun loadMediaInfo() + + /** + * Called when connection to playback service has been established or + * information has to be refreshed + */ + private fun updateStatus() { + Logd(TAG, "Querying service info") + if (playbackService != null && PlaybackService.mPlayerInfo != null) { + MediaPlayerBase.status = PlaybackService.mPlayerInfo!!.playerStatus + curMedia = PlaybackService.mPlayerInfo!!.playable + // make sure that new media is loaded if it's available + mediaInfoLoaded = false + handleStatus() + } else Log.e(TAG, "queryService() was called without an existing connection to playbackservice") + } + + companion object { + private val TAG: String = ServiceStatusHandler::class.simpleName ?: "Anonymous" + + /** + * Returns an intent which starts an audio- or videoplayer, depending on the + * type of media that is being played. If the playbackservice is not + * running, the type of the last played media will be looked up. + */ + @JvmStatic + fun getPlayerActivityIntent(context: Context): Intent { + val showVideoPlayer = if (isRunning) currentMediaType == MediaType.VIDEO && !isCasting + else curState.curIsVideo + return if (showVideoPlayer) VideoPlayerActivityStarter(context).intent + else MainActivityStarter(context).withOpenPlayer().getIntent() + } + + /** + * Same as [.getPlayerActivityIntent], but here the type of activity + * depends on the medaitype that is provided as an argument. + */ + @JvmStatic + fun getPlayerActivityIntent(context: Context, mediaType: MediaType?): Intent { + return if (mediaType == MediaType.VIDEO && !isCasting) VideoPlayerActivityStarter(context).intent + else MainActivityStarter(context).withOpenPlayer().getIntent() + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt index 5c6d466d..05290a47 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/base/MediaPlayerBase.kt @@ -3,22 +3,23 @@ package ac.mdiq.podcini.playback.base import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.preferences.UserPreferences.Prefs.prefPlaybackSpeed import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.setPlaybackSpeed import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.FeedPreferences -import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.storage.model.Playable import android.content.Context import android.media.AudioManager +import android.net.Uri import android.net.wifi.WifiManager import android.net.wifi.WifiManager.WifiLock import android.util.Log import android.util.Pair import android.view.SurfaceHolder +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.Volatile @@ -316,6 +317,28 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont } } + fun buildMetadata(p: Playable): MediaMetadata { + val builder = MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(true) + .setArtist(p.getFeedTitle()) + .setTitle(p.getEpisodeTitle()) + .setAlbumArtist(p.getFeedTitle()) + .setDisplayTitle(p.getEpisodeTitle()) + .setSubtitle(p.getFeedTitle()) + .setArtworkUri(null) + return builder.build() + } + + fun buildMediaItem(p: Playable): MediaItem? { + val url = p.getStreamUrl() ?: return null + val metadata = buildMetadata(p) + return MediaItem.Builder() + .setMediaId(url) + .setUri(Uri.parse(url)) + .setMediaMetadata(metadata).build() + } + /** * @param currentPosition current position in a media file in ms * @param lastPlayedTime timestamp when was media paused diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt index 59110b11..e23536dd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt @@ -166,17 +166,6 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP exoPlayer?.setAudioAttributes(b.build(), true) } - private fun buildMetadata(p: Playable): MediaMetadata { - val builder = MediaMetadata.Builder() - .setArtist(p.getFeedTitle()) - .setTitle(p.getEpisodeTitle()) - .setAlbumArtist(p.getFeedTitle()) - .setDisplayTitle(p.getEpisodeTitle()) - .setSubtitle(p.getFeedTitle()) - .setArtworkUri(null) - return builder.build() - } - @Throws(IllegalArgumentException::class, IllegalStateException::class) private fun setDataSource(metadata: MediaMetadata, mediaUrl: String, user: String?, password: String?) { Logd(TAG, "setDataSource: $mediaUrl") @@ -681,6 +670,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP private var httpDataSourceFactory: OkHttpDataSource.Factory? = null private var trackSelector: DefaultTrackSelector? = null + var exoPlayer: ExoPlayer? = null private var exoplayerListener: Listener? = 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 9bd8bfe8..7346a1c1 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 @@ -14,6 +14,8 @@ import ac.mdiq.podcini.playback.base.InTheatre.curState import ac.mdiq.podcini.playback.base.InTheatre.loadPlayableFromPreferences import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying import ac.mdiq.podcini.playback.base.MediaPlayerBase +import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.buildMediaItem +import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.MediaPlayerBase.MediaPlayerInfo import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus @@ -28,6 +30,7 @@ import ac.mdiq.podcini.preferences.SleepTimerPreferences.timerMillis 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.rewindSecs import ac.mdiq.podcini.receiver.MediaButtonReceiver import ac.mdiq.podcini.storage.database.Episodes.addToHistory @@ -74,6 +77,7 @@ import android.webkit.URLUtil import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat +import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player.STATE_ENDED import androidx.media3.common.Player.STATE_IDLE @@ -93,7 +97,9 @@ import kotlin.math.max * Controls the MediaPlayer that plays a EpisodeMedia-file */ @UnstableApi -class PlaybackService : MediaSessionService() { +class PlaybackService : MediaLibraryService() { + + private var mediaSession: MediaLibrarySession? = null internal var mPlayer: MediaPlayerBase? = null internal lateinit var taskManager: TaskManager @@ -110,7 +116,6 @@ class PlaybackService : MediaSessionService() { private var autoSkippedFeedMediaId: String? = null internal var normalSpeed = 1.0f - private var mediaSession: MediaSession? = null private val mBinder: IBinder = LocalBinder() private var clickCount = 0 @@ -475,20 +480,162 @@ class PlaybackService : MediaSessionService() { } } - inner class LocalBinder : Binder() { - val service: PlaybackService - get() = this@PlaybackService + val rootItem = MediaItem.Builder() + .setMediaId("CurQueue") + .setMediaMetadata( + MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .setTitle(curQueue.name) + .build()) + .build() + + val mediaItemsInQueue: MutableList by lazy { + val list = mutableListOf() + curQueue.episodes.forEach { + if (it.media != null) { + val item = buildMediaItem(it.media!!) + if (item != null) list += item + } + } + Logd(TAG, "mediaItemsInQueue: ${list.size}") + list } - override fun onUnbind(intent: Intent): Boolean { - Logd(TAG, "Received onUnbind event") - return super.onUnbind(intent) + val mediaLibrarySessionCK = object: MediaLibrarySession.Callback { + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + Logd(TAG, "in MyMediaSessionCallback onConnect") + when { + session.isMediaNotificationController(controller) -> { + Logd(TAG, "MyMediaSessionCallback onConnect isMediaNotificationController") + val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() + notificationCustomButtons.forEach { commandButton -> + Logd(TAG, "MyMediaSessionCallback onConnect commandButton ${commandButton.displayName}") + commandButton.sessionCommand?.let(sessionCommands::add) + } + return MediaSession.ConnectionResult.accept(sessionCommands.build(), playerCommands.build()) + } + session.isAutoCompanionController(controller) -> { + Logd(TAG, "MyMediaSessionCallback onConnect isAutoCompanionController") + val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() + notificationCustomButtons.forEach { commandButton -> + Logd(TAG, "MyMediaSessionCallback onConnect commandButton ${commandButton.displayName}") + commandButton.sessionCommand?.let(sessionCommands::add) + } + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands.build()) + .build() + } + else -> { + Logd(TAG, "MyMediaSessionCallback onConnect other controller: ${controller.toString()}") + return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() + } + } + } + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + super.onPostConnect(session, controller) + Logd(TAG, "MyMediaSessionCallback onPostConnect") + if (notificationCustomButtons.isNotEmpty()) { + mediaSession?.setCustomLayout(notificationCustomButtons) +// mediaSession?.setCustomLayout(customMediaNotificationProvider.notificationMediaButtons) + } + } + override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { + Log.d(TAG, "MyMediaSessionCallback onCustomCommand ${customCommand.customAction}") + /* Handling custom command buttons from player notification. */ + when (customCommand.customAction) { + NotificationCustomButton.REWIND.customAction -> mPlayer?.seekDelta(-rewindSecs * 1000) + NotificationCustomButton.FORWARD.customAction -> mPlayer?.seekDelta(fastForwardSecs * 1000) + NotificationCustomButton.SKIP.customAction -> mPlayer?.skip() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo): ListenableFuture { + Logd(TAG, "MyMediaSessionCallback onPlaybackResumption ") + val settable = SettableFuture.create() +// scope.launch { +// // Your app is responsible for storing the playlist and the start position +// // to use here +// val resumptionPlaylist = restorePlaylist() +// settable.set(resumptionPlaylist) +// } + return settable + } + override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) { + Logd(TAG, "in MyMediaSessionCallback onDisconnected") + when { + session.isMediaNotificationController(controller) -> { + Logd(TAG, "MyMediaSessionCallback onDisconnected isMediaNotificationController") + } + session.isAutoCompanionController(controller) -> { + Logd(TAG, "MyMediaSessionCallback onDisconnected isAutoCompanionController") + } + } + } + override fun onMediaButtonEvent(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, intent: Intent): Boolean { + val keyEvent = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent.extras!!.getParcelable(EXTRA_KEY_EVENT, KeyEvent::class.java) + else intent.extras!!.getParcelable(EXTRA_KEY_EVENT) as? KeyEvent + Log.d(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}") + + if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { + val keyCode = keyEvent.keyCode + if (keyCode == KeyEvent.KEYCODE_HEADSETHOOK || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) { + clickCount++ + clickHandler.removeCallbacksAndMessages(null) + clickHandler.postDelayed({ + when (clickCount) { + 1 -> handleKeycode(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false) + 2 -> mPlayer?.seekDelta(fastForwardSecs * 1000) + 3 -> mPlayer?.seekDelta(-rewindSecs * 1000) + } + clickCount = 0 + }, ViewConfiguration.getDoubleTapTimeout().toLong()) + return true + } else return handleKeycode(keyCode, false) + } + return false + } + override fun onGetItem(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String): ListenableFuture> { + Logd(TAG, "MyMediaSessionCallback onGetItem called mediaId:$mediaId") + return super.onGetItem(session, browser, mediaId) + } + override fun onGetLibraryRoot(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, params: LibraryParams?): ListenableFuture> { + Logd(TAG, "MyMediaSessionCallback onGetLibraryRoot called") + return Futures.immediateFuture(LibraryResult.ofItem(rootItem, params)) + } + override fun onGetChildren(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, page: Int, pageSize: Int, + params: LibraryParams?): ListenableFuture>> { + Logd(TAG, "MyMediaSessionCallback onGetChildren called parentId:$parentId page:$page pageSize:$pageSize") +// return super.onGetChildren(session, browser, parentId, page, pageSize, params) + return Futures.immediateFuture(LibraryResult.ofItemList(mediaItemsInQueue, params)) + } + override fun onSubscribe(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, + params: LibraryParams?): ListenableFuture> { + return Futures.immediateFuture(LibraryResult.ofVoid()) + } + override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList): ListenableFuture> { + Logd(TAG, "MyMediaSessionCallback onAddMediaItems called ${mediaItems.size} ${mediaItems[0]}") + /* This is the trickiest part, if you don't do this here, nothing will play */ + val episode = getEpisodeByGuidOrUrl(null, mediaItems.first().mediaId) ?: return Futures.immediateFuture(mutableListOf()) + val media = episode.media ?: return Futures.immediateFuture(mutableListOf()) + if (!InTheatre.isCurMedia(media)) { + PlaybackServiceStarter(applicationContext, media).callEvenIfRunning(true).start() + EventFlow.postEvent(FlowEvent.PlayEvent(episode)) + } + val updatedMediaItems = mediaItems.map { it.buildUpon().setUri(it.mediaId).build() }.toMutableList() +// updatedMediaItems += mediaItemsInQueue +// Logd(TAG, "MyMediaSessionCallback onAddMediaItems updatedMediaItems: ${updatedMediaItems.size} ") + return Futures.immediateFuture(updatedMediaItems) + } } override fun onCreate() { super.onCreate() Logd(TAG, "Service created.") isRunning = true + playbackService = this if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { registerReceiver(autoStateUpdated, IntentFilter("com.google.android.gms.car.media.STATUS"), RECEIVER_NOT_EXPORTED) @@ -523,10 +670,10 @@ class PlaybackService : MediaSessionService() { recreateMediaPlayer() if (LocalMediaPlayer.exoPlayer == null) LocalMediaPlayer.createStaticPlayer(applicationContext) val intent = packageManager.getLaunchIntentForPackage(packageName) - val pendingIntent = PendingIntent.getActivity(this, 0, intent, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) - mediaSession = MediaSession.Builder(applicationContext, LocalMediaPlayer.exoPlayer!!) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, if (Build.VERSION.SDK_INT >= 23) FLAG_IMMUTABLE else FLAG_UPDATE_CURRENT) + mediaSession = MediaLibrarySession.Builder(applicationContext, LocalMediaPlayer.exoPlayer!!, mediaLibrarySessionCK) + .setId(packageName) .setSessionActivity(pendingIntent) - .setCallback(MyMediaSessionCallback()) .setCustomLayout(notificationCustomButtons) .build() } @@ -561,6 +708,7 @@ class PlaybackService : MediaSessionService() { override fun onDestroy() { Logd(TAG, "Service is about to be destroyed") + playbackService = null isRunning = false currentMediaType = MediaType.UNKNOWN castStateListener.destroy() @@ -591,99 +739,19 @@ class PlaybackService : MediaSessionService() { return mediaSession?.player?.playbackState != STATE_IDLE && mediaSession?.player?.playbackState != STATE_ENDED } - private inner class MyMediaSessionCallback : MediaSession.Callback { - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { - Logd(TAG, "in MyMediaSessionCallback onConnect") - val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() -// .add(NotificationCustomButton.REWIND) -// .add(NotificationCustomButton.FORWARD) - when { - session.isMediaNotificationController(controller) -> { - Logd(TAG, "MyMediaSessionCallback onConnect isMediaNotificationController") - val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() - notificationCustomButtons.forEach { commandButton -> - Logd(TAG, "MyMediaSessionCallback onConnect commandButton ${commandButton.displayName}") - commandButton.sessionCommand?.let(sessionCommands::add) - } - return MediaSession.ConnectionResult.accept( - sessionCommands.build(), - playerCommands.build() - ) - } - session.isAutoCompanionController(controller) -> { - Logd(TAG, "MyMediaSessionCallback onConnect isAutoCompanionController") - return MediaSession.ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands.build()) - .build() - } - else -> { - Logd(TAG, "MyMediaSessionCallback onConnect other controller") - return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() - } - } - } - override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { - super.onPostConnect(session, controller) - Logd(TAG, "MyMediaSessionCallback onPostConnect") - if (notificationCustomButtons.isNotEmpty()) { - mediaSession?.setCustomLayout(notificationCustomButtons) -// mediaSession?.setCustomLayout(customMediaNotificationProvider.notificationMediaButtons) - } - } - override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { - Log.d(TAG, "onCustomCommand ${customCommand.customAction}") - /* Handling custom command buttons from player notification. */ - when (customCommand.customAction) { - NotificationCustomButton.REWIND.customAction -> mPlayer?.seekDelta(-rewindSecs * 1000) - NotificationCustomButton.FORWARD.customAction -> mPlayer?.seekDelta(fastForwardSecs * 1000) - NotificationCustomButton.SKIP.customAction -> mPlayer?.skip() - } - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo): ListenableFuture { - Logd(TAG, "MyMediaSessionCallback onPlaybackResumption ") - val settable = SettableFuture.create() -// scope.launch { -// // Your app is responsible for storing the playlist and the start position -// // to use here -// val resumptionPlaylist = restorePlaylist() -// settable.set(resumptionPlaylist) -// } - return settable - } - override fun onMediaButtonEvent(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, intent: Intent): Boolean { - val keyEvent = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent.extras!!.getParcelable(EXTRA_KEY_EVENT, KeyEvent::class.java) - else intent.extras!!.getParcelable(EXTRA_KEY_EVENT) as? KeyEvent - Log.d(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}") - - if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { - val keyCode = keyEvent.keyCode - if (keyCode == KeyEvent.KEYCODE_HEADSETHOOK || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) { - clickCount++ - clickHandler.removeCallbacksAndMessages(null) - clickHandler.postDelayed({ - when (clickCount) { - 1 -> handleKeycode(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false) - 2 -> mPlayer?.seekDelta(fastForwardSecs * 1000) - 3 -> mPlayer?.seekDelta(-rewindSecs * 1000) - } - clickCount = 0 - }, ViewConfiguration.getDoubleTapTimeout().toLong()) - return true - } else return handleKeycode(keyCode, false) - } - return false - } - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? { return mediaSession } - override fun onBind(intent: Intent?): IBinder? { - Logd(TAG, "Received onBind event") - return if (intent?.action != null && intent.action == SERVICE_INTERFACE) super.onBind(intent) else mBinder - } +// override fun onBind(intent: Intent?): IBinder? { +// Logd(TAG, "Received onBind event") +// return if (intent?.action != null && intent.action == SERVICE_INTERFACE) super.onBind(intent) else mBinder +// } + +// override fun onUnbind(intent: Intent): Boolean { +// Logd(TAG, "Received onUnbind event") +// return super.onUnbind(intent) +// } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1 @@ -718,6 +786,11 @@ class PlaybackService : MediaSessionService() { val handled = handleKeycode(keycode, !hardwareButton) return super.onStartCommand(intent, flags, startId) } + keyEvent != null && keyEvent.keyCode != -1 -> { + Logd(TAG, "onStartCommand Received button event: ${keyEvent.keyCode}") + val handled = handleKeycode(keyEvent.keyCode, !hardwareButton) + return super.onStartCommand(intent, flags, startId) + } playable != null -> { Logd(TAG, "onStartCommand status: $status") val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false @@ -964,6 +1037,7 @@ class PlaybackService : MediaSessionService() { private fun onQueueEvent(event: FlowEvent.QueueEvent) { if (event.action == FlowEvent.QueueEvent.Action.REMOVED) { Logd(TAG, "onQueueEvent: ending playback curEpisode ${curEpisode?.title}") + notifyCurQueueItemsChanged() for (e in event.episodes) { Logd(TAG, "onQueueEvent: ending playback event ${e.title}") if (e.id == curEpisode?.id) { @@ -974,6 +1048,12 @@ class PlaybackService : MediaSessionService() { } } + fun notifyCurQueueItemsChanged(range_: Int = -1) { + val range = if (range_ > 0) range_ else curQueue.size() + Logd(TAG, "notifyCurQueueItemsChanged curQueue: ${curQueue.id}") + mediaSession?.notifyChildrenChanged("CurQueue", range, null) + } + // private fun onVolumeAdaptionChanged(event: FlowEvent.VolumeAdaptionChangedEvent) { // if (mPlayer != null) updateVolumeIfNecessary(mPlayer!!, event.feedId, event.volumeAdaptionSetting) // } @@ -1130,6 +1210,11 @@ class PlaybackService : MediaSessionService() { } } + inner class LocalBinder : Binder() { + val service: PlaybackService + get() = this@PlaybackService + } + enum class NotificationCustomButton(val customAction: String, val commandButton: CommandButton) { SKIP( customAction = CUSTOM_COMMAND_SKIP_ACTION_ID, @@ -1211,6 +1296,9 @@ class PlaybackService : MediaSessionService() { private const val EXTRA_CODE_VIDEO: Int = 2 private const val EXTRA_CODE_CAST: Int = 3 + var playbackService: PlaybackService? = null + var mediaBrowser: MediaBrowser? = null + @JvmField var isRunning: Boolean = false @@ -1259,6 +1347,88 @@ class PlaybackService : MediaSessionService() { appPrefs.edit().putBoolean(UserPreferences.Prefs.prefFollowQueue.name, value).apply() } + val curPositionFB: Int + get() = playbackService?.curPosition ?: curMedia?.getPosition() ?: Playable.INVALID_TIME + + val curDurationFB: Int + get() = playbackService?.curDuration ?: curMedia?.getDuration() ?: Playable.INVALID_TIME + + val curSpeedFB: Float + get() = playbackService?.curSpeed ?: getCurrentPlaybackSpeed(curMedia) + + val isPlayingVideoLocally: Boolean + get() = when { + isCasting -> false + playbackService != null -> currentMediaType == MediaType.VIDEO + else -> curMedia?.getMediaType() == MediaType.VIDEO + } + + var isStartWhenPrepared: Boolean + get() = playbackService?.mPlayer?.startWhenPrepared?.get() ?: false + set(s) { + playbackService?.mPlayer?.startWhenPrepared?.set(s) + } + + val mPlayerInfo: MediaPlayerInfo? + get() = playbackService?.mPlayer?.playerInfo + + fun seekTo(time: Int) { + playbackService?.mPlayer?.seekTo(time) + } + + fun toggleFallbackSpeed(speed: Float) { + if (playbackService != null) { + when (MediaPlayerBase.status) { + PlayerStatus.PLAYING -> { + MediaPlayerBase.status = PlayerStatus.FALLBACK + setToFallbackSpeed(speed) + } + PlayerStatus.FALLBACK -> { + MediaPlayerBase.status = PlayerStatus.PLAYING + setToFallbackSpeed(speed) + } + else -> {} + } + } + } + + private fun setToFallbackSpeed(speed: Float) { + if (playbackService?.mPlayer == null || playbackService!!.isSpeedForward) return + + if (!playbackService!!.isFallbackSpeed) { + playbackService!!.normalSpeed = playbackService!!.mPlayer!!.getPlaybackSpeed() + playbackService!!.mPlayer!!.setPlaybackParams(speed, isSkipSilence) + } else playbackService!!.mPlayer!!.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) + + playbackService!!.isFallbackSpeed = !playbackService!!.isFallbackSpeed + } + + fun isSleepTimerActive(): Boolean { + return playbackService?.taskManager?.isSleepTimerActive ?: false + } + + fun playPause() { + when (MediaPlayerBase.status) { + PlayerStatus.FALLBACK -> toggleFallbackSpeed(1.0f) + PlayerStatus.PLAYING -> { + playbackService?.mPlayer?.pause(true, reinit = false) + playbackService?.isSpeedForward = false + playbackService?.isFallbackSpeed = false + } + PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { + playbackService?.mPlayer?.resume() + playbackService?.taskManager?.restartSleepTimer() + } + PlayerStatus.PREPARING -> isStartWhenPrepared = !isStartWhenPrepared + PlayerStatus.INITIALIZED -> { + if (playbackService != null) isStartWhenPrepared = true + playbackService?.mPlayer?.prepare() + playbackService?.taskManager?.restartSleepTimer() + } + else -> Log.w(TAG, "Play/Pause button was pressed and PlaybackService state was unknown") + } + } + fun updateVolumeIfNecessary(mediaPlayer: MediaPlayerBase, feedId: Long, volumeAdaptionSetting: VolumeAdaptionSetting) { val playable = curMedia if (playable is EpisodeMedia) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt index 8390e8a2..2cddac1d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt @@ -1,17 +1,15 @@ package ac.mdiq.podcini.ui.actions.actionbutton import ac.mdiq.podcini.R -import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed -import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService +import ac.mdiq.podcini.playback.ServiceStatusHandler.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.ui.actions.actionbutton.StreamActionButton.StreamingConfirmationDialog import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt index ecd7f91a..160ba26a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt @@ -1,10 +1,10 @@ package ac.mdiq.podcini.ui.actions.actionbutton import ac.mdiq.podcini.R -import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService +import ac.mdiq.podcini.playback.ServiceStatusHandler.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.util.Logd diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt index 79c2f9fd..6b126bda 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt @@ -10,7 +10,7 @@ import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.RemoteMedia import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed -import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent +import ac.mdiq.podcini.playback.ServiceStatusHandler.Companion.getPlayerActivityIntent import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import android.content.Context diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 36cf7e35..d8d85685 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -11,7 +11,6 @@ import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnceOrAsk import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.playback.cast.CastEnabledActivity -import ac.mdiq.podcini.playback.service.PlaybackService import ac.mdiq.podcini.preferences.ThemeSwitcher.getNoTitleTheme import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.backButtonOpensDrawer @@ -36,7 +35,6 @@ import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import android.Manifest import android.annotation.SuppressLint -import android.content.ComponentName import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -65,8 +63,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo import androidx.work.WorkManager @@ -75,8 +71,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import org.apache.commons.lang3.ArrayUtils @@ -96,7 +90,7 @@ class MainActivity : CastEnabledActivity() { private lateinit var navDrawerFragment: NavDrawerFragment private lateinit var audioPlayerFragment: AudioPlayerFragment private lateinit var audioPlayerView: View - private lateinit var controllerFuture: ListenableFuture +// private lateinit var controllerFuture: ListenableFuture private lateinit var navDrawer: View private lateinit var dummyView : View lateinit var bottomSheet: LockableBottomSheetBehavior<*> @@ -529,19 +523,20 @@ class MainActivity : CastEnabledActivity() { procFlowEvents() RatingDialog.init(this) - val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) - controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() - controllerFuture.addListener({ - // Call controllerFuture.get() to retrieve the MediaController. - // MediaController implements the Player interface, so it can be - // attached to the PlayerView UI component. -// playerView.setPlayer(controllerFuture.get()) - }, MoreExecutors.directExecutor()) +// val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) +// controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() +// controllerFuture.addListener({ +// // Call controllerFuture.get() to retrieve the MediaController. +// // MediaController implements the Player interface, so it can be +// // attached to the PlayerView UI component. +//// playerView.setPlayer(controllerFuture.get()) +// val player = controllerFuture.get() +// }, MoreExecutors.directExecutor()) } override fun onStop() { super.onStop() - MediaController.releaseFuture(controllerFuture) +// MediaController.releaseFuture(controllerFuture) cancelFlowEvents() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt index a761cba5..44bce2b0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt @@ -3,15 +3,15 @@ package ac.mdiq.podcini.ui.activity import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.AudioControlsBinding import ac.mdiq.podcini.databinding.VideoplayerActivityBinding -import ac.mdiq.podcini.playback.PlaybackController -import ac.mdiq.podcini.playback.PlaybackController.Companion.duration -import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService -import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo -import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive +import ac.mdiq.podcini.playback.ServiceStatusHandler +import ac.mdiq.podcini.playback.ServiceStatusHandler.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.cast.CastEnabledActivity +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curDurationFB import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isCasting +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isSleepTimerActive +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.storage.database.Episodes.setFavorite import ac.mdiq.podcini.storage.model.EpisodeMedia @@ -21,8 +21,6 @@ import ac.mdiq.podcini.ui.dialog.ShareDialog import ac.mdiq.podcini.ui.dialog.SleepTimerDialog import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog import ac.mdiq.podcini.ui.fragment.ChaptersFragment -import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment -import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion import ac.mdiq.podcini.ui.fragment.VideoEpisodeFragment import ac.mdiq.podcini.ui.utils.PictureInPictureUtil import ac.mdiq.podcini.util.IntentUtils.openInBrowser @@ -41,7 +39,6 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.Log import android.view.* import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.widget.EditText @@ -243,8 +240,8 @@ class VideoplayerActivity : CastEnabledActivity() { menu.findItem(R.id.remove_from_favorites_item).setVisible(videoEpisodeFragment.isFavorite) } - menu.findItem(R.id.set_sleeptimer_item).setVisible(!sleepTimerActive()) - menu.findItem(R.id.disable_sleeptimer_item).setVisible(sleepTimerActive()) + menu.findItem(R.id.set_sleeptimer_item).setVisible(!isSleepTimerActive()) + menu.findItem(R.id.disable_sleeptimer_item).setVisible(isSleepTimerActive()) menu.findItem(R.id.player_switch_to_audio_only).setVisible(true) menu.findItem(R.id.audio_controls).setVisible(audioTracks.size >= 2) @@ -382,7 +379,7 @@ class VideoplayerActivity : CastEnabledActivity() { } //Go to x% of video: if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) { - seekTo((0.1f * (keyCode - KeyEvent.KEYCODE_0) * duration).toInt()) + seekTo((0.1f * (keyCode - KeyEvent.KEYCODE_0) * curDurationFB).toInt()) return true } return super.onKeyUp(keyCode, event) @@ -393,11 +390,11 @@ class VideoplayerActivity : CastEnabledActivity() { private var _binding: AudioControlsBinding? = null private val binding get() = _binding!! - private var controller: PlaybackController? = null + private var controller: ServiceStatusHandler? = null @UnstableApi override fun onStart() { super.onStart() - controller = object : PlaybackController(requireActivity()) { + controller = object : ServiceStatusHandler(requireActivity()) { override fun loadMediaInfo() { setupAudioTracks() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SleepTimerDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SleepTimerDialog.kt index 1b7b4266..173a2e1b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SleepTimerDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SleepTimerDialog.kt @@ -2,10 +2,10 @@ package ac.mdiq.podcini.ui.dialog import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.TimeDialogBinding -import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.service.PlaybackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableFrom import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableTo @@ -51,7 +51,6 @@ import kotlinx.coroutines.launch import java.util.* import java.util.concurrent.TimeUnit import kotlin.math.* -import kotlin.time.DurationUnit class SleepTimerDialog : DialogFragment() { private var _binding: TimeDialogBinding? = null @@ -146,7 +145,7 @@ class SleepTimerDialog : DialogFragment() { val time = if (binding.endEpisode.isChecked) { val curPosition = curMedia?.getPosition() ?: 0 val duration = curMedia?.getDuration() ?: 0 - val converter = TimeSpeedConverter(curSpeedMultiplier) + val converter = TimeSpeedConverter(curSpeedFB) TimeUnit.MILLISECONDS.toMinutes(converter.convert(max((duration - curPosition).toDouble(), 0.0).toInt()).toLong()) // ms to minutes } else etxtTime.getText().toString().toLong() Logd(TAG, "Sleep timer set: $time") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt index a18cb09f..37d5df48 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt @@ -2,12 +2,12 @@ package ac.mdiq.podcini.ui.dialog import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.SpeedSelectDialogBinding -import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.InTheatre.curState +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB import ac.mdiq.podcini.playback.service.PlaybackService.Companion.currentMediaType +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence @@ -76,7 +76,7 @@ import java.util.* if (!settingCode[2]) binding.global.visibility = View.INVISIBLE procFlowEvents() - updateSpeed(FlowEvent.SpeedChangedEvent(curSpeedMultiplier)) + updateSpeed(FlowEvent.SpeedChangedEvent(curSpeedFB)) } @UnstableApi override fun onStop() { 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 9e169e4e..db40b000 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 @@ -3,27 +3,29 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding import ac.mdiq.podcini.databinding.PlayerUiFragmentBinding -import ac.mdiq.podcini.playback.PlaybackController -import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition -import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier -import ac.mdiq.podcini.playback.PlaybackController.Companion.duration -import ac.mdiq.podcini.playback.PlaybackController.Companion.fallbackSpeed -import ac.mdiq.podcini.playback.PlaybackController.Companion.getPlayerActivityIntent -import ac.mdiq.podcini.playback.PlaybackController.Companion.isPlayingVideoLocally -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService -import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo -import ac.mdiq.podcini.playback.PlaybackController.Companion.sleepTimerActive +import ac.mdiq.podcini.playback.PlaybackServiceStarter +import ac.mdiq.podcini.playback.ServiceStatusHandler +import ac.mdiq.podcini.playback.ServiceStatusHandler.Companion.getPlayerActivityIntent import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.cast.CastEnabledActivity +import ac.mdiq.podcini.playback.service.PlaybackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curDurationFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.toggleFallbackSpeed +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isPlayingVideoLocally +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isSleepTimerActive +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playPause +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.receiver.MediaButtonReceiver -import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.model.Chapter import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Playable @@ -47,6 +49,7 @@ import ac.mdiq.podcini.storage.utils.TimeSpeedConverter import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import android.app.Activity +import android.content.ComponentName import android.content.Intent import android.os.Bundle import android.util.Log @@ -63,12 +66,16 @@ import androidx.fragment.app.Fragment import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import coil.imageLoader import coil.request.ErrorResult import coil.request.ImageRequest import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.elevation.SurfaceColors +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -100,7 +107,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar private lateinit var cardViewSeek: CardView - private var controller: PlaybackController? = null + private lateinit var controllerFuture: ListenableFuture + private var controller: ServiceStatusHandler? = null private var seekedToChapterStart = false // private var currentChapterIndex = -1 @@ -128,7 +136,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } toolbar.setOnMenuItemClickListener(this) - controller = createController() + controller = createHandler() controller!!.init() playerUI1 = PlayerUIFragment.newInstance(controller!!) @@ -152,7 +160,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar return binding.root } - fun initDetailedView() { + private fun initDetailedView() { if (playerDetailsFragment == null) { val fm = requireActivity().supportFragmentManager val transaction = fm.beginTransaction() @@ -198,7 +206,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val dividerPos = FloatArray(chapters.size) for (i in chapters.indices) { - dividerPos[i] = chapters[i].start / duration.toFloat() + dividerPos[i] = chapters[i].start / curDurationFB.toFloat() } } } @@ -246,9 +254,9 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } } - private fun createController(): PlaybackController { - return object : PlaybackController(requireActivity()) { - override fun updatePlayButtonShowsPlay(showPlay: Boolean) { + private fun createHandler(): ServiceStatusHandler { + return object : ServiceStatusHandler(requireActivity()) { + override fun updatePlayButton(showPlay: Boolean) { isShowPlay = showPlay playerUI?.butPlay?.setIsShowPlay(showPlay) // playerFragment2?.butPlay?.setIsShowPlay(showPlay) @@ -287,12 +295,21 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar Logd(TAG, "onStart() isCollapsed: $isCollapsed") super.onStart() procFlowEvents() + + val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) + controllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() + controllerFuture.addListener({ +// mediaController = controllerFuture.get() +// Logd(TAG, "controllerFuture.addListener: $mediaController") + }, MoreExecutors.directExecutor()) + loadMediaInfo(false) } override fun onStop() { Logd(TAG, "onStop()") super.onStop() + MediaController.releaseFuture(controllerFuture) cancelFlowEvents() // progressIndicator.visibility = View.GONE // Controller released; we will not receive buffering updates } @@ -377,8 +394,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar when { fromUser -> { val prog: Float = progress / (seekBar.max.toFloat()) - val converter = TimeSpeedConverter(curSpeedMultiplier) - val position: Int = converter.convert((prog * duration).toInt()) + val converter = TimeSpeedConverter(curSpeedFB) + val position: Int = converter.convert((prog * curDurationFB).toInt()) val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(curMedia, position) if (newChapterIndex > -1) { // if (!sbPosition.isPressed && currentChapterIndex != newChapterIndex) { @@ -393,7 +410,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar binding.txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${DurationConverter.getDurationStringLong(position)}") } else binding.txtvSeek.text = DurationConverter.getDurationStringLong(position) } - duration != playbackService?.curDuration -> updateUi() + curDurationFB != playbackService?.curDuration -> updateUi() } } @@ -414,7 +431,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar seekedToChapterStart = false } else { val prog: Float = seekBar.progress / (seekBar.max.toFloat()) - seekTo((prog * duration).toInt()) + seekTo((prog * curDurationFB).toInt()) } } cardViewSeek.scaleX = 1f @@ -438,8 +455,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar toolbar.menu?.findItem(R.id.show_video)?.setVisible(mediaType == MediaType.VIDEO) if (controller != null) { - toolbar.menu.findItem(R.id.set_sleeptimer_item).setVisible(!sleepTimerActive()) - toolbar.menu.findItem(R.id.disable_sleeptimer_item).setVisible(sleepTimerActive()) + toolbar.menu.findItem(R.id.set_sleeptimer_item).setVisible(!isSleepTimerActive()) + toolbar.menu.findItem(R.id.disable_sleeptimer_item).setVisible(isSleepTimerActive()) } (activity as? CastEnabledActivity)?.requestCastButton(toolbar.menu) } @@ -457,7 +474,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar playerDetailsFragment?.buildHomeReaderText() } R.id.show_video -> { - controller!!.playPause() + playPause() VideoPlayerActivityStarter(requireContext(), VideoMode.FULL_SCREEN_VIEW).start() } R.id.disable_sleeptimer_item, R.id.set_sleeptimer_item -> SleepTimerDialog().show(childFragmentManager, "SleepTimerDialog") @@ -535,10 +552,10 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val mediaType = media.getMediaType() if (mediaType == MediaType.AUDIO || (mediaType == MediaType.VIDEO && (videoPlayMode == VideoMode.AUDIO_ONLY.mode || videoMode == VideoMode.AUDIO_ONLY))) { - controller!!.ensureService() + ensureService() (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_EXPANDED) } else { - controller?.playPause() + playPause() // controller!!.ensureService() val intent = getPlayerActivityIntent(requireContext(), mediaType) startActivity(intent) @@ -557,12 +574,11 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar super.onViewCreated(view, savedInstanceState) butPlay?.setOnClickListener { if (controller == null) return@setOnClickListener -// val media = curMedia if (curMedia != null) { if (curMedia?.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING) { - controller!!.playPause() + playPause() requireContext().startActivity(getPlayerActivityIntent(requireContext(), curMedia!!.getMediaType())) - } else controller!!.playPause() + } else playPause() if (!isControlButtonsSet) { sbPosition.visibility = View.VISIBLE isControlButtonsSet = true @@ -584,7 +600,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar butPlay?.setOnLongClickListener { if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { val fallbackSpeed = UserPreferences.fallbackSpeed - if (fallbackSpeed > 0.1f) fallbackSpeed(fallbackSpeed) + if (fallbackSpeed > 0.1f) toggleFallbackSpeed(fallbackSpeed) } true } @@ -609,6 +625,10 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar true } } + private fun ensureService() { + if (curMedia == null) return + if (playbackService == null) PlaybackServiceStarter(requireContext(), curMedia!!).start() + } private fun speedForward(speed: Float) { // playbackService?.speedForward(speed) if (playbackService?.mPlayer == null || playbackService?.isFallbackSpeed == true) return @@ -624,7 +644,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar if (controller == null) return@OnClickListener showTimeLeft = !showTimeLeft UserPreferences.setShowRemainTimeSetting(showTimeLeft) - onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia, curPosition, duration)) + onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia, curPositionFB, curDurationFB)) }) } fun updatePlaybackSpeedButton(event: FlowEvent.SpeedChangedEvent) { @@ -634,8 +654,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } @UnstableApi fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) { - if (curMedia?.getIdentifier() != event.media?.getIdentifier() || controller == null || curPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) return - val converter = TimeSpeedConverter(curSpeedMultiplier) + if (curMedia?.getIdentifier() != event.media?.getIdentifier() || controller == null || curPositionFB == Playable.INVALID_TIME || curDurationFB == Playable.INVALID_TIME) return + val converter = TimeSpeedConverter(curSpeedFB) val currentPosition: Int = converter.convert(event.position) val duration: Int = converter.convert(event.duration) val remainingTime: Int = converter.convert(max((event.duration - event.position).toDouble(), 0.0).toInt()) @@ -685,14 +705,14 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar override fun onPause() { Logd(TAG, "onPause() called") super.onPause() - controller?.pause() +// controller?.pause() } @OptIn(UnstableApi::class) override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} override fun onStartTrackingTouch(seekBar: SeekBar) {} @OptIn(UnstableApi::class) override fun onStopTrackingTouch(seekBar: SeekBar) { if (playbackService?.isServiceReady() == true) { val prog: Float = seekBar.progress / (seekBar.max.toFloat()) - seekTo((prog * duration).toInt()) + seekTo((prog * curDurationFB).toInt()) } } @UnstableApi @@ -736,8 +756,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } companion object { - var controller: PlaybackController? = null - fun newInstance(controller_: PlaybackController) : PlayerUIFragment { + var controller: ServiceStatusHandler? = null + fun newInstance(controller_: ServiceStatusHandler) : PlayerUIFragment { controller = controller_ return PlayerUIFragment() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt index 26be3d75..be574482 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt @@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.SimpleListFragmentBinding import ac.mdiq.podcini.databinding.SimplechapterItemBinding -import ac.mdiq.podcini.playback.PlaybackController +import ac.mdiq.podcini.playback.ServiceStatusHandler import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.storage.model.EpisodeMedia @@ -11,8 +11,9 @@ import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.ChapterUtils.getCurrentChapterIndex import ac.mdiq.podcini.storage.utils.ChapterUtils.loadChapters import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition -import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playPause +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo import ac.mdiq.podcini.storage.model.Chapter import ac.mdiq.podcini.storage.model.EmbeddedChapterImage import ac.mdiq.podcini.ui.view.CircularProgressBar @@ -63,7 +64,7 @@ class ChaptersFragment : AppCompatDialogFragment() { private lateinit var progressBar: ProgressBar private lateinit var adapter: ChaptersListAdapter - private var controller: PlaybackController? = null + private var controller: ServiceStatusHandler? = null private var focusedChapter = -1 private var media: Playable? = null @@ -97,7 +98,7 @@ class ChaptersFragment : AppCompatDialogFragment() { adapter = ChaptersListAdapter(requireContext(), object : ChaptersListAdapter.Callback { override fun onPlayChapterButtonClicked(pos: Int) { - if (MediaPlayerBase.status != PlayerStatus.PLAYING) controller!!.playPause() + if (MediaPlayerBase.status != PlayerStatus.PLAYING) playPause() val chapter = adapter.getItem(pos) if (chapter != null) seekTo(chapter.start.toInt()) @@ -111,7 +112,7 @@ class ChaptersFragment : AppCompatDialogFragment() { val wrapHeight = CoordinatorLayout.LayoutParams(CoordinatorLayout.LayoutParams.MATCH_PARENT, CoordinatorLayout.LayoutParams.WRAP_CONTENT) recyclerView.layoutParams = wrapHeight - controller = object : PlaybackController(requireActivity()) { + controller = object : ServiceStatusHandler(requireActivity()) { override fun loadMediaInfo() { this@ChaptersFragment.loadMediaInfo(false) } @@ -167,7 +168,7 @@ class ChaptersFragment : AppCompatDialogFragment() { private fun getCurrentChapter(media: Playable?): Int { if (controller == null) return -1 - return getCurrentChapterIndex(media, curPosition) + return getCurrentChapterIndex(media, curPositionFB) } private fun loadMediaInfo(forceRefresh: Boolean) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index 09e5458c..d0b456bb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -7,8 +7,8 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo import ac.mdiq.podcini.preferences.UsageStatistics import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.RealmDB.unmanaged diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt index a589c05a..ef23a737 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt @@ -3,10 +3,10 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.PlayerDetailsFragmentBinding import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource -import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition -import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier -import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.base.InTheatre.curMedia +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.* @@ -93,7 +93,7 @@ class PlayerDetailsFragment : Fragment() { Logd(TAG, "fragment onCreateView") shownoteView = binding.webview - shownoteView.setTimecodeSelectedListener { time: Int? -> seekTo(time!!) } + shownoteView.setTimecodeSelectedListener { time: Int -> seekTo(time) } shownoteView.setPageFinishedListener { // Restoring the scroll position might not always work shownoteView.postDelayed({ this@PlayerDetailsFragment.restoreFromPreference() }, 50) @@ -347,7 +347,7 @@ class PlayerDetailsFragment : Fragment() { when { displayedChapterIndex < 1 -> seekTo(0) - (curPosition - 10000 * curSpeedMultiplier) < curr.start -> { + (curPositionFB - 10000 * curSpeedFB) < curr.start -> { refreshChapterData(displayedChapterIndex - 1) if (playable != null) seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 8b4101b6..137bd438 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -6,6 +6,9 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.databinding.QueueFragmentBinding import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed +import ac.mdiq.podcini.playback.service.PlaybackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.mediaBrowser +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Queues.clearQueue import ac.mdiq.podcini.storage.database.Queues.isQueueKeepSorted @@ -38,6 +41,8 @@ import ac.mdiq.podcini.ui.view.EpisodesRecyclerView import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent +import android.annotation.SuppressLint +import android.content.ComponentName import android.content.Context import android.content.DialogInterface import android.content.SharedPreferences @@ -64,12 +69,16 @@ import androidx.compose.ui.window.Dialog import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaBrowser +import androidx.media3.session.SessionToken import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialView import kotlinx.coroutines.* @@ -104,6 +113,8 @@ import java.util.* private var showBin: Boolean = false private var addToQueueActionItem: SpeedDialActionItem? = null + private lateinit var browserFuture: ListenableFuture + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) retainInstance = true @@ -138,9 +149,11 @@ import java.util.* queueSpinner.setSelection(queueNames.indexOf(curQueue.name)) queueSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val prevQueueSize = curQueue.size() curQueue = upsertBlk(queues[position]) { it.update() } toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(curQueue.name != "Default") loadCurQueue(true) + playbackService?.notifyCurQueueItemsChanged(Math.max(prevQueueSize, curQueue.size())) } override fun onNothingSelected(parent: AdapterView<*>?) {} } @@ -216,6 +229,16 @@ import java.util.* adapter?.refreshFragPosCallback = ::refreshPosCallback loadCurQueue(true) procFlowEvents() + val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) + browserFuture = MediaBrowser.Builder(requireContext(), sessionToken).buildAsync() + browserFuture.addListener( + { + // here we can get the root of media items tree or we can get also the children if it is an album for example. + mediaBrowser = browserFuture.get() + mediaBrowser?.subscribe("CurQueue", null) + }, + MoreExecutors.directExecutor() + ) // if (queueItems.isNotEmpty()) recyclerView.restoreScrollPosition(TAG) } @@ -224,6 +247,9 @@ import java.util.* super.onStop() adapter?.refreshFragPosCallback = null cancelFlowEvents() + mediaBrowser?.unsubscribe("CurQueue") + mediaBrowser = null + MediaBrowser.releaseFuture(browserFuture) val childCount = recyclerView.childCount for (i in 0 until childCount) { val child = recyclerView.getChildAt(i) @@ -327,7 +353,10 @@ import java.util.* } } } - FlowEvent.QueueEvent.Action.SWITCH_QUEUE -> loadCurQueue(false) + FlowEvent.QueueEvent.Action.SWITCH_QUEUE -> { + loadCurQueue(false) + playbackService?.notifyCurQueueItemsChanged(event.episodes.size) + } FlowEvent.QueueEvent.Action.CLEARED -> { queueItems.clear() adapter?.updateItems(queueItems) @@ -385,6 +414,7 @@ import java.util.* if (swipeActions.actions?.right != null) binding.rightActionIcon.setImageResource(swipeActions.actions!!.right!!.getActionIcon()) } + @SuppressLint("RestrictedApi") private fun onKeyUp(event: KeyEvent) { if (!isAdded || !isVisible || !isMenuVisible) return @@ -650,10 +680,9 @@ import java.util.* while (curQueue.name.isEmpty()) runBlocking { delay(100) } if (queueItems.isNotEmpty()) emptyViewHandler.hide() queueItems.clear() - if (showBin) { - queueItems.addAll(realm.query(Episode::class, "id IN $0", curQueue.idsBinList) + if (showBin) queueItems.addAll(realm.query(Episode::class, "id IN $0", curQueue.idsBinList) .find().sortedByDescending { curQueue.idsBinList.indexOf(it.id) }) - } else { + else { curQueue.episodes.clear() queueItems.addAll(curQueue.episodes) } @@ -664,6 +693,7 @@ import java.util.* adapter?.updateItems(queueItems) if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) refreshInfoBar() +// playbackService?.notifyCurQueueItemsChanged() loadItemsRunning = false } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt index 203c5f68..3e4e1dc7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt @@ -2,16 +2,17 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.VideoEpisodeFragmentBinding -import ac.mdiq.podcini.playback.PlaybackController -import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier -import ac.mdiq.podcini.playback.PlaybackController.Companion.duration +import ac.mdiq.podcini.playback.ServiceStatusHandler import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.PlaybackController.Companion.isPlayingVideoLocally -import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService -import ac.mdiq.podcini.playback.PlaybackController.Companion.curPosition -import ac.mdiq.podcini.playback.PlaybackController.Companion.seekTo import ac.mdiq.podcini.playback.base.MediaPlayerBase import ac.mdiq.podcini.playback.base.PlayerStatus +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curDurationFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playPause +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isPlayingVideoLocally import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs import ac.mdiq.podcini.preferences.UserPreferences.setShowRemainTimeSetting @@ -76,7 +77,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { private lateinit var webvDescription: ShownotesWebView var destroyingDueToReload = false - var controller: PlaybackController? = null + var controller: ServiceStatusHandler? = null var isFavorite = false private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent -> @@ -116,9 +117,9 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { return root } - @OptIn(UnstableApi::class) private fun newPlaybackController(): PlaybackController { - return object : PlaybackController(requireActivity()) { - override fun updatePlayButtonShowsPlay(showPlay: Boolean) { + @OptIn(UnstableApi::class) private fun newPlaybackController(): ServiceStatusHandler { + return object : ServiceStatusHandler(requireActivity()) { + override fun updatePlayButton(showPlay: Boolean) { Logd(TAG, "updatePlayButtonShowsPlay called") binding.playButton.setIsShowPlay(showPlay) if (showPlay) (activity as AppCompatActivity).window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) @@ -159,9 +160,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { @UnstableApi override fun onPause() { - if (!PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) { - if (MediaPlayerBase.status == PlayerStatus.PLAYING) controller!!.pause() - } +// this does nothing +// if (!PictureInPictureUtil.isInPictureInPictureMode(requireActivity())) { +// if (MediaPlayerBase.status == PlayerStatus.PLAYING) controller!!.pause() +// } super.onPause() } @@ -286,7 +288,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { binding.durationLabel.setOnClickListener { showTimeLeft = !showTimeLeft val media = curMedia ?: return@setOnClickListener - val converter = TimeSpeedConverter(curSpeedMultiplier) + val converter = TimeSpeedConverter(curSpeedFB) val length: String if (showTimeLeft) { val remainingTime = converter.convert(media.getDuration() - media.getPosition()) @@ -423,8 +425,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { @UnstableApi fun onPlayPause() { - if (controller == null) return - controller!!.playPause() + playPause() setupVideoControlsToggler() } @@ -482,10 +483,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { private fun onPositionObserverUpdate() { if (controller == null) return - val converter = TimeSpeedConverter(curSpeedMultiplier) - val currentPosition = converter.convert(curPosition) - val duration_ = converter.convert(duration) - val remainingTime = converter.convert(duration - curPosition) + val converter = TimeSpeedConverter(curSpeedFB) + val currentPosition = converter.convert(curPositionFB) + val duration_ = converter.convert(curDurationFB) + val remainingTime = converter.convert(curDurationFB - curPositionFB) // Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition)); if (currentPosition == Playable.INVALID_TIME || duration_ == Playable.INVALID_TIME) { Log.w(TAG, "Could not react to position observer update because of invalid time") @@ -508,8 +509,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { if (controller == null) return if (fromUser) { prog = progress / (seekBar.max.toFloat()) - val converter = TimeSpeedConverter(curSpeedMultiplier) - val position = converter.convert((prog * duration).toInt()) + val converter = TimeSpeedConverter(curSpeedFB) + val position = converter.convert((prog * curDurationFB).toInt()) binding.seekPositionLabel.text = getDurationStringLong(position) } } @@ -526,7 +527,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } override fun onStopTrackingTouch(seekBar: SeekBar) { - seekTo((prog * duration).toInt()) + seekTo((prog * curDurationFB).toInt()) binding.seekCardView.scaleX = 1f binding.seekCardView.scaleY = 1f binding.seekCardView.animate() diff --git a/build.gradle b/build.gradle index 1f03c6a9..de5ee33a 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() gradlePluginPortal() } - ext.kotlin_version = '2.0.10' + ext.kotlin_version = "$libs.versions.kotlin" dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.android.tools.build:gradle:8.5.2' diff --git a/changelog.md b/changelog.md index 58f1636a..8f9a7df6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,10 @@ +# 6.4.0 + +* PlaybackService is now a MediaLibraryService and also takes over many functionalities from PlaybackController +* PlaybackController is renamed to ServiceStatusHandler and is stripped to bare bones +* enabled Android Auto UI, currently playing episode and episodes in the active queue are shown in the Auto UI +* added code to handle case where keycode=-1 and keyEvent!=null, attempting to resolve the occassional issue of random start playing + # 6.3.7 * inlined some DB writes of Episodes in some routines diff --git a/fastlane/metadata/android/en-US/changelogs/3020231.txt b/fastlane/metadata/android/en-US/changelogs/3020231.txt index 0e3f254f..3984807d 100644 --- a/fastlane/metadata/android/en-US/changelogs/3020231.txt +++ b/fastlane/metadata/android/en-US/changelogs/3020231.txt @@ -1,6 +1,6 @@ - Version 6.3.7 brings several changes: + Version 6.4.0 brings several changes: -* inlined some DB writes of Episodes in some routines -* enhanced DB writes in download routine, fixed a write error -* added a couple more Log.d statements in hope for tracking down the mysterious random playing -* Kotlin upped to 2.0.10 +* PlaybackService is now a MediaLibraryService and also takes over many functionalities from PlaybackController +* PlaybackController is renamed to ServiceStatusHandler and is stripped to bare bones +* enabled Android Auto UI, currently playing episode and episodes in the active queue are shown in the Auto UI +* added code to handle case where keycode=-1 and keyEvent!=null, attempting to resolve the occassional issue of random start playing diff --git a/fastlane/metadata/android/en-US/changelogs/3020232.txt b/fastlane/metadata/android/en-US/changelogs/3020232.txt new file mode 100644 index 00000000..0e3f254f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020232.txt @@ -0,0 +1,6 @@ + Version 6.3.7 brings several changes: + +* inlined some DB writes of Episodes in some routines +* enhanced DB writes in download routine, fixed a write error +* added a couple more Log.d statements in hope for tracking down the mysterious random playing +* Kotlin upped to 2.0.10 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 169b0a4c..70a44f6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -kotlin = "2.0.0" +kotlin = "2.0.10" [plugins] org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }