diff --git a/app/build.gradle b/app/build.gradle index 00bb0aa3..5b1801ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,7 @@ plugins { - id('com.android.application') + id 'com.android.application' id 'kotlin-android' -// id 'kotlin-kapt' + id 'org.jetbrains.kotlin.android' id 'com.google.devtools.ksp' id('com.github.triplet.play') version '3.8.3' apply false } @@ -159,8 +159,8 @@ android { // Version code schema (not used): // "1.2.3-beta4" -> 1020304 // "1.2.3" -> 1020395 - versionCode 3020147 - versionName "5.4.0" + versionCode 3020148 + versionName "5.4.1" def commit = "" try { @@ -221,6 +221,7 @@ android { dependencies { implementation "androidx.core:core-ktx:1.12.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation 'com.android.volley:volley:1.2.1' constraints { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08d1c705..a1747df5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,6 +56,7 @@ tools:ignore="ExportedService"> + diff --git a/app/src/main/java/ac/mdiq/podcini/playback/PlaybackController.kt b/app/src/main/java/ac/mdiq/podcini/playback/PlaybackController.kt index 25553bab..33d958d6 100644 --- a/app/src/main/java/ac/mdiq/podcini/playback/PlaybackController.kt +++ b/app/src/main/java/ac/mdiq/podcini/playback/PlaybackController.kt @@ -22,6 +22,7 @@ import android.os.IBinder import android.util.Log import android.util.Pair import android.view.SurfaceHolder +import android.widget.MediaController import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi @@ -114,31 +115,23 @@ abstract class PlaybackController(private val activity: FragmentActivity) { try { activity.unregisterReceiver(statusUpdate) - } catch (e: IllegalArgumentException) { - // ignore - } + } catch (e: IllegalArgumentException) { } try { activity.unregisterReceiver(notificationReceiver) - } catch (e: IllegalArgumentException) { - // ignore - } + } catch (e: IllegalArgumentException) { } + unbind() -// media = null released = true - if (eventsRegistered) { - - eventsRegistered = false - } + if (eventsRegistered) eventsRegistered = false } private fun unbind() { try { activity.unbindService(mConnection) - } catch (e: IllegalArgumentException) { - // ignore - } + } catch (e: IllegalArgumentException) { } + initialized = false } diff --git a/app/src/main/java/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt b/app/src/main/java/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt index e5136d78..5a0378f5 100644 --- a/app/src/main/java/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt @@ -574,7 +574,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP try { clearMediaPlayerListeners() // TODO: should use: exoPlayer!!.playWhenReady ? - if (exoPlayer!!.isPlaying) exoPlayer?.stop() + if (exoPlayer?.isPlaying == true) exoPlayer?.stop() } catch (e: Exception) { e.printStackTrace() } @@ -686,7 +686,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP while (true) { delay(bufferUpdateInterval) withContext(Dispatchers.Main) { - if (bufferedPercentagePrev != exoPlayer!!.bufferedPercentage) { + if (exoPlayer != null && bufferedPercentagePrev != exoPlayer?.bufferedPercentage) { bufferingUpdateListener?.accept(exoPlayer!!.bufferedPercentage) bufferedPercentagePrev = exoPlayer!!.bufferedPercentage } diff --git a/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt index d637a2fe..603fc88d 100644 --- a/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -11,10 +11,10 @@ import ac.mdiq.podcini.playback.cast.CastPsmp import ac.mdiq.podcini.playback.cast.CastStateListener import ac.mdiq.podcini.playback.service.PlaybackServiceTaskManager.PSTMCallback import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.clearCurrentlyPlayingTemporaryPlaybackSpeed -import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.loadPlayableFromPreferences import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.currentEpisodeIsVideo import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.currentlyPlayingFeedMediaId import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.currentlyPlayingTemporaryPlaybackSpeed +import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.loadPlayableFromPreferences import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.writeMediaPlaying import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.writeNoMediaPlaying import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.writePlayerStatus @@ -60,11 +60,14 @@ import ac.mdiq.podcini.util.NetworkUtils.isStreamingAllowed import ac.mdiq.podcini.util.event.EventFlow import ac.mdiq.podcini.util.event.FlowEvent import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.bluetooth.BluetoothA2dp import android.content.* +import android.content.Intent.EXTRA_KEY_EVENT import android.media.AudioManager import android.os.* import android.os.Build.VERSION_CODES @@ -74,6 +77,7 @@ import android.util.Log import android.util.Pair import android.view.KeyEvent import android.view.SurfaceHolder +import android.view.ViewConfiguration import android.webkit.URLUtil import android.widget.Toast import androidx.core.app.NotificationCompat @@ -81,6 +85,7 @@ import androidx.media3.common.Player.STATE_ENDED import androidx.media3.common.Player.STATE_IDLE import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult @@ -123,6 +128,9 @@ class PlaybackService : MediaSessionService() { private val mBinder: IBinder = LocalBinder() + private var clickCount = 0 + private val clickHandler = Handler(Looper.getMainLooper()) + val mPlayerInfo: MediaPlayerInfo get() = mediaPlayer!!.playerInfo @@ -164,7 +172,6 @@ class PlaybackService : MediaSessionService() { val videoSize: Pair? get() = mediaPlayer?.getVideoSize() - inner class LocalBinder : Binder() { val service: PlaybackService get() = this@PlaybackService @@ -214,6 +221,7 @@ class PlaybackService : MediaSessionService() { recreateMediaPlayer() if (LocalMediaPlayer.exoPlayer == null) LocalMediaPlayer.createStaticPlayer(applicationContext) + mediaSession = MediaSession.Builder(applicationContext, LocalMediaPlayer.exoPlayer!!) .setCallback(MyCallback()) .setCustomLayout(notificationCustomButtons) @@ -271,62 +279,65 @@ class PlaybackService : MediaSessionService() { unregisterReceiver(bluetoothStateUpdated) unregisterReceiver(audioBecomingNoisy) taskManager.shutdown() - } fun isServiceReady(): Boolean { return mediaSession?.player?.playbackState != STATE_IDLE && mediaSession?.player?.playbackState != STATE_ENDED } - private inner class MyCallback : MediaSession.Callback { + inner class MyCallback : MediaSession.Callback { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { - Logd(TAG, "in onConnect") + Logd(TAG, "in MyCallback onConnect") val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() // .add(NotificationCustomButton.REWIND) // .add(NotificationCustomButton.FORWARD) - if (session.isMediaNotificationController(controller)) { - val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() -// .remove(COMMAND_SEEK_TO_PREVIOUS) -// .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) -// .remove(COMMAND_SEEK_TO_NEXT) -// .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) -// .removeAll() + when { + session.isMediaNotificationController(controller) -> { + val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() + // .remove(COMMAND_SEEK_TO_PREVIOUS) + // .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + // .remove(COMMAND_SEEK_TO_NEXT) + // .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + // .removeAll() -// -// // Custom layout and available commands to configure the legacy/framework session. -// return MediaSession.ConnectionResult.AcceptedResultBuilder(session) -//// .setCustomLayout( -//// ImmutableList.of( -//// createSeekBackwardButton(NotificationCustomButton.REWIND), -//// createSeekForwardButton(customCommandSeekForward)) -//// ) -// .setAvailablePlayerCommands(playerCommands.build()) -// .setAvailableSessionCommands(sessionCommands.build()) -// .build() + // + // // Custom layout and available commands to configure the legacy/framework session. + // return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + //// .setCustomLayout( + //// ImmutableList.of( + //// createSeekBackwardButton(NotificationCustomButton.REWIND), + //// createSeekForwardButton(customCommandSeekForward)) + //// ) + // .setAvailablePlayerCommands(playerCommands.build()) + // .setAvailableSessionCommands(sessionCommands.build()) + // .build() -// val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() + // val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() - /* Registering custom player command buttons for player notification. */ - notificationCustomButtons.forEach { commandButton -> - Logd(TAG, "onConnect commandButton ${commandButton.displayName}") - commandButton.sessionCommand?.let(sessionCommands::add) + /* Registering custom player command buttons for player notification. */ + notificationCustomButtons.forEach { commandButton -> + Logd(TAG, "MyCallback onConnect commandButton ${commandButton.displayName}") + commandButton.sessionCommand?.let(sessionCommands::add) + } + + return MediaSession.ConnectionResult.accept( + sessionCommands.build(), + playerCommands.build() + ) } - - return MediaSession.ConnectionResult.accept( - sessionCommands.build(), - playerCommands.build() - ) - } else if (session.isAutoCompanionController(controller)) { - // Available session commands to accept incoming custom commands from Auto. - return MediaSession.ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands.build()) - .build() + session.isAutoCompanionController(controller) -> { + // Available session commands to accept incoming custom commands from Auto. + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands.build()) + .build() + } + // Default commands with default custom layout for all other controllers. + else -> return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() } - // Default commands with default custom layout for all other controllers. - return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() } override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + Logd(TAG, "MyCallback onPostConnect") super.onPostConnect(session, controller) if (notificationCustomButtons.isNotEmpty()) { /* Setting custom player command buttons to mediaLibrarySession for player notification. */ @@ -337,6 +348,7 @@ class PlaybackService : MediaSessionService() { override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { /* Handling custom command buttons from player notification. */ + Logd(TAG, "onCustomCommand called ${customCommand.customAction}") when (customCommand.customAction) { NotificationCustomButton.REWIND.customAction -> mediaPlayer?.seekDelta(-rewindSecs * 1000) NotificationCustomButton.FORWARD.customAction -> mediaPlayer?.seekDelta(fastForwardSecs * 1000) @@ -345,8 +357,9 @@ class PlaybackService : MediaSessionService() { return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } - override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo): ListenableFuture { - val settable = SettableFuture.create() + override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo): ListenableFuture { + Logd(TAG, "onPlaybackResumption called ") + val settable = SettableFuture.create() // scope.launch { // // Your app is responsible for storing the playlist and the start position // // to use here @@ -355,6 +368,31 @@ class PlaybackService : MediaSessionService() { // } 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 + Logd(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 -> mediaPlayer?.seekDelta(fastForwardSecs * 1000) + 3 -> mediaPlayer?.seekDelta(-rewindSecs * 1000) + } + clickCount = 0 + }, ViewConfiguration.getDoubleTapTimeout().toLong()) + return true + } else return handleKeycode(keyCode, false) + } + return false + } } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { @@ -373,6 +411,9 @@ class PlaybackService : MediaSessionService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) +// val notification = createNotification() +// startForeground(NOTIFICATION_ID, notification) + val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1 val customAction = intent?.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION) val hardwareButton = intent?.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false) ?: false @@ -405,20 +446,6 @@ class PlaybackService : MediaSessionService() { val allowStreamAlways = intent.getBooleanExtra(PlaybackServiceConstants.EXTRA_ALLOW_STREAM_ALWAYS, false) sendNotificationBroadcast(PlaybackServiceConstants.NOTIFICATION_TYPE_RELOAD, 0) if (allowStreamAlways) isAllowMobileStreaming = true - -// Observable.fromCallable { -// if (playable is FeedMedia) return@fromCallable DBReader.getFeedMedia(playable.id) -// else return@fromCallable playable -// } -// .subscribeOn(Schedulers.io()) -// .observeOn(AndroidSchedulers.mainThread()) -// .subscribe( -// { loadedPlayable: Playable? -> startPlaying(loadedPlayable, allowStreamThisTime) }, -// { error: Throwable -> -// Logd(TAG, "Playable was not found. Stopping service.") -// error.printStackTrace() -// }) - scope.launch { try { val loadedPlayable = withContext(Dispatchers.IO) { @@ -1324,6 +1351,9 @@ class PlaybackService : MediaSessionService() { companion object { private const val TAG = "PlaybackService" + private const val NOTIFICATION_ID = 5326 + private const val CHANNEL_ID = "podcini_session_notification_channel_id" + private const val POSITION_EVENT_INTERVAL = 5L const val ACTION_PLAYER_STATUS_CHANGED: String = "action.ac.mdiq.podcini.service.playerStatusChanged" diff --git a/app/src/main/java/ac/mdiq/podcini/storage/export/opml/OpmlReader.kt b/app/src/main/java/ac/mdiq/podcini/storage/export/opml/OpmlReader.kt index 43801fc9..e6a04698 100644 --- a/app/src/main/java/ac/mdiq/podcini/storage/export/opml/OpmlReader.kt +++ b/app/src/main/java/ac/mdiq/podcini/storage/export/opml/OpmlReader.kt @@ -66,7 +66,13 @@ class OpmlReader { } } } - eventType = xpp.next() + try { +// TODO: on first install app: java.io.IOException: Underlying input stream returned zero bytes + eventType = xpp.next() + } catch(e: Exception) { + Log.e(TAG, "xpp.next() invalid: $e") + break + } } Logd(TAG, "Parsing finished.") diff --git a/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt index 8b4f3122..80f300bd 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -74,6 +74,7 @@ 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.flow.collectLatest import kotlinx.coroutines.launch @@ -93,6 +94,7 @@ class MainActivity : CastEnabledActivity() { private lateinit var mainView: View private lateinit var audioPlayerFragment: AudioPlayerFragment private lateinit var audioPlayerFragmentView: View + private lateinit var controllerFuture: ListenableFuture private lateinit var navDrawer: View private lateinit var dummyView : View lateinit var bottomSheet: LockableBottomSheetBehavior<*> @@ -503,7 +505,7 @@ class MainActivity : CastEnabledActivity() { RatingDialog.init(this) val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) - val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() controllerFuture.addListener({ // Call controllerFuture.get() to retrieve the MediaController. // MediaController implements the Player interface, so it can be @@ -533,7 +535,7 @@ class MainActivity : CastEnabledActivity() { override fun onStop() { super.onStop() - + MediaController.releaseFuture(controllerFuture) } override fun onTrimMemory(level: Int) { diff --git a/changelog.md b/changelog.md index db7b72ee..e1bdb425 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,9 @@ +## 5.4.1 + +* fixed occasional crash of detecting existing OPML file for new install +* should have fixed the mal-functioning earphone buttons + ## 5.4.0 * replaced thread with coroutines in DBWrite diff --git a/fastlane/metadata/android/en-US/changelogs/3020148.txt b/fastlane/metadata/android/en-US/changelogs/3020148.txt new file mode 100644 index 00000000..506bb3e7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020148.txt @@ -0,0 +1,5 @@ + +Version 5.4.1 brings several changes: + +* fixed occasional crash of detecting existing OPML file for new install +* should have fixed the mal-functioning earphone buttons