From c2fb0f4a31377d503c818d4d246684afe354d202 Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Tue, 23 Apr 2024 22:54:22 +0100 Subject: [PATCH] 4.9.1 commit --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 4 +- .../CustomMediaNotificationProvider.kt | 37 + .../podcini/playback/service/LocalPSMP.kt | 113 +- .../NotificationPlayerCustomCommandButton.kt | 28 + .../playback/service/PlaybackService.kt | 15 +- .../playback/service/PlaybackService.kt1 | 1865 +++++++++++++++++ .../PlaybackServiceNotificationBuilder.kt | 54 +- .../ac/mdiq/podcini/receiver/PlayerWidget.kt | 3 +- .../mdiq/podcini/ui/activity/MainActivity.kt | 162 +- .../ui/activity/WidgetConfigActivity.kt | 8 +- .../ui/fragment/AudioPlayerFragment.kt | 8 + .../ui/fragment/EpisodeHomeFragment.kt | 131 +- .../ui/fragment/EpisodeInfoFragment.kt | 64 +- .../ui/fragment/PlayerDetailsFragment.kt | 98 +- .../mdiq/podcini/ui/utils/ShownotesCleaner.kt | 13 +- .../mdiq/podcini/ui/widget/WidgetUpdater.kt | 8 +- .../podcini/ui/widget/WidgetUpdaterWorker.kt | 13 +- .../main/res/layout/episode_home_fragment.xml | 6 + app/src/main/res/menu/mediaplayer.xml | 7 + changelog.md | 8 +- .../android/en-US/changelogs/3020132.txt | 6 + 22 files changed, 2257 insertions(+), 398 deletions(-) create mode 100644 app/src/main/java/ac/mdiq/podcini/playback/service/CustomMediaNotificationProvider.kt create mode 100644 app/src/main/java/ac/mdiq/podcini/playback/service/NotificationPlayerCustomCommandButton.kt create mode 100644 app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt1 create mode 100644 fastlane/metadata/android/en-US/changelogs/3020132.txt diff --git a/app/build.gradle b/app/build.gradle index 4594cfb6..51edfed2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -158,8 +158,8 @@ android { // Version code schema (not used): // "1.2.3-beta4" -> 1020304 // "1.2.3" -> 1020395 - versionCode 3020131 - versionName "4.9.0" + versionCode 3020132 + versionName "4.9.1" def commit = "" try { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 11da927d..4d312fa4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -57,8 +57,8 @@ tools:ignore="ExportedService"> - - + + diff --git a/app/src/main/java/ac/mdiq/podcini/playback/service/CustomMediaNotificationProvider.kt b/app/src/main/java/ac/mdiq/podcini/playback/service/CustomMediaNotificationProvider.kt new file mode 100644 index 00000000..ab15bbdd --- /dev/null +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/CustomMediaNotificationProvider.kt @@ -0,0 +1,37 @@ +package ac.mdiq.podcini.playback.service + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaSession +import com.google.common.collect.ImmutableList + +@UnstableApi +class CustomMediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { + + override fun addNotificationActions(mediaSession: MediaSession, mediaButtons: ImmutableList, builder: NotificationCompat.Builder, actionFactory: MediaNotification.ActionFactory): IntArray { + + /* Retrieving notification default play/pause button from mediaButtons list. */ + val defaultPlayPauseCommandButton = mediaButtons.getOrNull(0) + val notificationMediaButtons = if (defaultPlayPauseCommandButton != null) { + /* Overriding received mediaButtons list to ensure required buttons order: [rewind15, play/pause, forward15]. */ + ImmutableList.builder().apply { + add(NotificationPlayerCustomCommandButton.REWIND.commandButton) + add(defaultPlayPauseCommandButton) + add(NotificationPlayerCustomCommandButton.FORWARD.commandButton) + }.build() + } else { + /* Fallback option to handle nullability, in case retrieving default play/pause button fails for some reason (should never happen). */ + mediaButtons + } + return super.addNotificationActions( + mediaSession, + notificationMediaButtons, + builder, + actionFactory + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ac/mdiq/podcini/playback/service/LocalPSMP.kt b/app/src/main/java/ac/mdiq/podcini/playback/service/LocalPSMP.kt index 6fd907ed..c1ef7dac 100644 --- a/app/src/main/java/ac/mdiq/podcini/playback/service/LocalPSMP.kt +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/LocalPSMP.kt @@ -125,13 +125,11 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia return } else { // stop playback of this episode - if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARED) { + if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARED) mediaPlayer?.stop() - } + // set temporarily to pause in order to update list with current position - if (playerStatus == PlayerStatus.PLAYING) { - callback.onPlaybackPause(media, getPosition()) - } + if (playerStatus == PlayerStatus.PLAYING) callback.onPlaybackPause(media, getPosition()) if (media!!.getIdentifier() != playable.getIdentifier()) { val oldMedia: Playable = media!! @@ -160,22 +158,17 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia if (playable is FeedMedia && playable.item?.feed?.preferences != null) { val preferences = playable.item!!.feed!!.preferences!! mediaPlayer?.setDataSource(streamurl, preferences.username, preferences.password) - } else { - mediaPlayer?.setDataSource(streamurl) - } + } else mediaPlayer?.setDataSource(streamurl) } } else -> { val localMediaurl = media!!.getLocalMediaUrl() - if (localMediaurl != null && File(localMediaurl).canRead()) { - mediaPlayer?.setDataSource(localMediaurl) - } else throw IOException("Unable to read local file $localMediaurl") + if (localMediaurl != null && File(localMediaurl).canRead()) mediaPlayer?.setDataSource(localMediaurl) + else throw IOException("Unable to read local file $localMediaurl") } } val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) { - setPlayerStatus(PlayerStatus.INITIALIZED, media) - } + if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, media) if (prepareImmediately) { setPlayerStatus(PlayerStatus.PREPARING, media) @@ -250,9 +243,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia abandonAudioFocus() pausedBecauseOfTransientAudiofocusLoss = false } - if (stream && reinit) { - reinit() - } + if (stream && reinit) reinit() } else { Log.d(TAG, "Ignoring call to pause: Player is in $playerStatus state") } @@ -285,15 +276,11 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia check(playerStatus == PlayerStatus.PREPARING) { "Player is not in PREPARING state" } Log.d(TAG, "Resource prepared") - if (mediaPlayer != null && mediaType == MediaType.VIDEO) { - videoSize = Pair(mediaPlayer!!.videoWidth, mediaPlayer!!.videoHeight) - } + if (mediaPlayer != null && mediaType == MediaType.VIDEO) videoSize = Pair(mediaPlayer!!.videoWidth, mediaPlayer!!.videoHeight) if (media != null) { // TODO this call has no effect! - if (media!!.getPosition() > 0) { - seekTo(media!!.getPosition()) - } + if (media!!.getPosition() > 0) seekTo(media!!.getPosition()) if (media!!.getDuration() <= 0) { Log.d(TAG, "Setting duration of media") @@ -302,9 +289,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia } setPlayerStatus(PlayerStatus.PREPARED, media) - if (startWhenPrepared) { - resume() - } + if (startWhenPrepared) resume() } /** @@ -317,15 +302,9 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia Log.d(TAG, "reinit()") releaseWifiLockIfNecessary() when { - media != null -> { - playMediaObject(media!!, true, stream, startWhenPrepared.get(), false) - } - mediaPlayer != null -> { - mediaPlayer!!.reset() - } - else -> { - Log.d(TAG, "Call to reinit was ignored: media and mediaPlayer were null") - } + media != null -> playMediaObject(media!!, true, stream, startWhenPrepared.get(), false) + mediaPlayer != null -> mediaPlayer!!.reset() + else -> Log.d(TAG, "Call to reinit was ignored: media and mediaPlayer were null") } } @@ -338,9 +317,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia */ override fun seekTo(t0: Int) { var t = t0 - if (t < 0) { - t = 0 - } + if (t < 0) t = 0 if (t >= getDuration()) { Log.d(TAG, "Seek reached end of file, skipping to next episode") @@ -361,9 +338,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia statusBeforeSeeking = playerStatus setPlayerStatus(PlayerStatus.SEEKING, media, getPosition()) mediaPlayer?.seekTo(t) - if (statusBeforeSeeking == PlayerStatus.PREPARED) { - media?.setPosition(t) - } + if (statusBeforeSeeking == PlayerStatus.PREPARED) media?.setPosition(t) try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { @@ -401,9 +376,8 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia if (playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) { if (mediaPlayer != null) retVal = mediaPlayer!!.duration } - if (retVal <= 0 && media != null && media!!.getDuration() > 0) { - retVal = media!!.getDuration() - } + if (retVal <= 0 && media != null && media!!.getDuration() > 0) retVal = media!!.getDuration() + return retVal } @@ -415,9 +389,8 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia if (playerStatus.isAtLeast(PlayerStatus.PREPARED)) { if (mediaPlayer != null) retVal = mediaPlayer!!.currentPosition } - if (retVal <= 0 && media != null && media!!.getPosition() >= 0) { - retVal = media!!.getPosition() - } + if (retVal <= 0 && media != null && media!!.getPosition() >= 0) retVal = media!!.getPosition() + return retVal } @@ -521,9 +494,9 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia * invalid values. */ override fun getVideoSize(): Pair? { - if (mediaPlayer != null && playerStatus != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) { + if (mediaPlayer != null && playerStatus != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) videoSize = Pair(mediaPlayer!!.videoWidth, mediaPlayer!!.videoHeight) - } + return videoSize } @@ -568,9 +541,8 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia } private val audioFocusChangeListener = OnAudioFocusChangeListener { focusChange -> - if (isShutDown) { - return@OnAudioFocusChangeListener - } + if (isShutDown) return@OnAudioFocusChangeListener + when { !PlaybackService.isRunning -> { abandonAudioFocus() @@ -582,8 +554,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia pause(true, reinit = false) callback.shouldStop() } - focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - && !UserPreferences.shouldPauseForFocusLoss() -> { + focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK && !UserPreferences.shouldPauseForFocusLoss() -> { if (playerStatus == PlayerStatus.PLAYING) { Log.d(TAG, "Lost audio focus temporarily. Ducking...") setVolume(0.25f, 0.25f) @@ -598,21 +569,17 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia audioFocusCanceller.removeCallbacksAndMessages(null) audioFocusCanceller.postDelayed({ - if (pausedBecauseOfTransientAudiofocusLoss) { - // Still did not get back the audio focus. Now actually pause. - pause(abandonFocus = true, reinit = false) - } + // Still did not get back the audio focus. Now actually pause. + if (pausedBecauseOfTransientAudiofocusLoss) pause(abandonFocus = true, reinit = false) }, 30000) } } focusChange == AudioManager.AUDIOFOCUS_GAIN -> { Log.d(TAG, "Gained audio focus") audioFocusCanceller.removeCallbacksAndMessages(null) - if (pausedBecauseOfTransientAudiofocusLoss) { // we paused => play now - mediaPlayer?.start() - } else { // we ducked => raise audio level back - setVolume(1.0f, 1.0f) - } + if (pausedBecauseOfTransientAudiofocusLoss) mediaPlayer?.start() // we paused => play now + else setVolume(1.0f, 1.0f) // we ducked => raise audio level back + pausedBecauseOfTransientAudiofocusLoss = false } } @@ -639,9 +606,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia // we're relying on the position stored in the Playable object for post-playback processing val position = getPosition() - if (position >= 0) { - media?.setPosition(position) - } + if (position >= 0) media?.setPosition(position) mediaPlayer?.reset() @@ -706,15 +671,9 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia mp.setOnSeekCompleteListener(Runnable { this.genericSeekCompleteListener() }) mp.setOnBufferingUpdateListener(Consumer { percent: Int -> when (percent) { - ExoPlayerWrapper.BUFFERING_STARTED -> { - EventBus.getDefault().post(BufferUpdateEvent.started()) - } - ExoPlayerWrapper.BUFFERING_ENDED -> { - EventBus.getDefault().post(BufferUpdateEvent.ended()) - } - else -> { - EventBus.getDefault().post(BufferUpdateEvent.progressUpdate(0.01f * percent)) - } + ExoPlayerWrapper.BUFFERING_STARTED -> EventBus.getDefault().post(BufferUpdateEvent.started()) + ExoPlayerWrapper.BUFFERING_ENDED -> EventBus.getDefault().post(BufferUpdateEvent.ended()) + else -> EventBus.getDefault().post(BufferUpdateEvent.progressUpdate(0.01f * percent)) } }) mp.setOnErrorListener(Consumer { message: String -> @@ -737,9 +696,7 @@ class LocalPSMP(context: Context, callback: PSMPCallback) : PlaybackServiceMedia if (playerStatus == PlayerStatus.PLAYING) { if (media != null) callback.onPlaybackStart(media!!, getPosition()) } - if (playerStatus == PlayerStatus.SEEKING && statusBeforeSeeking != null) { - setPlayerStatus(statusBeforeSeeking!!, media, getPosition()) - } + if (playerStatus == PlayerStatus.SEEKING && statusBeforeSeeking != null) setPlayerStatus(statusBeforeSeeking!!, media, getPosition()) } override fun isCasting(): Boolean { diff --git a/app/src/main/java/ac/mdiq/podcini/playback/service/NotificationPlayerCustomCommandButton.kt b/app/src/main/java/ac/mdiq/podcini/playback/service/NotificationPlayerCustomCommandButton.kt new file mode 100644 index 00000000..7f6fd0a1 --- /dev/null +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/NotificationPlayerCustomCommandButton.kt @@ -0,0 +1,28 @@ +package ac.mdiq.podcini.playback.service + +import ac.mdiq.podcini.R +import android.os.Bundle +import androidx.media3.session.CommandButton +import androidx.media3.session.SessionCommand + +private const val CUSTOM_COMMAND_REWIND_ACTION_ID = "REWIND_15" +private const val CUSTOM_COMMAND_FORWARD_ACTION_ID = "FAST_FWD_15" + +enum class NotificationPlayerCustomCommandButton(val customAction: String, val commandButton: CommandButton) { + REWIND( + customAction = CUSTOM_COMMAND_REWIND_ACTION_ID, + commandButton = CommandButton.Builder() + .setDisplayName("Rewind") + .setSessionCommand(SessionCommand(CUSTOM_COMMAND_REWIND_ACTION_ID, Bundle())) + .setIconResId(R.drawable.ic_notification_fast_rewind) + .build(), + ), + FORWARD( + customAction = CUSTOM_COMMAND_FORWARD_ACTION_ID, + commandButton = CommandButton.Builder() + .setDisplayName("Forward") + .setSessionCommand(SessionCommand(CUSTOM_COMMAND_FORWARD_ACTION_ID, Bundle())) + .setIconResId(R.drawable.ic_notification_fast_forward) + .build(), + ); +} \ No newline at end of file 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 fe2de3fe..d53a15ce 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 @@ -47,7 +47,6 @@ import ac.mdiq.podcini.receiver.MediaButtonReceiver import ac.mdiq.podcini.service.playback.WearMediaSession import ac.mdiq.podcini.storage.DBReader import ac.mdiq.podcini.storage.DBWriter -import ac.mdiq.podcini.storage.model.feed.Feed import ac.mdiq.podcini.storage.model.feed.FeedItem import ac.mdiq.podcini.storage.model.feed.FeedMedia import ac.mdiq.podcini.storage.model.feed.FeedPreferences @@ -76,15 +75,12 @@ import android.bluetooth.BluetoothA2dp import android.content.* import android.content.pm.PackageManager import android.media.AudioManager -import android.net.Uri import android.os.Binder import android.os.Build import android.os.Build.VERSION_CODES import android.os.IBinder import android.os.Vibrator import android.service.quicksettings.TileService -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat @@ -95,15 +91,12 @@ import android.view.KeyEvent import android.view.SurfaceHolder import android.webkit.URLUtil import android.widget.Toast -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService import io.reactivex.Observable import io.reactivex.Single import io.reactivex.SingleEmitter @@ -122,7 +115,7 @@ import kotlin.math.max * Controls the MediaPlayer that plays a FeedMedia-file */ @UnstableApi -class PlaybackService : MediaLibraryService() { +class PlaybackService : MediaSessionService() { private var mediaPlayer: PlaybackServiceMediaPlayer? = null private var positionEventTimer: Disposable? = null @@ -266,8 +259,8 @@ class PlaybackService : MediaLibraryService() { EventBus.getDefault().unregister(this) } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? { - return null + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession } private fun loadQueueForMediaSession() { diff --git a/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt1 b/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt1 new file mode 100644 index 00000000..64d4d8b1 --- /dev/null +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackService.kt1 @@ -0,0 +1,1865 @@ +package ac.mdiq.podcini.playback.service + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink +import ac.mdiq.podcini.playback.PlayableUtils.saveCurrentPosition +import ac.mdiq.podcini.playback.PlaybackServiceStarter +import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer +import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPCallback +import ac.mdiq.podcini.playback.base.PlaybackServiceMediaPlayer.PSMPInfo +import ac.mdiq.podcini.playback.base.PlayerStatus +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.createInstanceFromPreferences +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.writeMediaPlaying +import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.writeNoMediaPlaying +import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.writePlayerStatus +import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable +import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableFrom +import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableTo +import ac.mdiq.podcini.preferences.SleepTimerPreferences.isInTimeRange +import ac.mdiq.podcini.preferences.SleepTimerPreferences.timerMillis +import ac.mdiq.podcini.preferences.UserPreferences.fastForwardSecs +import ac.mdiq.podcini.preferences.UserPreferences.getPlaybackSpeed +import ac.mdiq.podcini.preferences.UserPreferences.hardwareForwardButton +import ac.mdiq.podcini.preferences.UserPreferences.hardwarePreviousButton +import ac.mdiq.podcini.preferences.UserPreferences.isAllowMobileStreaming +import ac.mdiq.podcini.preferences.UserPreferences.isFollowQueue +import ac.mdiq.podcini.preferences.UserPreferences.isPauseOnHeadsetDisconnect +import ac.mdiq.podcini.preferences.UserPreferences.isPersistNotify +import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence +import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnBluetoothReconnect +import ac.mdiq.podcini.preferences.UserPreferences.isUnpauseOnHeadsetReconnect +import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs +import ac.mdiq.podcini.preferences.UserPreferences.setPlaybackSpeed +import ac.mdiq.podcini.preferences.UserPreferences.shouldFavoriteKeepEpisode +import ac.mdiq.podcini.preferences.UserPreferences.shouldSkipKeepEpisode +import ac.mdiq.podcini.preferences.UserPreferences.showNextChapterOnFullNotification +import ac.mdiq.podcini.preferences.UserPreferences.showPlaybackSpeedOnFullNotification +import ac.mdiq.podcini.preferences.UserPreferences.showSkipOnFullNotification +import ac.mdiq.podcini.preferences.UserPreferences.videoPlaybackSpeed +import ac.mdiq.podcini.receiver.MediaButtonReceiver +import ac.mdiq.podcini.service.playback.WearMediaSession +import ac.mdiq.podcini.storage.DBReader +import ac.mdiq.podcini.storage.DBWriter +import ac.mdiq.podcini.storage.model.feed.FeedItem +import ac.mdiq.podcini.storage.model.feed.FeedMedia +import ac.mdiq.podcini.storage.model.feed.FeedPreferences +import ac.mdiq.podcini.storage.model.feed.FeedPreferences.AutoDeleteAction +import ac.mdiq.podcini.storage.model.playback.MediaType +import ac.mdiq.podcini.storage.model.playback.Playable +import ac.mdiq.podcini.ui.activity.appstartintent.MainActivityStarter +import ac.mdiq.podcini.ui.activity.appstartintent.VideoPlayerActivityStarter +import ac.mdiq.podcini.ui.utils.NotificationUtils +import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState +import ac.mdiq.podcini.util.FeedItemUtil.hasAlmostEnded +import ac.mdiq.podcini.util.FeedUtil.shouldAutoDeleteItemsOnThatFeed +import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast +import ac.mdiq.podcini.util.NetworkUtils.isStreamingAllowed +import ac.mdiq.podcini.util.event.MessageEvent +import ac.mdiq.podcini.util.event.PlayerErrorEvent +import ac.mdiq.podcini.util.event.playback.* +import ac.mdiq.podcini.util.event.settings.SkipIntroEndingChangedEvent +import ac.mdiq.podcini.util.event.settings.SpeedPresetChangedEvent +import ac.mdiq.podcini.util.event.settings.VolumeAdaptionChangedEvent +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.bluetooth.BluetoothA2dp +import android.content.* +import android.content.pm.PackageManager +import android.media.AudioManager +import android.os.* +import android.os.Build.VERSION_CODES +import android.service.quicksettings.TileService +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.text.TextUtils +import android.util.Log +import android.util.Pair +import android.view.KeyEvent +import android.view.SurfaceHolder +import android.webkit.URLUtil +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.* +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.SingleEmitter +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.concurrent.Volatile +import kotlin.math.max + +/** + * Controls the MediaPlayer that plays a FeedMedia-file + */ +@UnstableApi +class PlaybackService : MediaLibraryService() { + private var mediaPlayer: PlaybackServiceMediaPlayer? = null + private var positionEventTimer: Disposable? = null + + private lateinit var customMediaNotificationProvider: CustomMediaNotificationProvider + private val notificationPlayerCustomCommandButtons = NotificationPlayerCustomCommandButton.values().map { command -> command.commandButton } + + private lateinit var mediaLibrarySession: MediaLibrarySession + + private lateinit var taskManager: PlaybackServiceTaskManager + private lateinit var stateManager: PlaybackServiceStateManager +// private lateinit var notificationBuilder: PlaybackServiceNotificationBuilder + private lateinit var castStateListener: CastStateListener + + private var autoSkippedFeedMediaId: String? = null +// private var clickCount = 0 +// private val clickHandler = Handler(Looper.getMainLooper()) + + private var isSpeedForward = false + private var normalSpeed = 1.0f + private var isFallbackSpeed = false + + private var currentitem: FeedItem? = null + + /** + * Used for Lollipop notifications, Android Wear, and Android Auto. + */ + private var mediaSession: MediaSession? = null + + private val mBinder: IBinder = LocalBinder() + + inner class LocalBinder : Binder() { + val service: PlaybackService + get() = this@PlaybackService + } + + override fun onUnbind(intent: Intent): Boolean { + Log.d(TAG, "Received onUnbind event") + return super.onUnbind(intent) + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service created.") + isRunning = true + + this.startForeground() + + stateManager = PlaybackServiceStateManager(this) +// notificationBuilder = PlaybackServiceNotificationBuilder(this) + + if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { + registerReceiver(autoStateUpdated, IntentFilter("com.google.android.gms.car.media.STATUS"), RECEIVER_NOT_EXPORTED) + registerReceiver(shutdownReceiver, IntentFilter(PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE), RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(autoStateUpdated, IntentFilter("com.google.android.gms.car.media.STATUS")) + registerReceiver(shutdownReceiver, IntentFilter(PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE)) + } + + registerReceiver(headsetDisconnected, IntentFilter(Intent.ACTION_HEADSET_PLUG)) + registerReceiver(bluetoothStateUpdated, IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)) + registerReceiver(audioBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) + EventBus.getDefault().register(this) + taskManager = PlaybackServiceTaskManager(this, taskManagerCallback) + + recreateMediaSessionIfNeeded() + castStateListener = object : CastStateListener(this) { + override fun onSessionStartedOrEnded() { + recreateMediaPlayer() + } + } + EventBus.getDefault().post(PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_STARTED)) + } + + fun recreateMediaSessionIfNeeded() { + if (mediaSession != null) return + + customMediaNotificationProvider = CustomMediaNotificationProvider(applicationContext) + setMediaNotificationProvider(customMediaNotificationProvider) + + if (ExoPlayerWrapper.exoPlayer == null) ExoPlayerWrapper.createStaticPlayer(applicationContext) + mediaSession = MediaSession.Builder(applicationContext, ExoPlayerWrapper.exoPlayer!!) + .setCallback(MyCallback()) + .setCustomLayout(notificationPlayerCustomCommandButtons) + .build() + + recreateMediaPlayer() + } + + fun recreateMediaPlayer() { + var media: Playable? = null + var wasPlaying = false + if (mediaPlayer != null) { + media = mediaPlayer!!.getPlayable() + wasPlaying = mediaPlayer!!.playerStatus == PlayerStatus.PLAYING || mediaPlayer!!.playerStatus == PlayerStatus.FALLBACK + mediaPlayer!!.pause(true, false) + mediaPlayer!!.shutdown() + } + mediaPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback) + if (mediaPlayer == null) { + mediaPlayer = LocalPSMP(applicationContext, mediaPlayerCallback) // Cast not supported or not connected + } + if (media != null) { + mediaPlayer!!.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true) + } + isCasting = mediaPlayer!!.isCasting() + } + + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "Service is about to be destroyed") + +// if (notificationBuilder.playerStatus == PlayerStatus.PLAYING || notificationBuilder.playerStatus == PlayerStatus.FALLBACK) { +// notificationBuilder.playerStatus = PlayerStatus.STOPPED +// val notificationManager = NotificationManagerCompat.from(this) +// if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this, +// Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { +// // TODO: Consider calling +// // ActivityCompat#requestPermissions +//// requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) +// // here to request the missing permissions, and then overriding +// // public void onRequestPermissionsResult(int requestCode, String[] permissions, +// // int[] grantResults) +// // to handle the case where the user grants the permission. See the documentation +// // for ActivityCompat#requestPermissions for more details. +// Log.e(TAG, "onDestroy: require POST_NOTIFICATIONS permission") +// Toast.makeText(applicationContext, R.string.notification_permission_text, Toast.LENGTH_LONG).show() +// return +// } +// notificationManager.notify(R.id.notification_playing, notificationBuilder.build()) +// } + stateManager.stopForeground(!isPersistNotify) + isRunning = false + currentMediaType = MediaType.UNKNOWN + castStateListener.destroy() + + cancelPositionObserver() + mediaSession?.run { + player.release() + release() + mediaSession = null + } + ExoPlayerWrapper.exoPlayer?.release() + ExoPlayerWrapper.exoPlayer = null + mediaPlayer?.shutdown() + + unregisterReceiver(autoStateUpdated) + unregisterReceiver(headsetDisconnected) + unregisterReceiver(shutdownReceiver) + unregisterReceiver(bluetoothStateUpdated) + unregisterReceiver(audioBecomingNoisy) + taskManager.shutdown() + EventBus.getDefault().unregister(this) + } + + private inner class MyCallback : MediaSession.Callback { + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + Log.d(TAG, "in onConnect") + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailablePlayerCommands( + MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() +// .remove(COMMAND_SEEK_TO_NEXT) +// .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) +// .remove(COMMAND_SEEK_TO_PREVIOUS) +// .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build() + ) + .setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() +// .add(customCommandFavorites) + .build() + ) + .build() + +// val connectionResult = super.onConnect(session, controller) +// val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() +// +// /* Registering custom player command buttons for player notification. */ +// notificationPlayerCustomCommandButtons.forEach { commandButton -> +// Log.d(TAG, "onConnect commandButton ${commandButton.displayName}") +// commandButton.sessionCommand?.let(availableSessionCommands::add) +// } +// +// return MediaSession.ConnectionResult.accept( +// availableSessionCommands.build(), +// connectionResult.availablePlayerCommands +// ) + } + + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + super.onPostConnect(session, controller) + if (notificationPlayerCustomCommandButtons.isNotEmpty()) { + /* Setting custom player command buttons to mediaLibrarySession for player notification. */ + mediaLibrarySession.setCustomLayout(notificationPlayerCustomCommandButtons) + } + } + + override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { + /* Handling custom command buttons from player notification. */ + if (customCommand.customAction == NotificationPlayerCustomCommandButton.REWIND.customAction) { + session.player.seekBack() + } + if (customCommand.customAction == NotificationPlayerCustomCommandButton.FORWARD.customAction) { + session.player.seekForward() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? { + return null + } + + private fun loadQueueForMediaSession() { + Single.create { emitter: SingleEmitter?> -> + val queueItems: MutableList = ArrayList() + for (feedItem in DBReader.getQueue()) { + if (feedItem.media != null) { + val mediaDescription = feedItem.media!!.mediaItem.description + queueItems.add(MediaSessionCompat.QueueItem(mediaDescription, feedItem.id)) + } + } + emitter.onSuccess(queueItems) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ queueItems: List? -> +// mediaSession?.setQueue(queueItems) + }, + { obj: Throwable -> obj.printStackTrace() }) + } + +// private fun createBrowsableMediaItem( +// @StringRes title: Int, @DrawableRes icon: Int, numEpisodes: Int +// ): MediaBrowserCompat.MediaItem { +// val uri = Uri.Builder() +// .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) +// .authority(resources.getResourcePackageName(icon)) +// .appendPath(resources.getResourceTypeName(icon)) +// .appendPath(resources.getResourceEntryName(icon)) +// .build() +// +// val description = MediaDescriptionCompat.Builder() +// .setIconUri(uri) +// .setMediaId(resources.getString(title)) +// .setTitle(resources.getString(title)) +// .setSubtitle(resources.getQuantityString(R.plurals.num_episodes, numEpisodes, numEpisodes)) +// .build() +// return MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) +// } + +// private fun createBrowsableMediaItemForFeed(feed: Feed): MediaBrowserCompat.MediaItem { +// val builder = MediaDescriptionCompat.Builder() +// .setMediaId("FeedId:" + feed.id) +// .setTitle(feed.title) +// .setDescription(feed.description) +// .setSubtitle(feed.getCustomTitle()) +// if (feed.imageUrl != null) { +// builder.setIconUri(Uri.parse(feed.imageUrl)) +// } +// if (feed.link != null) { +// builder.setMediaUri(Uri.parse(feed.link)) +// } +// val description = builder.build() +// return MediaBrowserCompat.MediaItem(description, +// MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) +// } + +// override fun onLoadChildren(parentId: String, +// result: Result> +// ) { +// Log.d(TAG, "OnLoadChildren: parentMediaId=$parentId") +// result.detach() +// +// Completable.create { emitter: CompletableEmitter -> +// result.sendResult(loadChildrenSynchronous(parentId)) +// emitter.onComplete() +// } +// .subscribeOn(Schedulers.io()) +// .observeOn(AndroidSchedulers.mainThread()) +// .subscribe( +// {}, { e: Throwable -> +// e.printStackTrace() +// result.sendResult(null) +// }) +// } + +// private fun loadChildrenSynchronous(parentId: String): List? { +// val mediaItems: MutableList = ArrayList() +// if (parentId == resources.getString(R.string.app_name)) { +// val currentlyPlaying = currentPlayerStatus.toLong() +// if (currentlyPlaying == PlaybackPreferences.PLAYER_STATUS_PLAYING.toLong() +// || currentlyPlaying == PlaybackPreferences.PLAYER_STATUS_PAUSED.toLong()) { +// mediaItems.add(createBrowsableMediaItem(R.string.current_playing_episode, R.drawable.ic_play_48dp, 1)) +// } +// mediaItems.add(createBrowsableMediaItem(R.string.queue_label, R.drawable.ic_playlist_play_black, +// DBReader.getTotalEpisodeCount(FeedItemFilter(FeedItemFilter.QUEUED)))) +// mediaItems.add(createBrowsableMediaItem(R.string.downloads_label, R.drawable.ic_download_black, +// DBReader.getTotalEpisodeCount(FeedItemFilter(FeedItemFilter.DOWNLOADED)))) +// mediaItems.add(createBrowsableMediaItem(R.string.episodes_label, R.drawable.ic_feed_black, +// DBReader.getTotalEpisodeCount(FeedItemFilter(FeedItemFilter.UNPLAYED)))) +// val feeds = DBReader.getFeedList() +// for (feed in feeds) { +// mediaItems.add(createBrowsableMediaItemForFeed(feed)) +// } +// return mediaItems +// } +// +// val feedItems: List +// when { +// parentId == resources.getString(R.string.queue_label) -> { +// feedItems = DBReader.getQueue() +// } +// parentId == resources.getString(R.string.downloads_label) -> { +// feedItems = DBReader.getEpisodes(0, MAX_ANDROID_AUTO_EPISODES_PER_FEED, +// FeedItemFilter(FeedItemFilter.DOWNLOADED), downloadsSortedOrder) +// } +// parentId == resources.getString(R.string.episodes_label) -> { +// feedItems = DBReader.getEpisodes(0, MAX_ANDROID_AUTO_EPISODES_PER_FEED, +// FeedItemFilter(FeedItemFilter.UNPLAYED), allEpisodesSortOrder) +// } +// parentId.startsWith("FeedId:") -> { +// val feedId = parentId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1].toLong() +// val feed = DBReader.getFeed(feedId) +// feedItems = if (feed != null) DBReader.getFeedItemList(feed, FeedItemFilter.unfiltered(), feed.sortOrder) else listOf() +// } +// parentId == getString(R.string.current_playing_episode) -> { +// val playable = createInstanceFromPreferences(this) +// if (playable is FeedMedia) { +// feedItems = listOf(playable.item) +// } else { +// return null +// } +// } +// else -> { +// Log.e(TAG, "Parent ID not found: $parentId") +// return null +// } +// } +// var count = 0 +// for (feedItem in feedItems) { +// if (feedItem?.media != null) { +// mediaItems.add(feedItem.media!!.mediaItem) +// if (++count >= MAX_ANDROID_AUTO_EPISODES_PER_FEED) { +// break +// } +// } +// } +// return mediaItems +// } + + override fun onBind(intent: Intent?): IBinder? { + Log.d(TAG, "Received onBind event") + return if (intent?.action != null && TextUtils.equals(intent.action, SERVICE_INTERFACE)) { + super.onBind(intent) + } else { + mBinder + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + Log.d(TAG, "OnStartCommand called") + + stateManager.startForeground(R.id.notification_playing, notificationBuilder.build()) +// val notificationManager = NotificationManagerCompat.from(this) +// notificationManager.cancel(R.id.notification_streaming_confirmation) + + 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 + val playable = intent?.getParcelableExtra(PlaybackServiceInterface.EXTRA_PLAYABLE) + if (keycode == -1 && playable == null && customAction == null) { + Log.e(TAG, "PlaybackService was started with no arguments") + stateManager.stopService() + return START_NOT_STICKY + } + + if ((flags and START_FLAG_REDELIVERY) != 0) { + Log.d(TAG, "onStartCommand is a redelivered intent, calling stopForeground now.") + stateManager.stopForeground(true) + } else { + when { + keycode != -1 -> { + val notificationButton: Boolean + if (hardwareButton) { + Log.d(TAG, "Received hardware button event") + notificationButton = false + } else { + Log.d(TAG, "Received media button event") + notificationButton = true + } + val handled = handleKeycode(keycode, notificationButton) + if (!handled && !stateManager.hasReceivedValidStartCommand()) { + stateManager.stopService() + return START_NOT_STICKY + } + } + playable != null -> { + stateManager.validStartCommandWasReceived() + val allowStreamThisTime = intent.getBooleanExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME, false) + val allowStreamAlways = intent.getBooleanExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_ALWAYS, false) + sendNotificationBroadcast(PlaybackServiceInterface.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 -> + Log.d(TAG, "Playable was not found. Stopping service.") + error.printStackTrace() + stateManager.stopService() + }) + return START_NOT_STICKY + } + else -> { +// mediaSession?.controller?.transportControls?.sendCustomAction(customAction, null) + } + } + } + + return START_NOT_STICKY + } + + private fun skipIntro(playable: Playable) { + val item = (playable as? FeedMedia)?.item ?: currentitem ?: return +// val item = currentitem ?: (playable as? FeedMedia)?.item ?: return + val feed = item.feed ?: DBReader.getFeed(item.feedId) + val preferences = feed?.preferences + + val skipIntro = preferences?.feedSkipIntro ?: 0 + val skipIntroMS = skipIntro * 1000 + if (skipIntro > 0 && playable.getPosition() < skipIntroMS) { + val duration = duration + if (skipIntroMS < duration || duration <= 0) { + Log.d(TAG, "skipIntro " + playable.getEpisodeTitle()) + mediaPlayer?.seekTo(skipIntroMS) + val skipIntroMesg = applicationContext.getString(R.string.pref_feed_skip_intro_toast, skipIntro) + val toast = Toast.makeText(applicationContext, skipIntroMesg, Toast.LENGTH_LONG) + toast.show() + } + } + } + + @SuppressLint("LaunchActivityFromNotification") + private fun displayStreamingNotAllowedNotification(originalIntent: Intent) { + if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent::class.java)) { + EventBus.getDefault().post(MessageEvent(getString(R.string.confirm_mobile_streaming_notification_message))) + return + } + + val intentAllowThisTime = Intent(originalIntent) + intentAllowThisTime.setAction(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME) + intentAllowThisTime.putExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME, true) + val pendingIntentAllowThisTime = if (Build.VERSION.SDK_INT >= VERSION_CODES.O) { + PendingIntent.getForegroundService(this, R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getService(this, R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val intentAlwaysAllow = Intent(intentAllowThisTime) + intentAlwaysAllow.setAction(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_ALWAYS) + intentAlwaysAllow.putExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_ALWAYS, true) + val pendingIntentAlwaysAllow = if (Build.VERSION.SDK_INT >= VERSION_CODES.O) { + PendingIntent.getForegroundService(this, R.id.pending_intent_allow_stream_always, intentAlwaysAllow, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getService(this, R.id.pending_intent_allow_stream_always, intentAlwaysAllow, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val builder = NotificationCompat.Builder(this, + NotificationUtils.CHANNEL_ID_USER_ACTION) + .setSmallIcon(R.drawable.ic_notification_stream) + .setContentTitle(getString(R.string.confirm_mobile_streaming_notification_title)) + .setContentText(getString(R.string.confirm_mobile_streaming_notification_message)) + .setStyle(NotificationCompat.BigTextStyle() + .bigText(getString(R.string.confirm_mobile_streaming_notification_message))) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntentAllowThisTime) + .addAction(R.drawable.ic_notification_stream, getString(R.string.confirm_mobile_streaming_button_once), pendingIntentAllowThisTime) + .addAction(R.drawable.ic_notification_stream, getString(R.string.confirm_mobile_streaming_button_always), pendingIntentAlwaysAllow) + .setAutoCancel(true) +// val notificationManager = NotificationManagerCompat.from(this) + if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this, + Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions +// requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + Log.e(TAG, "displayStreamingNotAllowedNotification: require POST_NOTIFICATIONS permission") + Toast.makeText(applicationContext, R.string.notification_permission_text, Toast.LENGTH_LONG).show() + return + } +// notificationManager.notify(R.id.notification_streaming_confirmation, builder.build()) + } + + /** + * Handles media button events + * return: keycode was handled + */ + private fun handleKeycode(keycode: Int, notificationButton: Boolean): Boolean { + Log.d(TAG, "Handling keycode: $keycode") + val info = mediaPlayer?.pSMPInfo + val status = info?.playerStatus + when (keycode) { + KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + when { + status == PlayerStatus.PLAYING -> { + mediaPlayer?.pause(!isPersistNotify, false) + } + status == PlayerStatus.FALLBACK || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> { + mediaPlayer?.resume() + } + status == PlayerStatus.PREPARING -> { + mediaPlayer?.setStartWhenPrepared(!mediaPlayer!!.isStartWhenPrepared()) + } + status == PlayerStatus.INITIALIZED -> { + mediaPlayer?.setStartWhenPrepared(true) + mediaPlayer?.prepare() + } + mediaPlayer?.getPlayable() == null -> { + startPlayingFromPreferences() + } + else -> { + return false + } + } + taskManager.restartSleepTimer() + return true + } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + when { + status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> { + mediaPlayer?.resume() + } + status == PlayerStatus.INITIALIZED -> { + mediaPlayer?.setStartWhenPrepared(true) + mediaPlayer?.prepare() + } + mediaPlayer?.getPlayable() == null -> { + startPlayingFromPreferences() + } + else -> { + return false + } + } + taskManager.restartSleepTimer() + return true + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + if (status == PlayerStatus.PLAYING) { + mediaPlayer?.pause(!isPersistNotify, false) + return true + } + return false + } + KeyEvent.KEYCODE_MEDIA_NEXT -> { + when { + !notificationButton -> { + // Handle remapped button as notification button which is not remapped again. + return handleKeycode(hardwareForwardButton, true) + } + this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED -> { + mediaPlayer?.skip() + return true + } + else -> return false + } + } + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + if (this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) { + mediaPlayer?.seekDelta(fastForwardSecs * 1000) + return true + } + return false + } + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + when { + !notificationButton -> { + // Handle remapped button as notification button which is not remapped again. + return handleKeycode(hardwarePreviousButton, true) + } + this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED -> { + mediaPlayer?.seekTo(0) + return true + } + else -> return false + } + } + KeyEvent.KEYCODE_MEDIA_REWIND -> { + if (this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) { + mediaPlayer?.seekDelta(-rewindSecs * 1000) + return true + } + return false + } + KeyEvent.KEYCODE_MEDIA_STOP -> { + if (this.status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING) { + mediaPlayer?.pause(true, true) + } + stateManager.stopForeground(true) // gets rid of persistent notification + return true + } + else -> { + Log.d(TAG, "Unhandled key code: $keycode") + if (info?.playable != null && info.playerStatus == PlayerStatus.PLAYING) { + // only notify the user about an unknown key event if it is actually doing something + val message = String.format(resources.getString(R.string.unknown_media_key), keycode) + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + } + } + return false + } + + private fun startPlayingFromPreferences() { + Observable.fromCallable { + createInstanceFromPreferences(applicationContext) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { playable: Playable? -> startPlaying(playable, false) }, + { error: Throwable -> + Log.d(TAG, "Playable was not loaded from preferences. Stopping service.") + error.printStackTrace() + stateManager.stopService() + }) + } + + private fun startPlaying(playable: Playable?, allowStreamThisTime: Boolean) { + if (playable == null) return + + val localFeed = URLUtil.isContentUrl(playable.getStreamUrl()) + val stream = !playable.localFileAvailable() || localFeed + if (stream && !localFeed && !isStreamingAllowed && !allowStreamThisTime) { + displayStreamingNotAllowedNotification(PlaybackServiceStarter(this, playable).intent) + writeNoMediaPlaying() + stateManager.stopService() + return + } + + if (playable.getIdentifier() != currentlyPlayingFeedMediaId) { + clearCurrentlyPlayingTemporaryPlaybackSpeed() + } + + mediaPlayer?.playMediaObject(playable, stream, true, true) + stateManager.validStartCommandWasReceived() +// stateManager.startForeground(R.id.notification_playing, notificationBuilder.build()) + recreateMediaSessionIfNeeded() + updateNotificationAndMediaSession(playable) + addPlayableToQueue(playable) + } + + /** + * Called by a mediaplayer Activity as soon as it has prepared its + * mediaplayer. + */ + fun setVideoSurface(sh: SurfaceHolder?) { + Log.d(TAG, "Setting display") + mediaPlayer?.setVideoSurface(sh) + } + + fun notifyVideoSurfaceAbandoned() { + mediaPlayer?.pause(true, false) + mediaPlayer?.resetVideoSurface() + updateNotificationAndMediaSession(playable) + stateManager.stopForeground(!isPersistNotify) + } + + private val taskManagerCallback: PSTMCallback = object : PSTMCallback { + override fun positionSaverTick() { + saveCurrentPosition(true, null, Playable.INVALID_TIME) + } + + override fun requestWidgetState(): WidgetState { + return WidgetState(this@PlaybackService.playable, this@PlaybackService.status, + this@PlaybackService.currentPosition, this@PlaybackService.duration, this@PlaybackService.currentPlaybackSpeed) + } + + override fun onChapterLoaded(media: Playable?) { + sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD, 0) + updateMediaSession(mediaPlayer?.playerStatus) + } + } + + private val mediaPlayerCallback: PSMPCallback = object : PSMPCallback { + override fun statusChanged(newInfo: PSMPInfo?) { + currentMediaType = mediaPlayer?.getCurrentMediaType() ?: MediaType.UNKNOWN + Log.d(TAG, "statusChanged called") + updateMediaSession(newInfo?.playerStatus) + if (newInfo != null) { + when (newInfo.playerStatus) { + PlayerStatus.INITIALIZED -> { + if (mediaPlayer != null) { + writeMediaPlaying(mediaPlayer!!.pSMPInfo.playable, mediaPlayer!!.pSMPInfo.playerStatus, currentitem) + } + updateNotificationAndMediaSession(newInfo.playable) + } + PlayerStatus.PREPARED -> { + if (mediaPlayer != null) { + writeMediaPlaying(mediaPlayer!!.pSMPInfo.playable, mediaPlayer!!.pSMPInfo.playerStatus, currentitem) + } + taskManager.startChapterLoader(newInfo.playable!!) + } + PlayerStatus.PAUSED -> { + updateNotificationAndMediaSession(newInfo.playable) + if (!isCasting) { + stateManager.stopForeground(!isPersistNotify) + } + cancelPositionObserver() + if (mediaPlayer != null) writePlayerStatus(mediaPlayer!!.playerStatus) + } + PlayerStatus.STOPPED -> {} + PlayerStatus.PLAYING -> { + if (mediaPlayer != null) writePlayerStatus(mediaPlayer!!.playerStatus) + saveCurrentPosition(true, null, Playable.INVALID_TIME) + recreateMediaSessionIfNeeded() + updateNotificationAndMediaSession(newInfo.playable) + setupPositionObserver() + stateManager.validStartCommandWasReceived() +// stateManager.startForeground(R.id.notification_playing, notificationBuilder.build()) + // set sleep timer if auto-enabled + var autoEnableByTime = true + val fromSetting = autoEnableFrom() + val toSetting = autoEnableTo() + if (fromSetting != toSetting) { + val now: Calendar = GregorianCalendar() + now.timeInMillis = System.currentTimeMillis() + val currentHour = now[Calendar.HOUR_OF_DAY] + autoEnableByTime = isInTimeRange(fromSetting, toSetting, currentHour) + } + + if (newInfo.oldPlayerStatus != null && newInfo.oldPlayerStatus != PlayerStatus.SEEKING && autoEnable() && autoEnableByTime && !sleepTimerActive()) { + setSleepTimer(timerMillis()) + EventBus.getDefault().post(MessageEvent(getString(R.string.sleep_timer_enabled_label), { disableSleepTimer() }, getString(R.string.undo))) + } + loadQueueForMediaSession() + } + PlayerStatus.ERROR -> { + writeNoMediaPlaying() + stateManager.stopService() + } + else -> {} + } + } + if (Build.VERSION.SDK_INT >= VERSION_CODES.N) { + TileService.requestListeningState(applicationContext, ComponentName(applicationContext, QuickSettingsTileService::class.java)) + } + + sendLocalBroadcast(applicationContext, ACTION_PLAYER_STATUS_CHANGED) + bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED) + bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED) + taskManager.requestWidgetUpdate() + } + + override fun shouldStop() { + stateManager.stopForeground(!isPersistNotify) + } + + override fun onMediaChanged(reloadUI: Boolean) { + Log.d(TAG, "reloadUI callback reached") + if (reloadUI) { + sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD, 0) + } + updateNotificationAndMediaSession(this@PlaybackService.playable) + } + + override fun onPostPlayback(media: Playable, ended: Boolean, skipped: Boolean, playingNext: Boolean) { + this@PlaybackService.onPostPlayback(media, ended, skipped, playingNext) + } + + override fun onPlaybackStart(playable: Playable, position: Int) { + taskManager.startWidgetUpdater() + if (position != Playable.INVALID_TIME) { + playable.setPosition(position) + } else { + skipIntro(playable) + } + playable.onPlaybackStart() + taskManager.startPositionSaver() + } + + override fun onPlaybackPause(playable: Playable?, position: Int) { + taskManager.cancelPositionSaver() + cancelPositionObserver() + saveCurrentPosition(position == Playable.INVALID_TIME || playable == null, playable, position) + taskManager.cancelWidgetUpdater() + if (playable != null) { + if (playable is FeedMedia) { + SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(applicationContext, playable, false) + } + playable.onPlaybackPause(applicationContext) + } + } + + override fun getNextInQueue(currentMedia: Playable?): Playable? { + return this@PlaybackService.getNextInQueue(currentMedia) + } + + override fun findMedia(url: String): Playable? { + val item = DBReader.getFeedItemByGuidOrEpisodeUrl(null, url) + return item?.media + } + + override fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean) { + this@PlaybackService.onPlaybackEnded(mediaType, stopPlaying) + } + + override fun ensureMediaInfoLoaded(media: Playable) { + if (media is FeedMedia && media.item == null) { + media.setItem(DBReader.getFeedItem(media.itemId)) + } + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @Suppress("unused") + fun playerError(event: PlayerErrorEvent?) { + if (mediaPlayer?.playerStatus == PlayerStatus.PLAYING || mediaPlayer?.playerStatus == PlayerStatus.FALLBACK) { + mediaPlayer!!.pause(true, false) + } + stateManager.stopService() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @Suppress("unused") + fun bufferUpdate(event: BufferUpdateEvent) { + if (event.hasEnded()) { + val playable = playable + if (this.playable is FeedMedia && playable!!.getDuration() <= 0 && (mediaPlayer?.getDuration()?:0) > 0) { + // Playable is being streamed and does not have a duration specified in the feed + playable.setDuration(mediaPlayer!!.getDuration()) + DBWriter.setFeedMedia(playable as FeedMedia?) + updateNotificationAndMediaSession(playable) + } + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @Suppress("unused") + fun sleepTimerUpdate(event: SleepTimerUpdatedEvent) { + when { + event.isOver -> { + mediaPlayer?.pause(true, true) + mediaPlayer?.setVolume(1.0f, 1.0f) + } + event.getTimeLeft() < PlaybackServiceTaskManager.NOTIFICATION_THRESHOLD -> { + val multiplicators = floatArrayOf(0.1f, 0.2f, 0.3f, 0.3f, 0.3f, 0.4f, 0.4f, 0.4f, 0.6f, 0.8f) + val multiplicator = multiplicators[max(0.0, (event.getTimeLeft().toInt() / 1000).toDouble()) + .toInt()] + Log.d(TAG, "onSleepTimerAlmostExpired: $multiplicator") + mediaPlayer?.setVolume(multiplicator, multiplicator) + } + event.isCancelled -> { + mediaPlayer?.setVolume(1.0f, 1.0f) + } + } + } + + private fun getNextInQueue(currentMedia: Playable?): Playable? { + if (currentMedia !is FeedMedia) { + Log.d(TAG, "getNextInQueue(), but playable not an instance of FeedMedia, so not proceeding") + writeNoMediaPlaying() + return null + } + Log.d(TAG, "getNextInQueue()") + if (currentMedia.item == null) { + currentMedia.setItem(DBReader.getFeedItem(currentMedia.itemId)) + } + val item = currentMedia.item + if (item == null) { + Log.w(TAG, "getNextInQueue() with FeedMedia object whose FeedItem is null") + writeNoMediaPlaying() + return null + } + val nextItem = DBReader.getNextInQueue(item) + + if (nextItem?.media == null) { + writeNoMediaPlaying() + return null + } + + if (!isFollowQueue) { + Log.d(TAG, "getNextInQueue(), but follow queue is not enabled.") + writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED, currentitem) + updateNotificationAndMediaSession(nextItem.media) + return null + } + + if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed != null && !nextItem.feed!!.isLocalFeed) { + displayStreamingNotAllowedNotification(PlaybackServiceStarter(this, nextItem.media!!).intent) + writeNoMediaPlaying() + stateManager.stopService() + return null + } + return nextItem.media + } + + /** + * Set of instructions to be performed when playback ends. + */ + private fun onPlaybackEnded(mediaType: MediaType?, stopPlaying: Boolean) { + Log.d(TAG, "Playback ended") + clearCurrentlyPlayingTemporaryPlaybackSpeed() + if (stopPlaying) { + taskManager.cancelPositionSaver() + cancelPositionObserver() + if (!isCasting) { + stateManager.stopForeground(true) + stateManager.stopService() + } + } + if (mediaType == null) { + sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_PLAYBACK_END, 0) + } else { + sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD, + when { + isCasting -> PlaybackServiceInterface.EXTRA_CODE_CAST + mediaType == MediaType.VIDEO -> PlaybackServiceInterface.EXTRA_CODE_VIDEO + else -> PlaybackServiceInterface.EXTRA_CODE_AUDIO + }) + } + } + + /** + * This method processes the media object after its playback ended, either because it completed + * or because a different media object was selected for playback. + * + * + * Even though these tasks aren't supposed to be resource intensive, a good practice is to + * usually call this method on a background thread. + * + * @param playable the media object that was playing. It is assumed that its position + * property was updated before this method was called. + * @param ended if true, it signals that {@param playable} was played until its end. + * In such case, the position property of the media becomes irrelevant for + * most of the tasks (although it's still a good practice to keep it + * accurate). + * @param skipped if the user pressed a skip >| button. + * @param playingNext if true, it means another media object is being loaded in place of this + * one. + * Instances when we'd set it to false would be when we're not following the + * queue or when the queue has ended. + */ + private fun onPostPlayback(playable: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean) { + if (playable == null) { + Log.e(TAG, "Cannot do post-playback processing: media was null") + return + } + Log.d(TAG, "onPostPlayback(): media=" + playable.getEpisodeTitle()) + + if (playable !is FeedMedia) { + Log.d(TAG, "Not doing post-playback processing: media not of type FeedMedia") + if (ended) { + playable.onPlaybackCompleted(applicationContext) + } else { + playable.onPlaybackPause(applicationContext) + } +// return + } + val media = playable + val item = (media as? FeedMedia)?.item ?: currentitem + val smartMarkAsPlayed = hasAlmostEnded(media) + if (!ended && smartMarkAsPlayed) { + Log.d(TAG, "smart mark as played") + } + + var autoSkipped = false + if (autoSkippedFeedMediaId != null && autoSkippedFeedMediaId == item?.identifyingValue) { + autoSkippedFeedMediaId = null + autoSkipped = true + } + + if (media is FeedMedia) { + if (ended || smartMarkAsPlayed) { + SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(applicationContext, media, true) + media.onPlaybackCompleted(applicationContext) + } else { + SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(applicationContext, media, false) + media.onPlaybackPause(applicationContext) + } + } + + if (item != null) { + if (ended || smartMarkAsPlayed || autoSkipped || (skipped && !shouldSkipKeepEpisode())) { + // only mark the item as played if we're not keeping it anyways + DBWriter.markItemPlayed(item, FeedItem.PLAYED, ended || (skipped && smartMarkAsPlayed)) + // don't know if it actually matters to not autodownload when smart mark as played is triggered + DBWriter.removeQueueItem(this@PlaybackService, ended, item) + // Delete episode if enabled + val action = item.feed?.preferences?.currentAutoDelete + val shouldAutoDelete = (action == AutoDeleteAction.ALWAYS + || (action == AutoDeleteAction.GLOBAL && item.feed != null && shouldAutoDeleteItemsOnThatFeed(item.feed!!))) + if (media is FeedMedia && shouldAutoDelete && (!item.isTagged(FeedItem.TAG_FAVORITE) || !shouldFavoriteKeepEpisode())) { + DBWriter.deleteFeedMediaOfItem(this@PlaybackService, media.id) + Log.d(TAG, "Episode Deleted") + } +// notifyChildrenChanged(getString(R.string.queue_label)) + } + } + + if (media is FeedMedia && (ended || skipped || playingNext)) { + DBWriter.addItemToPlaybackHistory(media) + } + } + + fun setSleepTimer(waitingTime: Long) { + Log.d(TAG, "Setting sleep timer to $waitingTime milliseconds") + taskManager.setSleepTimer(waitingTime) + } + + fun disableSleepTimer() { + taskManager.disableSleepTimer() + } + + private fun sendNotificationBroadcast(type: Int, code: Int) { + val intent = Intent(PlaybackServiceInterface.ACTION_PLAYER_NOTIFICATION) + intent.putExtra(PlaybackServiceInterface.EXTRA_NOTIFICATION_TYPE, type) + intent.putExtra(PlaybackServiceInterface.EXTRA_NOTIFICATION_CODE, code) + intent.setPackage(packageName) + sendBroadcast(intent) + } + + private fun skipEndingIfNecessary() { + val playable = mediaPlayer?.getPlayable() as? FeedMedia + + val duration = duration + val remainingTime = duration - currentPosition + + val item = playable?.item ?: currentitem ?: return + val feed = item.feed ?: DBReader.getFeed(item.feedId) + val preferences = feed?.preferences + + val skipEnd = preferences?.feedSkipEnding?:0 + val skipEndMS = skipEnd * 1000 +// Log.d(TAG, "skipEndingIfNecessary: checking " + remainingTime + " " + skipEndMS + " speed " + currentPlaybackSpeed) + if (skipEnd > 0 && skipEndMS < this.duration && (remainingTime - skipEndMS < 0)) { + Log.d(TAG, "skipEndingIfNecessary: Skipping the remaining $remainingTime $skipEndMS speed $currentPlaybackSpeed") + val context = applicationContext + val skipMesg = context.getString(R.string.pref_feed_skip_ending_toast, skipEnd) + val toast = Toast.makeText(context, skipMesg, Toast.LENGTH_LONG) + toast.show() + + this.autoSkippedFeedMediaId = item.identifyingValue + mediaPlayer?.skip() + } + } + + /** + * Updates the Media Session for the corresponding status. + * + * @param playerStatus the current [PlayerStatus] + */ + private fun updateMediaSession(playerStatus: PlayerStatus?) { + val sessionState = PlaybackStateCompat.Builder() + val state = if (playerStatus != null) { + when (playerStatus) { + PlayerStatus.PLAYING -> PlaybackStateCompat.STATE_PLAYING + PlayerStatus.FALLBACK -> PlaybackStateCompat.STATE_PLAYING + PlayerStatus.PREPARED, PlayerStatus.PAUSED -> PlaybackStateCompat.STATE_PAUSED + PlayerStatus.STOPPED -> PlaybackStateCompat.STATE_STOPPED + PlayerStatus.SEEKING -> PlaybackStateCompat.STATE_FAST_FORWARDING + PlayerStatus.PREPARING, PlayerStatus.INITIALIZING -> PlaybackStateCompat.STATE_CONNECTING + PlayerStatus.ERROR -> PlaybackStateCompat.STATE_ERROR + PlayerStatus.INITIALIZED, PlayerStatus.INDETERMINATE -> PlaybackStateCompat.STATE_NONE + } + } else { + PlaybackStateCompat.STATE_NONE + } + + sessionState.setState(state, currentPosition.toLong(), currentPlaybackSpeed) + val capabilities = (PlaybackStateCompat.ACTION_PLAY + or PlaybackStateCompat.ACTION_PLAY_PAUSE + or PlaybackStateCompat.ACTION_REWIND + or PlaybackStateCompat.ACTION_PAUSE + or PlaybackStateCompat.ACTION_FAST_FORWARD + or PlaybackStateCompat.ACTION_SEEK_TO + or PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED) + + sessionState.setActions(capabilities) + + // On Android Auto, custom actions are added in the following order around the play button, if no default + // actions are present: Near left, near right, far left, far right, additional actions panel + val rewindBuilder = PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_REWIND, getString(R.string.rewind_label), R.drawable.ic_notification_fast_rewind) + WearMediaSession.addWearExtrasToAction(rewindBuilder) + sessionState.addCustomAction(rewindBuilder.build()) + + val fastForwardBuilder = PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_FAST_FORWARD, getString(R.string.fast_forward_label), R.drawable.ic_notification_fast_forward) + WearMediaSession.addWearExtrasToAction(fastForwardBuilder) + sessionState.addCustomAction(fastForwardBuilder.build()) + + if (showPlaybackSpeedOnFullNotification()) { + sessionState.addCustomAction(PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_CHANGE_PLAYBACK_SPEED, + getString(R.string.playback_speed), R.drawable.ic_notification_playback_speed).build()) + } + + if (showNextChapterOnFullNotification()) { + if (!playable?.getChapters().isNullOrEmpty()) { + sessionState.addCustomAction(PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_NEXT_CHAPTER, + getString(R.string.next_chapter), R.drawable.ic_notification_next_chapter).build()) + } + } + + if (showSkipOnFullNotification()) { + sessionState.addCustomAction(PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_SKIP_TO_NEXT, + getString(R.string.skip_episode_label), R.drawable.ic_notification_skip).build() + ) + } + + if (mediaSession != null) { + WearMediaSession.mediaSessionSetExtraForWear(mediaSession!!) +// mediaSession!!.setPlaybackState(sessionState.build()) + } + } + + private fun updateNotificationAndMediaSession(p: Playable?) { + setupNotification(p) + updateMediaSessionMetadata(p) + } + + private fun updateMediaSessionMetadata(p: Playable?) { + if (p == null || mediaSession == null) return + + val builder = MediaMetadataCompat.Builder() + builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, p.getFeedTitle()) + builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, p.getEpisodeTitle()) + builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, p.getFeedTitle()) + builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, p.getDuration().toLong()) + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle()) + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, p.getFeedTitle()) + + +// if (notificationBuilder.isIconCached) { +// builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, notificationBuilder.cachedIcon) +// } else { +// var iconUri = p.getImageLocation() +// // Don't use embedded cover etc, which Android can't load +// if (p is FeedMedia) { +// val m = p +// if (m.item != null) { +// val item = m.item!! +// when { +// item.imageUrl != null -> { +// iconUri = item.imageUrl +// } +// item.feed != null -> { +// iconUri = item.feed!!.imageUrl +// } +// } +// } +// } +// if (!iconUri.isNullOrEmpty()) { +// builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri) +// } +// } + + if (stateManager.hasReceivedValidStartCommand()) { + mediaSession!!.setSessionActivity(PendingIntent.getActivity(this, R.id.pending_intent_player_activity, + getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT + or (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0))) +// try { +// mediaSession!!.setMetadata(builder.build()) +// } catch (e: OutOfMemoryError) { +// Log.e(TAG, "Setting media session metadata", e) +// builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, null) +// mediaSession!!.setMetadata(builder.build()) +// } + } + } + + /** + * Used by setupNotification to load notification data in another thread. + */ + private var playableIconLoaderThread: Thread? = null + + /** + * Prepares notification and starts the service in the foreground. + */ + @Synchronized + private fun setupNotification(playable: Playable?) { + Log.d(TAG, "setupNotification") + playableIconLoaderThread?.interrupt() + + if (playable == null || mediaPlayer == null) { + Log.d(TAG, "setupNotification: playable=$playable mediaPlayer=$mediaPlayer") + if (!stateManager.hasReceivedValidStartCommand()) { + stateManager.stopService() + } + return + } + + val playerStatus = mediaPlayer!!.playerStatus +// notificationBuilder.setPlayable(playable) +// if (mediaSession != null) notificationBuilder.setMediaSessionToken(mediaSession!!.getSessionCompatToken()) +// notificationBuilder.playerStatus = playerStatus +// notificationBuilder.updatePosition(currentPosition, currentPlaybackSpeed) + +// val notificationManager = NotificationManagerCompat.from(this) + if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this, + Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions +// requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + Log.e(TAG, "setupNotification: require POST_NOTIFICATIONS permission") + Toast.makeText(applicationContext, R.string.notification_permission_text, Toast.LENGTH_LONG).show() + return + } +// notificationManager.notify(R.id.notification_playing, notificationBuilder.build()) + +// if (!notificationBuilder.isIconCached) { +// playableIconLoaderThread = Thread { +// Log.d(TAG, "Loading notification icon") +// notificationBuilder.loadIcon() +// if (!Thread.currentThread().isInterrupted) { +// notificationManager.notify(R.id.notification_playing, notificationBuilder.build()) +// updateMediaSessionMetadata(playable) +// } +// } +// playableIconLoaderThread?.start() +// } + } + + /** + * Persists the current position and last played time of the media file. + * + * @param fromMediaPlayer if true, the information is gathered from the current Media Player + * and {@param playable} and {@param position} become irrelevant. + * @param playable the playable for which the current position should be saved, unless + * {@param fromMediaPlayer} is true. + * @param position the position that should be saved, unless {@param fromMediaPlayer} is true. + */ + @Synchronized + private fun saveCurrentPosition(fromMediaPlayer: Boolean, playable: Playable?, position: Int) { + var playable = playable + var position = position + val duration: Int + if (fromMediaPlayer) { + position = currentPosition + duration = this.duration + playable = mediaPlayer?.getPlayable() + } else { + duration = playable?.getDuration() ?: Playable.INVALID_TIME + } + if (position != Playable.INVALID_TIME && duration != Playable.INVALID_TIME && playable != null) { +// Log.d(TAG, "Saving current position to $position $duration") + saveCurrentPosition(playable, position, System.currentTimeMillis()) + } + } + + fun sleepTimerActive(): Boolean { + return taskManager.isSleepTimerActive + } + + val sleepTimerTimeLeft: Long + get() = taskManager.sleepTimerTimeLeft + + private fun bluetoothNotifyChange(info: PSMPInfo?, whatChanged: String) { + var isPlaying = false + + if (info?.playerStatus == PlayerStatus.PLAYING || info?.playerStatus == PlayerStatus.FALLBACK) { + isPlaying = true + } + + if (info?.playable != null) { + val i = Intent(whatChanged) + i.putExtra("id", 1L) + i.putExtra("artist", "") + i.putExtra("album", info.playable!!.getFeedTitle()) + i.putExtra("track", info.playable!!.getEpisodeTitle()) + i.putExtra("playing", isPlaying) + i.putExtra("duration", info.playable!!.getDuration().toLong()) + i.putExtra("position", info.playable!!.getPosition().toLong()) + sendBroadcast(i) + } + } + + private val autoStateUpdated: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getStringExtra("media_connection_status") + val isConnectedToCar = "media_connected" == status + Log.d(TAG, "Received Auto Connection update: $status") + if (!isConnectedToCar) { + Log.d(TAG, "Car was unplugged during playback.") + } else { + val playerStatus = mediaPlayer?.playerStatus + when (playerStatus) { + PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { + mediaPlayer?.resume() + } + PlayerStatus.PREPARING -> { + mediaPlayer?.setStartWhenPrepared(!mediaPlayer!!.isStartWhenPrepared()) + } + PlayerStatus.INITIALIZED -> { + mediaPlayer?.setStartWhenPrepared(true) + mediaPlayer?.prepare() + } + else -> {} + } + } + } + } + + /** + * Pauses playback when the headset is disconnected and the preference is + * set + */ + private val headsetDisconnected: BroadcastReceiver = object : BroadcastReceiver() { + private val TAG = "headsetDisconnected" + private val UNPLUGGED = 0 + private val PLUGGED = 1 + + override fun onReceive(context: Context, intent: Intent) { + if (isInitialStickyBroadcast) { + // Don't pause playback after we just started, just because the receiver + // delivers the current headset state (instead of a change) + return + } + + if (TextUtils.equals(intent.action, Intent.ACTION_HEADSET_PLUG)) { + val state = intent.getIntExtra("state", -1) + Log.d(TAG, "Headset plug event. State is $state") + if (state != -1) { + when (state) { + UNPLUGGED -> { + Log.d(TAG, "Headset was unplugged during playback.") + } + PLUGGED -> { + Log.d(TAG, "Headset was plugged in during playback.") + unpauseIfPauseOnDisconnect(false) + } + } + } else { + Log.e(TAG, "Received invalid ACTION_HEADSET_PLUG intent") + } + } + } + } + + private val bluetoothStateUpdated: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (TextUtils.equals(intent.action, BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)) { + val state = intent.getIntExtra(BluetoothA2dp.EXTRA_STATE, -1) + if (state == BluetoothA2dp.STATE_CONNECTED) { + Log.d(TAG, "Received bluetooth connection intent") + unpauseIfPauseOnDisconnect(true) + } + } + } + } + + private val audioBecomingNoisy: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // sound is about to change, eg. bluetooth -> speaker + Log.d(TAG, "Pausing playback because audio is becoming noisy") + pauseIfPauseOnDisconnect() + } + } + + /** + * Pauses playback if PREF_PAUSE_ON_HEADSET_DISCONNECT was set to true. + */ + private fun pauseIfPauseOnDisconnect() { + Log.d(TAG, "pauseIfPauseOnDisconnect()") + transientPause = (mediaPlayer?.playerStatus == PlayerStatus.PLAYING || mediaPlayer?.playerStatus == PlayerStatus.FALLBACK) + if (isPauseOnHeadsetDisconnect && !isCasting) { + mediaPlayer?.pause(!isPersistNotify, false) + } + } + + /** + * @param bluetooth true if the event for unpausing came from bluetooth + */ + private fun unpauseIfPauseOnDisconnect(bluetooth: Boolean) { + if (mediaPlayer != null && mediaPlayer!!.isAudioChannelInUse) { + Log.d(TAG, "unpauseIfPauseOnDisconnect() audio is in use") + return + } + if (transientPause) { + transientPause = false + if (Build.VERSION.SDK_INT >= 31) { + stateManager.stopService() + return + } + when { + !bluetooth && isUnpauseOnHeadsetReconnect -> { + mediaPlayer?.resume() + } + bluetooth && isUnpauseOnBluetoothReconnect -> { + // let the user know we've started playback again... + val v = applicationContext.getSystemService(VIBRATOR_SERVICE) as? Vibrator + v?.vibrate(500) + mediaPlayer?.resume() + } + } + } + } + + private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (TextUtils.equals(intent.action, PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE)) { + EventBus.getDefault().post(PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)) + stateManager.stopService() + } + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @Suppress("unused") + fun volumeAdaptionChanged(event: VolumeAdaptionChangedEvent) { + val playbackVolumeUpdater = PlaybackVolumeUpdater() + if (mediaPlayer != null) playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer!!, event.feedId, event.volumeAdaptionSetting) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @Suppress("unused") + fun speedPresetChanged(event: SpeedPresetChangedEvent) { +// TODO: speed + val item = (playable as? FeedMedia)?.item ?: currentitem +// if (playable is FeedMedia) { + if (item?.feed?.id == event.feedId) { + if (event.speed == FeedPreferences.SPEED_USE_GLOBAL) { + setSpeed(getPlaybackSpeed(playable!!.getMediaType())) + } else { + setSpeed(event.speed) + } + } +// } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @Suppress("unused") + fun skipIntroEndingPresetChanged(event: SkipIntroEndingChangedEvent) { + val item = (playable as? FeedMedia)?.item ?: currentitem +// if (playable is FeedMedia) { + if (item?.feed?.id == event.feedId) { + val feedPreferences = item.feed?.preferences + if (feedPreferences != null) { + Log.d(TAG, "skipIntroEndingPresetChanged ${event.skipIntro} ${event.skipEnding}") + feedPreferences.feedSkipIntro = event.skipIntro + feedPreferences.feedSkipEnding = event.skipEnding + } + } +// } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onEvenStartPlay(event: StartPlayEvent) { + Log.d(TAG, "onEvenStartPlay ${event.item.title}") + currentitem = event.item + } + + + fun resume() { + mediaPlayer?.resume() + taskManager.restartSleepTimer() + } + + fun prepare() { + mediaPlayer?.prepare() + taskManager.restartSleepTimer() + } + + fun pause(abandonAudioFocus: Boolean, reinit: Boolean) { + mediaPlayer?.pause(abandonAudioFocus, reinit) + isSpeedForward = false + isFallbackSpeed = false + } + + val pSMPInfo: PSMPInfo + get() = mediaPlayer!!.pSMPInfo + + val status: PlayerStatus + get() = mediaPlayer!!.playerStatus + + val playable: Playable? + get() = mediaPlayer?.getPlayable() + + fun setSpeed(speed: Float, codeArray: BooleanArray? = null) { + isSpeedForward = false + isFallbackSpeed = false + + if (currentMediaType == MediaType.VIDEO) { + currentlyPlayingTemporaryPlaybackSpeed = speed + videoPlaybackSpeed = speed + mediaPlayer?.setPlaybackParams(speed, isSkipSilence) + } else { + if (codeArray != null && codeArray.size == 3) { + Log.d(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}") + if (codeArray[2]) setPlaybackSpeed(speed) + if (codeArray[1]) { + var item = (playable as? FeedMedia)?.item ?: currentitem +// var item = (playable as FeedMedia).item + if (item == null) { + val itemId = (playable as? FeedMedia)?.itemId + if (itemId != null) item = DBReader.getFeedItem(itemId) + } + if (item != null) { + var feed = item.feed + if (feed == null) { + feed = DBReader.getFeed(item.feedId) + } + if (feed != null) { + val feedPreferences = feed.preferences + if (feedPreferences != null) { + feedPreferences.feedPlaybackSpeed = speed + Log.d(TAG, "setSpeed ${feed.title} $speed") + DBWriter.setFeedPreferences(feedPreferences) + EventBus.getDefault().post(SpeedPresetChangedEvent(feedPreferences.feedPlaybackSpeed, feed.id)) + } + } + } + } + if (codeArray[0]) { + currentlyPlayingTemporaryPlaybackSpeed = speed + mediaPlayer?.setPlaybackParams(speed, isSkipSilence) + } + } else { + currentlyPlayingTemporaryPlaybackSpeed = speed + mediaPlayer?.setPlaybackParams(speed, isSkipSilence) + } + } + } + + fun speedForward(speed: Float) { + if (mediaPlayer == null || isFallbackSpeed) return + + if (!isSpeedForward) { + normalSpeed = mediaPlayer!!.getPlaybackSpeed() + mediaPlayer!!.setPlaybackParams(speed, isSkipSilence) + } else { + mediaPlayer!!.setPlaybackParams(normalSpeed, isSkipSilence) + } + isSpeedForward = !isSpeedForward + } + + fun fallbackSpeed(speed: Float) { + if (mediaPlayer == null || isSpeedForward) return + + if (!isFallbackSpeed) { + normalSpeed = mediaPlayer!!.getPlaybackSpeed() + mediaPlayer!!.setPlaybackParams(speed, isSkipSilence) + } else { + mediaPlayer!!.setPlaybackParams(normalSpeed, isSkipSilence) + } + isFallbackSpeed = !isFallbackSpeed + } + + fun skipSilence(skipSilence: Boolean) { + mediaPlayer?.setPlaybackParams(currentPlaybackSpeed, skipSilence) + } + + val currentPlaybackSpeed: Float + get() { + return mediaPlayer?.getPlaybackSpeed() ?: 1.0f + } + + var isStartWhenPrepared: Boolean + get() = mediaPlayer?.isStartWhenPrepared() ?: false + set(s) { + mediaPlayer?.setStartWhenPrepared(s) + } + + fun seekTo(t: Int) { + mediaPlayer?.seekTo(t) + EventBus.getDefault().post(PlaybackPositionEvent(t, duration)) + } + + private fun seekDelta(d: Int) { + mediaPlayer?.seekDelta(d) + } + + val duration: Int + /** + * call getDuration() on mediaplayer or return INVALID_TIME if player is in + * an invalid state. + */ + get() { + return mediaPlayer?.getDuration() ?: Playable.INVALID_TIME + } + + val currentPosition: Int + /** + * call getCurrentPosition() on mediaplayer or return INVALID_TIME if player + * is in an invalid state. + */ + get() { + return mediaPlayer?.getPosition() ?: Playable.INVALID_TIME + } + + val audioTracks: List + get() { + return mediaPlayer?.getAudioTracks() ?: listOf() + } + + val selectedAudioTrack: Int + get() { + return mediaPlayer?.getSelectedAudioTrack() ?: -1 + } + + fun setAudioTrack(track: Int) { + mediaPlayer?.setAudioTrack(track) + } + + val isStreaming: Boolean + get() = mediaPlayer?.isStreaming() ?: false + + val videoSize: Pair? + get() = mediaPlayer?.getVideoSize() + + private fun setupPositionObserver() { + positionEventTimer?.dispose() + + Log.d(TAG, "Setting up position observer") + positionEventTimer = Observable.interval(POSITION_EVENT_INTERVAL, TimeUnit.SECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + Log.d(TAG, "notificationBuilder.updatePosition currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed") + EventBus.getDefault().post(PlaybackPositionEvent(currentPosition, duration)) +// TODO: why set SDK_INT < 29 + if (Build.VERSION.SDK_INT < 29) { +// notificationBuilder.updatePosition(currentPosition, currentPlaybackSpeed) +// val notificationManager = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager +// notificationManager?.notify(R.id.notification_playing, notificationBuilder.build()) + } + skipEndingIfNecessary() + } + } + + private fun cancelPositionObserver() { + positionEventTimer?.dispose() + } + + private fun addPlayableToQueue(playable: Playable?) { + if (playable is FeedMedia) { + val itemId = playable.item?.id ?: return + DBWriter.addQueueItem(this, false, true, itemId) +// notifyChildrenChanged(getString(R.string.queue_label)) + } + } + +// private val sessionCallback: MediaSession.Callback = object : MediaSession.Callback { +// private val TAG = "MediaSessionCompat" +//// TODO: not used now with media3 +// } + + companion object { + private const val TAG = "PlaybackService" + +// TODO: need to experiment this value + private const val POSITION_EVENT_INTERVAL = 5L + + const val ACTION_PLAYER_STATUS_CHANGED: String = "action.ac.mdiq.podcini.service.playerStatusChanged" + private const val AVRCP_ACTION_PLAYER_STATUS_CHANGED = "com.android.music.playstatechanged" + private const val AVRCP_ACTION_META_CHANGED = "com.android.music.metachanged" + + /** + * Custom actions used by Android Wear, Android Auto, and Android (API 33+ only) + */ + private const val CUSTOM_ACTION_SKIP_TO_NEXT = "action.ac.mdiq.podcini.service.skipToNext" + private const val CUSTOM_ACTION_FAST_FORWARD = "action.ac.mdiq.podcini.service.fastForward" + private const val CUSTOM_ACTION_REWIND = "action.ac.mdiq.podcini.service.rewind" + private const val CUSTOM_ACTION_CHANGE_PLAYBACK_SPEED = "action.ac.mdiq.podcini.service.changePlaybackSpeed" + const val CUSTOM_ACTION_NEXT_CHAPTER: String = "action.ac.mdiq.podcini.service.next_chapter" + + /** + * Set a max number of episodes to load for Android Auto, otherwise there could be performance issues + */ + const val MAX_ANDROID_AUTO_EPISODES_PER_FEED: Int = 100 + + /** + * Is true if service is running. + */ + @JvmField + var isRunning: Boolean = false + + /** + * Is true if the service was running, but paused due to headphone disconnect + */ + private var transientPause = false + + /** + * Is true if a Cast Device is connected to the service. + */ + @JvmStatic + @Volatile + var isCasting: Boolean = false + private set + + @Volatile + var currentMediaType: MediaType? = MediaType.UNKNOWN + private set + + /** + * 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 { + currentEpisodeIsVideo + } + + 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/java/ac/mdiq/podcini/playback/service/PlaybackServiceNotificationBuilder.kt b/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackServiceNotificationBuilder.kt index ffaab349..81441bab 100644 --- a/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackServiceNotificationBuilder.kt +++ b/app/src/main/java/ac/mdiq/podcini/playback/service/PlaybackServiceNotificationBuilder.kt @@ -39,9 +39,8 @@ class PlaybackServiceNotificationBuilder(private val context: Context) { private var position: String? = null fun setPlayable(playable: Playable) { - if (playable !== this.playable) { - clearCache() - } + if (playable !== this.playable) clearCache() + this.playable = playable } @@ -86,31 +85,22 @@ class PlaybackServiceNotificationBuilder(private val context: Context) { private val defaultIcon: Bitmap? get() { - if (Companion.defaultIcon == null) { - Companion.defaultIcon = getBitmap(context, R.mipmap.ic_launcher) - } + if (Companion.defaultIcon == null) Companion.defaultIcon = getBitmap(context, R.mipmap.ic_launcher) return Companion.defaultIcon } fun build(): Notification { - val notification = NotificationCompat.Builder( - context, - NotificationUtils.CHANNEL_ID_PLAYING) + val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_PLAYING) if (playable != null) { notification.setContentTitle(playable!!.getFeedTitle()) notification.setContentText(playable!!.getEpisodeTitle()) addActions(notification, mediaSessionToken, playerStatus) - if (cachedIcon != null) { - notification.setLargeIcon(cachedIcon) - } else { - notification.setLargeIcon(this.defaultIcon) - } + if (cachedIcon != null) notification.setLargeIcon(cachedIcon) + else notification.setLargeIcon(this.defaultIcon) - if (Build.VERSION.SDK_INT < 29) { - notification.setSubText(position) - } + if (Build.VERSION.SDK_INT < 29) notification.setSubText(position) } else { notification.setContentTitle(context.getString(R.string.app_name)) notification.setContentText("Loading. If this does not go away, play any episode and contact us.") @@ -129,11 +119,10 @@ class PlaybackServiceNotificationBuilder(private val context: Context) { } private val playerActivityPendingIntent: PendingIntent - get() = PendingIntent.getActivity(context, R.id.pending_intent_player_activity, - PlaybackService.getPlayerActivityIntent(context), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + get() = PendingIntent.getActivity(context, R.id.pending_intent_player_activity, PlaybackService.getPlayerActivityIntent(context), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - private fun addActions(notification: NotificationCompat.Builder, mediaSessionToken: MediaSessionCompat.Token?, - playerStatus: PlayerStatus?) { + private fun addActions(notification: NotificationCompat.Builder, mediaSessionToken: MediaSessionCompat.Token?, playerStatus: PlayerStatus?) { val compactActionList = ArrayList() var numActions = 0 // we start and 0 and then increment by 1 for each call to addAction @@ -173,11 +162,12 @@ class PlaybackServiceNotificationBuilder(private val context: Context) { } val stopButtonPendingIntent = getPendingIntentForMediaAction(KeyEvent.KEYCODE_MEDIA_STOP, numActions) - notification.setStyle(androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(mediaSessionToken) - .setShowActionsInCompactView(*ArrayUtils.toPrimitive(compactActionList.toTypedArray())) - .setShowCancelButton(true) - .setCancelButtonIntent(stopButtonPendingIntent)) + notification + .setStyle(androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(mediaSessionToken) + .setShowActionsInCompactView(*ArrayUtils.toPrimitive(compactActionList.toTypedArray())) + .setShowCancelButton(true) + .setCancelButtonIntent(stopButtonPendingIntent)) } private fun getPendingIntentForMediaAction(keycodeValue: Int, requestCode: Int): PendingIntent { @@ -222,15 +212,9 @@ class PlaybackServiceNotificationBuilder(private val context: Context) { private fun getBitmap(context: Context, drawableId: Int): Bitmap? { return when (val drawable = AppCompatResources.getDrawable(context, drawableId)) { - is BitmapDrawable -> { - drawable.bitmap - } - is VectorDrawable -> { - getBitmap(drawable) - } - else -> { - null - } + is BitmapDrawable -> drawable.bitmap + is VectorDrawable -> getBitmap(drawable) + else -> null } } } diff --git a/app/src/main/java/ac/mdiq/podcini/receiver/PlayerWidget.kt b/app/src/main/java/ac/mdiq/podcini/receiver/PlayerWidget.kt index 44963b8f..41c679d9 100644 --- a/app/src/main/java/ac/mdiq/podcini/receiver/PlayerWidget.kt +++ b/app/src/main/java/ac/mdiq/podcini/receiver/PlayerWidget.kt @@ -21,8 +21,7 @@ class PlayerWidget : AppWidgetProvider() { } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" - + appWidgetManager + "], appWidgetIds = [" + appWidgetIds.contentToString() + "]") + Log.d(TAG, "onUpdate() called with: context = [$context], appWidgetManager = [$appWidgetManager], appWidgetIds = [${appWidgetIds.contentToString()}]") WidgetUpdaterWorker.enqueueWork(context) val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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 bfac9e85..fcf9275a 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 @@ -98,9 +98,8 @@ class MainActivity : CastEnabledActivity() { DBReader.updateFeedList() - if (savedInstanceState != null) { - ensureGeneratedViewIdGreaterThan(savedInstanceState.getInt(KEY_GENERATED_VIEW_ID, 0)) - } + if (savedInstanceState != null) ensureGeneratedViewIdGreaterThan(savedInstanceState.getInt(KEY_GENERATED_VIEW_ID, 0)) + WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) _binding = MainActivityBinding.inflate(layoutInflater) @@ -175,12 +174,8 @@ class MainActivity : CastEnabledActivity() { var isRefreshingFeeds = false for (workInfo in workInfos) { when (workInfo.state) { - WorkInfo.State.RUNNING -> { - isRefreshingFeeds = true - } - WorkInfo.State.ENQUEUED -> { - isRefreshingFeeds = true - } + WorkInfo.State.RUNNING -> isRefreshingFeeds = true + WorkInfo.State.ENQUEUED -> isRefreshingFeeds = true else -> { // Log.d(TAG, "workInfo.state ${workInfo.state}") } @@ -199,20 +194,13 @@ class MainActivity : CastEnabledActivity() { downloadUrl = tag.substring(DownloadServiceInterface.WORK_TAG_EPISODE_URL.length) } } - if (downloadUrl == null) { - continue - } + if (downloadUrl == null) continue + var status: Int status = when (workInfo.state) { - WorkInfo.State.RUNNING -> { - DownloadStatus.STATE_RUNNING - } - WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> { - DownloadStatus.STATE_QUEUED - } - WorkInfo.State.SUCCEEDED -> { - DownloadStatus.STATE_COMPLETED - } + WorkInfo.State.RUNNING -> DownloadStatus.STATE_RUNNING + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> DownloadStatus.STATE_QUEUED + WorkInfo.State.SUCCEEDED -> DownloadStatus.STATE_COMPLETED WorkInfo.State.FAILED -> { Log.e(TAG, "download failed $downloadUrl") DownloadStatus.STATE_COMPLETED @@ -240,9 +228,8 @@ class MainActivity : CastEnabledActivity() { private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> - if (isGranted) { - return@registerForActivityResult - } + if (isGranted) return@registerForActivityResult + MaterialAlertDialogBuilder(this) .setMessage(R.string.notification_permission_text) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> {} } @@ -276,12 +263,8 @@ class MainActivity : CastEnabledActivity() { override fun onStateChanged(view: View, state: Int) { Log.d(TAG, "bottomSheet onStateChanged $state") when (state) { - BottomSheetBehavior.STATE_COLLAPSED -> { - onSlide(view,0.0f) - } - BottomSheetBehavior.STATE_EXPANDED -> { - onSlide(view, 1.0f) - } + BottomSheetBehavior.STATE_COLLAPSED -> onSlide(view,0.0f) + BottomSheetBehavior.STATE_EXPANDED -> onSlide(view, 1.0f) else -> {} } } @@ -299,18 +282,15 @@ class MainActivity : CastEnabledActivity() { // Tablet layout does not have a drawer when { drawerLayout != null -> { - if (drawerToggle != null) { - drawerLayout!!.removeDrawerListener(drawerToggle!!) - } + if (drawerToggle != null) drawerLayout!!.removeDrawerListener(drawerToggle!!) + drawerToggle = ActionBarDrawerToggle(this, drawerLayout, toolbar, R.string.drawer_open, R.string.drawer_close) drawerLayout!!.addDrawerListener(drawerToggle!!) drawerToggle!!.syncState() drawerToggle!!.isDrawerIndicatorEnabled = !displayUpArrow drawerToggle!!.toolbarNavigationClickListener = View.OnClickListener { supportFragmentManager.popBackStack() } } - !displayUpArrow -> { - toolbar.navigationIcon = null - } + !displayUpArrow -> toolbar.navigationIcon = null else -> { toolbar.setNavigationIcon(getDrawableFromAttr(this, R.attr.homeAsUpIndicator)) toolbar.setNavigationOnClickListener { supportFragmentManager.popBackStack() } @@ -348,12 +328,9 @@ class MainActivity : CastEnabledActivity() { val visible = if (visible_ != null) visible_ else if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) false else true bottomSheet.setLocked(!visible) - if (visible) { - // Update toolbar visibility - bottomSheetCallback.onStateChanged(dummyView, bottomSheet.state) - } else { - bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) - } + if (visible) bottomSheetCallback.onStateChanged(dummyView, bottomSheet.state) // Update toolbar visibility + else bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) + // val mainView = findViewById(R.id.main_view) val params = mainView.layoutParams as MarginLayoutParams val externalPlayerHeight = resources.getDimension(R.dimen.external_player_height).toInt() @@ -388,18 +365,16 @@ class MainActivity : CastEnabledActivity() { args = null } } - if (args != null) { - fragment.arguments = args - } + if (args != null) fragment.arguments = args + NavDrawerFragment.saveLastNavFragment(this, tag) loadFragment(fragment) } fun loadFeedFragmentById(feedId: Long, args: Bundle?) { val fragment: Fragment = FeedItemlistFragment.newInstance(feedId) - if (args != null) { - fragment.arguments = args - } + if (args != null) fragment.arguments = args + NavDrawerFragment.saveLastNavFragment(this, feedId.toString()) loadFragment(fragment) } @@ -461,9 +436,8 @@ class MainActivity : CastEnabledActivity() { private fun setNavDrawerSize() { // Tablet layout does not have a drawer - if (drawerLayout == null) { - return - } + if (drawerLayout == null) return + val screenPercent = resources.getInteger(R.integer.nav_drawer_screen_size_percent) * 0.01f val width = (screenWidth * screenPercent).toInt() val maxWidth = resources.getDimension(R.dimen.nav_drawer_max_screen_size).toInt() @@ -482,9 +456,7 @@ class MainActivity : CastEnabledActivity() { override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - if (bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED) { - bottomSheetCallback.onSlide(dummyView, 1.0f) - } + if (bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED) bottomSheetCallback.onSlide(dummyView, 1.0f) } public override fun onStart() { @@ -503,9 +475,7 @@ class MainActivity : CastEnabledActivity() { finish() startActivity(Intent(this, MainActivity::class.java)) } - if (hiddenDrawerItems.contains(NavDrawerFragment.getLastNavFragment(this))) { - loadFragment(defaultPage, null) - } + if (hiddenDrawerItems.contains(NavDrawerFragment.getLastNavFragment(this))) loadFragment(defaultPage, null) } @Deprecated("Deprecated in Java") @@ -531,39 +501,27 @@ class MainActivity : CastEnabledActivity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { Log.d(TAG, "onOptionsItemSelected ${item.title}") - if (drawerToggle != null && drawerToggle!!.onOptionsItemSelected(item)) { - // Tablet layout does not have a drawer - return true - } else if (item.itemId == android.R.id.home) { - if (supportFragmentManager.backStackEntryCount > 0) { - supportFragmentManager.popBackStack() + when { + drawerToggle != null && drawerToggle!!.onOptionsItemSelected(item) -> return true // Tablet layout does not have a drawer + item.itemId == android.R.id.home -> { + if (supportFragmentManager.backStackEntryCount > 0) supportFragmentManager.popBackStack() + return true } - return true - } else { - return super.onOptionsItemSelected(item) + else -> return super.onOptionsItemSelected(item) } } @Deprecated("Deprecated in Java") override fun onBackPressed() { when { - isDrawerOpen -> { - drawerLayout?.closeDrawer(navDrawer) - } - bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED -> { - bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) - } - supportFragmentManager.backStackEntryCount != 0 -> { - super.onBackPressed() - } + isDrawerOpen -> drawerLayout?.closeDrawer(navDrawer) + bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED -> bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) + supportFragmentManager.backStackEntryCount != 0 -> super.onBackPressed() else -> { val toPage = defaultPage if (NavDrawerFragment.getLastNavFragment(this) == toPage || UserPreferences.DEFAULT_PAGE_REMEMBER == toPage) { - if (backButtonOpensDrawer()) { - drawerLayout?.openDrawer(navDrawer) - } else { - super.onBackPressed() - } + if (backButtonOpensDrawer()) drawerLayout?.openDrawer(navDrawer) + else super.onBackPressed() } else { loadFragment(toPage, null) } @@ -576,9 +534,7 @@ class MainActivity : CastEnabledActivity() { Log.d(TAG, "onEvent($event)") val snackbar = showSnackbarAbovePlayer(event.message, Snackbar.LENGTH_LONG) - if (event.action != null) { - snackbar.setAction(event.actionText) { event.action.accept(this) } - } + if (event.action != null) snackbar.setAction(event.actionText) { event.action.accept(this) } } private fun handleNavIntent() { @@ -591,11 +547,8 @@ class MainActivity : CastEnabledActivity() { if (feedId > 0) { val startedFromSearch = intent.getBooleanExtra(EXTRA_STARTED_FROM_SEARCH, false) val addToBackStack = intent.getBooleanExtra(EXTRA_ADD_TO_BACK_STACK, false) - if (startedFromSearch || addToBackStack) { - loadChildFragment(FeedItemlistFragment.newInstance(feedId)) - } else { - loadFeedFragmentById(feedId, args) - } + if (startedFromSearch || addToBackStack) loadChildFragment(FeedItemlistFragment.newInstance(feedId)) + else loadFeedFragmentById(feedId, args) } bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) } @@ -614,14 +567,11 @@ class MainActivity : CastEnabledActivity() { // bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED // bottomSheetCallback.onSlide(dummyView, 1.0f) } - else -> { - handleDeeplink(intent.data) - } + else -> handleDeeplink(intent.data) } - if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DRAWER, false)) { - drawerLayout?.open() - } + if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DRAWER, false)) drawerLayout?.open() + if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DOWNLOAD_LOGS, false)) { DownloadLogFragment().show(supportFragmentManager, null) } @@ -643,9 +593,7 @@ class MainActivity : CastEnabledActivity() { val s: Snackbar if (bottomSheet.state == BottomSheetBehavior.STATE_COLLAPSED) { s = Snackbar.make(mainView, text!!, duration) - if (audioPlayerFragmentView.visibility == View.VISIBLE) { - s.setAnchorView(audioPlayerFragmentView) - } + if (audioPlayerFragmentView.visibility == View.VISIBLE) s.setAnchorView(audioPlayerFragmentView) } else { s = Snackbar.make(binding.root, text!!, duration) } @@ -671,7 +619,6 @@ class MainActivity : CastEnabledActivity() { when (uri.path) { "/deeplink/search" -> { val query = uri.getQueryParameter("query") ?: return - this.loadChildFragment(SearchFragment.newInstance(query)) } "/deeplink/main" -> { @@ -696,9 +643,7 @@ class MainActivity : CastEnabledActivity() { //Hardware keyboard support override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { val currentFocus = currentFocus - if (currentFocus is EditText) { - return super.onKeyUp(keyCode, event) - } + if (currentFocus is EditText) return super.onKeyUp(keyCode, event) val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager var customKeyCode: Int? = null @@ -706,23 +651,18 @@ class MainActivity : CastEnabledActivity() { when (keyCode) { KeyEvent.KEYCODE_P -> customKeyCode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_COMMA -> customKeyCode = - KeyEvent.KEYCODE_MEDIA_REWIND - KeyEvent.KEYCODE_K, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_PERIOD -> customKeyCode = - KeyEvent.KEYCODE_MEDIA_FAST_FORWARD + KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_COMMA -> customKeyCode = KeyEvent.KEYCODE_MEDIA_REWIND + KeyEvent.KEYCODE_K, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_PERIOD -> customKeyCode = KeyEvent.KEYCODE_MEDIA_FAST_FORWARD KeyEvent.KEYCODE_PLUS, KeyEvent.KEYCODE_W -> { - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI) + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI) return true } KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_S -> { - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI) + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI) return true } KeyEvent.KEYCODE_M -> { - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI) + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI) return true } } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt b/app/src/main/java/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt index 8824eda7..415e1924 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/activity/WidgetConfigActivity.kt @@ -103,10 +103,10 @@ class WidgetConfigActivity : AppCompatActivity() { private fun setInitialState() { val prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, MODE_PRIVATE) - ckPlaybackSpeed.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, false) - ckRewind.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, false) - ckFastForward.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + appWidgetId, false) - ckSkip.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + appWidgetId, false) + ckPlaybackSpeed.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, true) + ckRewind.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, true) + ckFastForward.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + appWidgetId, true) + ckSkip.isChecked = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + appWidgetId, true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val color = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, PlayerWidget.DEFAULT_COLOR) val opacity = Color.alpha(color) * 100 / 0xFF diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 63d55e78..82fb23dc 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -26,6 +26,8 @@ import ac.mdiq.podcini.ui.dialog.MediaPlayerErrorDialog import ac.mdiq.podcini.ui.dialog.SkipPreferenceDialog import ac.mdiq.podcini.ui.dialog.SleepTimerDialog import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog +import ac.mdiq.podcini.ui.fragment.EpisodeHomeFragment.Companion.fetchHtmlSource +import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ChapterSeekBar import ac.mdiq.podcini.ui.view.PlayButton import ac.mdiq.podcini.ui.view.PlaybackSpeedIndicatorView @@ -63,6 +65,8 @@ import io.reactivex.MaybeEmitter import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking +import net.dankito.readability4j.Readability4J import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -424,6 +428,10 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val itemId = menuItem.itemId when (itemId) { + R.id.show_home_reader_view -> { + itemDescFrag.buildHomeReaderText() + return true + } R.id.show_video -> { controller!!.playPause() VideoPlayerActivityStarter(requireContext(), VideoMode.FULL_SCREEN_VIEW).start() diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt index 02789ee1..b5240716 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt @@ -3,6 +3,7 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding import ac.mdiq.podcini.storage.model.feed.FeedItem +import ac.mdiq.podcini.ui.utils.ShownotesCleaner import android.speech.tts.TextToSpeech import android.os.Build import android.os.Bundle @@ -33,23 +34,22 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS private var _binding: EpisodeHomeFragmentBinding? = null private val binding get() = _binding!! - private var item: FeedItem? = null +// private var item: FeedItem? = null private lateinit var tts: TextToSpeech private lateinit var toolbar: MaterialToolbar private var disposable: Disposable? = null - private var readerhtml: String? = null - private var textContent: String? = null +// private var readerhtml: String? = null private var readMode = false private var ttsPlaying = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - item = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) requireArguments().getSerializable(ARG_FEEDITEM, FeedItem::class.java) - else requireArguments().getSerializable(ARG_FEEDITEM) as? FeedItem +// item = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) requireArguments().getSerializable(ARG_FEEDITEM, FeedItem::class.java) +// else requireArguments().getSerializable(ARG_FEEDITEM) as? FeedItem tts = TextToSpeech(requireContext(), this) } @@ -65,9 +65,8 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } toolbar.setOnMenuItemClickListener(this) - if (item?.link != null) { - showContent() - } + if (currentItem?.link != null) showContent() + updateAppearance() return binding.root } @@ -81,9 +80,9 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) { // TTS initialization successful - Log.i(TAG, "TTS init success with Locale: ${item?.feed?.language}") - if (item?.feed?.language != null) { - val result = tts.setLanguage(Locale(item!!.feed!!.language)) + Log.i(TAG, "TTS init success with Locale: ${currentItem?.feed?.language}") + if (currentItem?.feed?.language != null) { + val result = tts.setLanguage(Locale(currentItem!!.feed!!.language!!)) // val result = tts.setLanguage(Locale.UK) if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { Log.w(TAG, "TTS language not supported") @@ -100,47 +99,61 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS private fun showContent() { if (readMode) { - if (readerhtml == null) { + var readerhtml: String? = null + if (cleanedNotes == null) { runBlocking { - val url = item!!.link!! + val url = currentItem!!.link!! val htmlSource = fetchHtmlSource(url) - val readability4J = Readability4J(item?.link!!, htmlSource) + val readability4J = Readability4J(currentItem?.link!!, htmlSource) val article = readability4J.parse() textContent = article.textContent // Log.d(TAG, "readability4J: ${article.textContent}") readerhtml = article.contentWithDocumentsCharsetOrUtf8 + if (readerhtml != null) { + val shownotesCleaner = ShownotesCleaner(requireContext(), readerhtml!!, 0) + cleanedNotes = shownotesCleaner.processShownotes() + } } } - if (readerhtml != null) binding.webView.loadDataWithBaseURL(item!!.link!!, readerhtml!!, "text/html", "UTF-8", null) + if (cleanedNotes != null) { + binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes!!, "text/html", "UTF-8", null) +// binding.readerView.loadDataWithBaseURL(currentItem!!.link!!, readerhtml!!, "text/html", "UTF-8", null) + binding.readerView.visibility = View.VISIBLE + binding.webView.visibility = View.GONE + } } else { - if (item?.link != null) binding.webView.loadUrl(item!!.link!!) + if (currentItem?.link != null) { + binding.webView.loadUrl(currentItem!!.link!!) + binding.readerView.visibility = View.GONE + binding.webView.visibility = View.VISIBLE + } } } - private suspend fun fetchHtmlSource(urlString: String): String = withContext(Dispatchers.IO) { - val url = URL(urlString) - val connection = url.openConnection() - val inputStream = connection.getInputStream() - val bufferedReader = BufferedReader(InputStreamReader(inputStream)) - - val stringBuilder = StringBuilder() - var line: String? - while (bufferedReader.readLine().also { line = it } != null) { - stringBuilder.append(line) - } - - bufferedReader.close() - inputStream.close() - - stringBuilder.toString() - } +// suspend fun fetchHtmlSource(urlString: String): String = withContext(Dispatchers.IO) { +// val url = URL(urlString) +// val connection = url.openConnection() +// val inputStream = connection.getInputStream() +// val bufferedReader = BufferedReader(InputStreamReader(inputStream)) +// +// val stringBuilder = StringBuilder() +// var line: String? +// while (bufferedReader.readLine().also { line = it } != null) { +// stringBuilder.append(line) +// } +// +// bufferedReader.close() +// inputStream.close() +// +// stringBuilder.toString() +// } + @Deprecated("Deprecated in Java") override fun onPrepareOptionsMenu(menu: Menu) { val textSpeech = menu.findItem(R.id.text_speech) textSpeech.isVisible = readMode if (readMode) { - if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) - else textSpeech.setIcon(R.drawable.ic_play_24dp) + if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp) } } @@ -174,8 +187,8 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS return true } R.id.share_notes -> { - if (item == null) return false - val notes = item!!.description + if (currentItem == null) return false + val notes = currentItem!!.description if (!notes.isNullOrEmpty()) { val shareText = if (Build.VERSION.SDK_INT >= 24) Html.fromHtml(notes, Html.FROM_HTML_MODE_LEGACY).toString() else Html.fromHtml(notes).toString() @@ -190,8 +203,7 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS return true } else -> { - if (item == null) return false - return true + return currentItem != null } } } @@ -210,25 +222,54 @@ class EpisodeHomeFragment : Fragment(), Toolbar.OnMenuItemClickListener, TextToS } @UnstableApi private fun updateAppearance() { - if (item == null) { - Log.d(TAG, "updateAppearance item is null") + if (currentItem == null) { + Log.d(TAG, "updateAppearance currentItem is null") return } onPrepareOptionsMenu(toolbar.menu) -// FeedItemMenuHandler.onPrepareMenu(toolbar.menu, item, R.id.switch_home) +// FeedItemMenuHandler.onPrepareMenu(toolbar.menu, currentItem, R.id.switch_home) } companion object { private const val TAG = "EpisodeWebviewFragment" private const val ARG_FEEDITEM = "feeditem" + private var textContent: String? = null + private var cleanedNotes: String? = null + private var currentItem: FeedItem? = null + @JvmStatic fun newInstance(item: FeedItem): EpisodeHomeFragment { val fragment = EpisodeHomeFragment() - val args = Bundle() - args.putSerializable(ARG_FEEDITEM, item) - fragment.arguments = args +// val args = Bundle() + Log.d(TAG, "item.itemIdentifier ${item.itemIdentifier}") + if (item.itemIdentifier != currentItem?.itemIdentifier) { + currentItem = item + cleanedNotes = null + textContent = null + } +// args.putSerializable(ARG_FEEDITEM, item) +// fragment.arguments = args return fragment } + + suspend fun fetchHtmlSource(urlString: String): String = withContext(Dispatchers.IO) { + val url = URL(urlString) + val connection = url.openConnection() + val inputStream = connection.getInputStream() + val bufferedReader = BufferedReader(InputStreamReader(inputStream)) + + val stringBuilder = StringBuilder() + var line: String? + while (bufferedReader.readLine().also { line = it } != null) { + stringBuilder.append(line) + } + + bufferedReader.close() + inputStream.close() + + stringBuilder.toString() + } + } } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index d76b22f0..ffe01ec7 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -129,8 +129,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (item?.media?.getIdentifier() == cMedia?.getIdentifier()) { controller!!.seekTo(time ?: 0) } else { - (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, - Snackbar.LENGTH_LONG) + (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, Snackbar.LENGTH_LONG) } } registerForContextMenu(webvDescription) @@ -155,9 +154,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { showOnDemandConfigBalloon(true) return@OnClickListener } - actionButton1 == null -> { - return@OnClickListener // Not loaded yet - } + actionButton1 == null -> return@OnClickListener // Not loaded yet else -> actionButton1?.onClick(requireContext()) } }) @@ -168,9 +165,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { showOnDemandConfigBalloon(false) return@OnClickListener } - actionButton2 == null -> { - return@OnClickListener // Not loaded yet - } + actionButton2 == null -> return@OnClickListener // Not loaded yet else -> actionButton2?.onClick(requireContext()) } }) @@ -283,8 +278,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { FeedItemMenuHandler.onPrepareMenu(toolbar.menu, item, R.id.open_podcast) } else { // these are already available via button1 and button2 - FeedItemMenuHandler.onPrepareMenu(toolbar.menu, item, - R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item) + FeedItemMenuHandler.onPrepareMenu(toolbar.menu, item, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item) } if (item!!.feed != null) txtvPodcast.text = item!!.feed!!.title txtvTitle.text = item!!.title @@ -339,53 +333,30 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } if (item != null) { actionButton1 = when { - media.getMediaType() == MediaType.FLASH -> { - VisitWebsiteActionButton(item!!) - } - PlaybackStatus.isCurrentlyPlaying(media) -> { - PauseActionButton(item!!) - } - item!!.feed != null && item!!.feed!!.isLocalFeed -> { - PlayLocalActionButton(item) - } - media.isDownloaded() -> { - PlayActionButton(item!!) - } - else -> { - StreamActionButton(item!!) - } + media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(item!!) + PlaybackStatus.isCurrentlyPlaying(media) -> PauseActionButton(item!!) + item!!.feed != null && item!!.feed!!.isLocalFeed -> PlayLocalActionButton(item) + media.isDownloaded() -> PlayActionButton(item!!) + else -> StreamActionButton(item!!) } actionButton2 = when { - media.getMediaType() == MediaType.FLASH -> { - VisitWebsiteActionButton(item!!) - } - dls != null && media.download_url != null && dls.isDownloadingEpisode(media.download_url!!) -> { - CancelDownloadActionButton(item!!) - } - !media.isDownloaded() -> { - DownloadActionButton(item!!) - } - else -> { - DeleteActionButton(item!!) - } + media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(item!!) + dls != null && media.download_url != null && dls.isDownloadingEpisode(media.download_url!!) -> CancelDownloadActionButton(item!!) + !media.isDownloaded() -> DownloadActionButton(item!!) + else -> DeleteActionButton(item!!) } // if (actionButton2 != null && media.getMediaType() == MediaType.FLASH) actionButton2!!.visibility = View.GONE } } if (actionButton1 != null) { -// butAction1Text.setText(actionButton1!!.getLabel()) butAction1.setImageResource(actionButton1!!.getDrawable()) + butAction1.visibility = actionButton1!!.visibility } -// butAction1Text.transformationMethod = null - if (actionButton1 != null) butAction1.visibility = actionButton1!!.visibility - if (actionButton2 != null) { -// butAction2Text.setText(actionButton2!!.getLabel()) butAction2.setImageResource(actionButton2!!.getDrawable()) + butAction2.visibility = actionButton2!!.visibility } -// butAction2Text.transformationMethod = null - if (actionButton2 != null) butAction2.visibility = actionButton2!!.visibility } override fun onContextItemSelected(item: MenuItem): Boolean { @@ -431,9 +402,8 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { @UnstableApi private fun load() { disposable?.dispose() - if (!itemsLoaded) { - progbarLoading.visibility = View.VISIBLE - } + if (!itemsLoaded) progbarLoading.visibility = View.VISIBLE + disposable = Observable.fromCallable { this.loadInBackground() } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt index a4cc3e01..1f46f9ed 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt @@ -11,6 +11,7 @@ import ac.mdiq.podcini.storage.model.feed.FeedItem import ac.mdiq.podcini.storage.model.feed.FeedMedia import ac.mdiq.podcini.storage.model.playback.Playable import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.fragment.EpisodeHomeFragment.Companion.fetchHtmlSource import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ShownotesWebView import ac.mdiq.podcini.util.ChapterUtils @@ -51,6 +52,8 @@ import io.reactivex.MaybeEmitter import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking +import net.dankito.readability4j.Readability4J import org.apache.commons.lang3.StringUtils import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -75,39 +78,33 @@ class PlayerDetailsFragment : Fragment() { private var webViewLoader: Disposable? = null private var controller: PlaybackController? = null + private var showHomeText = false + var homeText: String? = null + @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { Log.d(TAG, "fragment onCreateView") _binding = PlayerDetailsFragmentBinding.inflate(inflater) binding.imgvCover.setOnClickListener { onPlayPause() } - val colorFilter: ColorFilter? = BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - binding.txtvPodcastTitle.currentTextColor, BlendModeCompat.SRC_IN) + val colorFilter: ColorFilter? = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(binding.txtvPodcastTitle.currentTextColor, BlendModeCompat.SRC_IN) binding.butNextChapter.colorFilter = colorFilter binding.butPrevChapter.colorFilter = colorFilter - binding.chapterButton.setOnClickListener { - ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) - } + binding.chapterButton.setOnClickListener { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) } binding.butPrevChapter.setOnClickListener { seekToPrevChapter() } binding.butNextChapter.setOnClickListener { seekToNextChapter() } Log.d(TAG, "fragment onCreateView") webvDescription = binding.webview - webvDescription.setTimecodeSelectedListener { time: Int? -> - controller?.seekTo(time!!) - } + webvDescription.setTimecodeSelectedListener { time: Int? -> controller?.seekTo(time!!) } webvDescription.setPageFinishedListener { // Restoring the scroll position might not always work webvDescription.postDelayed({ this@PlayerDetailsFragment.restoreFromPreference() }, 50) } binding.root.addOnLayoutChangeListener(object : OnLayoutChangeListener { - override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, - bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int - ) { - if (binding.root.measuredHeight != webvDescription.minimumHeight) { - webvDescription.setMinimumHeight(binding.root.measuredHeight) - } + override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { + if (binding.root.measuredHeight != webvDescription.minimumHeight) webvDescription.setMinimumHeight(binding.root.measuredHeight) binding.root.removeOnLayoutChangeListener(this) } }) @@ -148,7 +145,11 @@ class PlayerDetailsFragment : Fragment() { } if (media is FeedMedia) { val feedMedia = media as FeedMedia - item = feedMedia.item + if (item?.itemIdentifier != feedMedia.item?.itemIdentifier) { + item = feedMedia.item + showHomeText = false + homeText = null + } } // Log.d(TAG, "webViewLoader ${item?.id} ${cleanedNotes==null} ${item!!.description==null} ${loadedMediaId == null} ${item?.media?.getIdentifier()} ${media?.getIdentifier()}") if (item != null) { @@ -166,8 +167,7 @@ class PlayerDetailsFragment : Fragment() { .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ data: String? -> - webvDescription.loadDataWithBaseURL("https://127.0.0.1", data!!, "text/html", - "utf-8", "about:blank") + webvDescription.loadDataWithBaseURL("https://127.0.0.1", data!!, "text/html", "utf-8", "about:blank") Log.d(TAG, "Webview loaded") }, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }) loadMediaInfo() @@ -178,11 +178,8 @@ class PlayerDetailsFragment : Fragment() { disposable = Maybe.create { emitter: MaybeEmitter -> media = controller?.getMedia() - if (media != null) { - emitter.onSuccess(media!!) - } else { - emitter.onComplete() - } + if (media != null) emitter.onSuccess(media!!) + else emitter.onComplete() }.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ media: Playable -> @@ -191,12 +188,41 @@ class PlayerDetailsFragment : Fragment() { }, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }) } + fun buildHomeReaderText() { + showHomeText = !showHomeText + if (showHomeText) { + if (homeText == null && item?.link != null) { + runBlocking { + val url = item!!.link!! + val htmlSource = fetchHtmlSource(url) + val readability4J = Readability4J(item!!.link!!, htmlSource) + val article = readability4J.parse() + val readerhtml = article.contentWithDocumentsCharsetOrUtf8 + if (readerhtml != null) { + val shownotesCleaner = ShownotesCleaner(requireContext(), readerhtml, 0) + homeText = shownotesCleaner.processShownotes() + } + } + } + if (homeText != null) + binding.webview.loadDataWithBaseURL("https://127.0.0.1", homeText!!, "text/html", "UTF-8", null) + } else { + val shownotesCleaner = ShownotesCleaner(requireContext(), item?.description ?: "", media?.getDuration()?:0) + cleanedNotes = shownotesCleaner.processShownotes() + if (cleanedNotes != null) binding.webview.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes!!, "text/html", "UTF-8", null) + } + } + @UnstableApi private fun displayMediaInfo(media: Playable) { val pubDateStr = DateFormatter.formatAbbrev(context, media.getPubDate()) binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle()) if (item == null || item!!.media?.getIdentifier() != media.getIdentifier()) { if (media is FeedMedia) { - item = media.item + if (item?.itemIdentifier != media.item?.itemIdentifier) { + item = media.item + showHomeText = false + homeText = null + } if (item != null) { val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), item!!.feedId) binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } @@ -223,8 +249,7 @@ class PlayerDetailsFragment : Fragment() { binding.txtvEpisodeTitle.scrollTo(0, 0) } }) - val fadeBackIn: ObjectAnimator = ObjectAnimator.ofFloat( - binding.txtvEpisodeTitle, "alpha", 1f) + val fadeBackIn: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 1f) val set = AnimatorSet() set.playSequentially(verticalMarquee, fadeOut, fadeBackIn) set.start() @@ -239,9 +264,7 @@ class PlayerDetailsFragment : Fragment() { private fun updateChapterControlVisibility() { var chapterControlVisible = false when { - media?.getChapters() != null -> { - chapterControlVisible = media!!.getChapters().isNotEmpty() - } + media?.getChapters() != null -> chapterControlVisible = media!!.getChapters().isNotEmpty() media is FeedMedia -> { val fm: FeedMedia? = (media as FeedMedia?) // If an item has chapters but they are not loaded yet, still display the button. @@ -310,23 +333,18 @@ class PlayerDetailsFragment : Fragment() { if (controller == null || curr == null || displayedChapterIndex == -1) return when { - displayedChapterIndex < 1 -> { - controller!!.seekTo(0) - } + displayedChapterIndex < 1 -> controller!!.seekTo(0) (controller!!.position - 10000 * controller!!.currentPlaybackSpeedMultiplier) < curr.start -> { refreshChapterData(displayedChapterIndex - 1) if (media != null) controller!!.seekTo(media!!.getChapters()[displayedChapterIndex].start.toInt()) } - else -> { - controller!!.seekTo(curr.start.toInt()) - } + else -> controller!!.seekTo(curr.start.toInt()) } } @UnstableApi private fun seekToNextChapter() { - if (controller == null || media == null || media!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= media!!.getChapters().size) { - return - } + if (controller == null || media == null || media!!.getChapters().isEmpty() || displayedChapterIndex == -1 + || displayedChapterIndex + 1 >= media!!.getChapters().size) return refreshChapterData(displayedChapterIndex + 1) controller!!.seekTo(media!!.getChapters()[displayedChapterIndex].start.toInt()) @@ -393,7 +411,11 @@ class PlayerDetailsFragment : Fragment() { fun setItem(item_: FeedItem) { Log.d(TAG, "setItem ${item_.title}") - item = item_ + if (item?.itemIdentifier != item_.itemIdentifier) { + item = item_ + showHomeText = false + homeText = null + } } // override fun onConfigurationChanged(newConfig: Configuration) { diff --git a/app/src/main/java/ac/mdiq/podcini/ui/utils/ShownotesCleaner.kt b/app/src/main/java/ac/mdiq/podcini/ui/utils/ShownotesCleaner.kt index ccc5c907..0383c5f4 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/utils/ShownotesCleaner.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/utils/ShownotesCleaner.kt @@ -64,9 +64,8 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva } // replace ASCII line breaks with HTML ones if shownotes don't contain HTML line breaks already - if (!LINE_BREAK_REGEX.matcher(shownotes).find() && !shownotes.contains("

")) { + if (!LINE_BREAK_REGEX.matcher(shownotes).find() && !shownotes.contains("

")) shownotes = shownotes.replace("\n", "
") - } val document = Jsoup.parse(shownotes) cleanCss(document) @@ -79,10 +78,8 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva val elementsWithTimeCodes = document.body().getElementsMatchingOwnText(TIMECODE_REGEX) Log.d(TAG, "Recognized " + elementsWithTimeCodes.size + " timecodes") - if (elementsWithTimeCodes.size == 0) { - // No elements with timecodes - return - } + if (elementsWithTimeCodes.size == 0) return // No elements with timecodes + var useHourFormat = true if (playableDuration != Int.MAX_VALUE) { @@ -107,9 +104,7 @@ class ShownotesCleaner(context: Context, private val rawShownotes: String, priva } } - if (!useHourFormat) { - break - } + if (!useHourFormat) break } } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt b/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt index be13e7e6..03a17608 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt @@ -136,10 +136,10 @@ object WidgetUpdater { } else { views.setViewVisibility(R.id.layout_center, View.VISIBLE) } - val showPlaybackSpeed = prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + id, false) - val showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, false) - val showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, false) - val showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, false) + val showPlaybackSpeed = prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + id, true) + val showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, true) + val showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, true) + val showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, true) if (showPlaybackSpeed || showRewind || showSkip || showFastForward) { views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.VISIBLE) diff --git a/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdaterWorker.kt b/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdaterWorker.kt index 1212d007..f3367266 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdaterWorker.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/widget/WidgetUpdaterWorker.kt @@ -8,9 +8,8 @@ import ac.mdiq.podcini.preferences.PlaybackPreferences.Companion.createInstanceF import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState import ac.mdiq.podcini.playback.base.PlayerStatus -class WidgetUpdaterWorker(context: Context, - workerParams: WorkerParameters -) : Worker(context, workerParams) { +class WidgetUpdaterWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { + override fun doWork(): Result { try { updateWidget() @@ -27,13 +26,9 @@ class WidgetUpdaterWorker(context: Context, private fun updateWidget() { val media = createInstanceFromPreferences(applicationContext) if (media != null) { - WidgetUpdater.updateWidget(applicationContext, - WidgetState(media, PlayerStatus.STOPPED, - media.getPosition(), media.getDuration(), - getCurrentPlaybackSpeed(media))) + WidgetUpdater.updateWidget(applicationContext, WidgetState(media, PlayerStatus.STOPPED, media.getPosition(), media.getDuration(), getCurrentPlaybackSpeed(media))) } else { - WidgetUpdater.updateWidget(applicationContext, - WidgetState(PlayerStatus.STOPPED)) + WidgetUpdater.updateWidget(applicationContext, WidgetState(PlayerStatus.STOPPED)) } } diff --git a/app/src/main/res/layout/episode_home_fragment.xml b/app/src/main/res/layout/episode_home_fragment.xml index 8400d222..5242f781 100644 --- a/app/src/main/res/layout/episode_home_fragment.xml +++ b/app/src/main/res/layout/episode_home_fragment.xml @@ -23,4 +23,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content"/> + + \ No newline at end of file diff --git a/app/src/main/res/menu/mediaplayer.xml b/app/src/main/res/menu/mediaplayer.xml index 594888c8..82ce95c4 100644 --- a/app/src/main/res/menu/mediaplayer.xml +++ b/app/src/main/res/menu/mediaplayer.xml @@ -2,6 +2,13 @@

+ + +