From 0a4ccae79a172c343c10614446284030f7e2f051 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 18 Feb 2024 16:53:34 +0100 Subject: [PATCH] refactor: don't recreate player on orientation change --- .../services/OfflinePlayerService.kt | 4 +- .../libretube/services/OnlinePlayerService.kt | 5 +- .../libretube/ui/fragments/PlayerFragment.kt | 264 +++++++++--------- .../libretube/ui/models/PlayerViewModel.kt | 79 ++++++ .../libretube/util/NowPlayingNotification.kt | 5 +- 5 files changed, 212 insertions(+), 145 deletions(-) diff --git a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt index 2cda51045f..971203129b 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -146,8 +146,10 @@ class OfflinePlayerService : LifecycleService() { } override fun onDestroy() { - nowPlayingNotification?.destroySelfAndPlayer() + nowPlayingNotification?.destroySelf() + player?.stop() + player?.release() player = null nowPlayingNotification = null diff --git a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt index 5817ce7afd..edc9dbe415 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -387,7 +387,10 @@ class OnlinePlayerService : LifecycleService() { // reset the playing queue PlayingQueue.resetToDefaults() - if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelfAndPlayer() + if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelf() + + player?.stop() + player?.release() // called when the user pressed stop in the notification // stop the service from being in the foreground and remove the notification diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index 7a2d24a28f..ec5523bf3b 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -50,10 +50,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R import com.github.libretube.api.CronetHelper -import com.github.libretube.api.JsonHelper -import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.ChapterSegment -import com.github.libretube.api.obj.Message import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Subtitle @@ -82,7 +79,6 @@ import com.github.libretube.helpers.IntentHelper import com.github.libretube.helpers.NavBarHelper import com.github.libretube.helpers.NavigationHelper import com.github.libretube.helpers.PlayerHelper -import com.github.libretube.helpers.PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY import com.github.libretube.helpers.PlayerHelper.checkForSegments import com.github.libretube.helpers.PlayerHelper.getVideoStats import com.github.libretube.helpers.PlayerHelper.isInSegment @@ -116,14 +112,10 @@ import com.github.libretube.util.PlayingQueue import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils.toTimeInSeconds import com.github.libretube.util.YoutubeHlsPlaylistParser -import com.github.libretube.util.deArrow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import retrofit2.HttpException -import java.io.IOException import java.util.* import java.util.concurrent.Executors import kotlin.math.abs @@ -140,18 +132,16 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { private val viewModel: PlayerViewModel by activityViewModels() private val commentsViewModel: CommentsViewModel by activityViewModels() - /** - * Video information passed by the intent - */ + // Video information passed by the intent private lateinit var videoId: String private var playlistId: String? = null private var channelId: String? = null private var keepQueue = false private var timeStamp = 0L - /** - * Video information fetched at runtime - */ + // data and objects stored for the player + private lateinit var exoPlayer: ExoPlayer + private lateinit var trackSelector: DefaultTrackSelector private lateinit var streams: Streams // progress state of the motion layout transition @@ -159,11 +149,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { private var transitionEndId = 0 private var isTransitioning = true - // data and objects stored for the player - private lateinit var exoPlayer: ExoPlayer - private lateinit var trackSelector: DefaultTrackSelector - private var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode) - // if null, it's been set to automatic private var fullscreenResolution: Int? = null @@ -176,13 +161,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { Executors.newCachedThreadPool() ) - // for the player notification - private lateinit var nowPlayingNotification: NowPlayingNotification - // SponsorBlock - private var segments = listOf() private var sponsorBlockEnabled = PlayerHelper.sponsorBlockEnabled - private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() private val handler = Handler(Looper.getMainLooper()) @@ -311,9 +291,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onPlaybackStateChanged(playbackState: Int) { saveWatchPosition() - if (playbackState == Player.STATE_READY) { - } - // set the playback speed to one if having reached the end of a livestream if (playbackState == Player.STATE_BUFFERING && binding.player.isLive && exoPlayer.duration - exoPlayer.currentPosition < 700 @@ -468,7 +445,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (currentId == transitionStartId) { viewModel.isMiniPlayerVisible.value = false // re-enable captions - updateCurrentSubtitle(currentSubtitle) + updateCurrentSubtitle(viewModel.currentSubtitle) binding.player.useController = true commentsViewModel.setCommentSheetExpand(true) mainMotionLayout.progress = 0F @@ -608,7 +585,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { val hlsStream = withContext(Dispatchers.IO) { ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri() } - IntentHelper.openWithExternalPlayer(context, hlsStream, streams.title, streams.uploader) + IntentHelper.openWithExternalPlayer( + context, + hlsStream, + streams.title, + streams.uploader + ) } } @@ -795,7 +777,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (closedVideo) { closedVideo = false - nowPlayingNotification.refreshNotification() + viewModel.nowPlayingNotification?.refreshNotification() } // re-enable and load video stream @@ -810,7 +792,14 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { super.onDestroy() if (this::exoPlayer.isInitialized) { - if (viewModel.player == exoPlayer) viewModel.player = null + exoPlayer.removeListener(playerListener) + + // the player could also be a different instance because a new player fragment + // got created in the meanwhile + if (!viewModel.shouldUseExistingPlayer && viewModel.player == exoPlayer) { + viewModel.player = null + viewModel.trackSelector = null + } exoPlayer.pause() @@ -839,7 +828,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { try { saveWatchPosition() - nowPlayingNotification.destroySelfAndPlayer() + viewModel.nowPlayingNotification?.destroySelf() + viewModel.nowPlayingNotification = null + + if (!viewModel.shouldUseExistingPlayer) { + exoPlayer.stop() + exoPlayer.release() + } (context as MainActivity).requestOrientationChange() } catch (e: Exception) { @@ -867,9 +862,9 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (!exoPlayer.isPlaying || !PlayerHelper.sponsorBlockEnabled) return handler.postDelayed(this::checkForSegments, 100) - if (!sponsorBlockEnabled || segments.isEmpty()) return + if (!sponsorBlockEnabled || viewModel.segments.isEmpty()) return - exoPlayer.checkForSegments(requireContext(), segments, sponsorBlockConfig) + exoPlayer.checkForSegments(requireContext(), viewModel.segments, viewModel.sponsorBlockConfig) ?.let { segment -> if (viewModel.isMiniPlayerVisible.value == true) return@let binding.sbSkipBtn.isVisible = true @@ -879,7 +874,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } return } - if (!exoPlayer.isInSegment(segments)) binding.sbSkipBtn.isGone = true + if (!exoPlayer.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true } private fun playVideo() { @@ -890,20 +885,14 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // reset the comments to become reloaded later commentsViewModel.reset() - lifecycleScope.launch(Dispatchers.IO) { - streams = try { - RetrofitInstance.api.getStreams(videoId).apply { - relatedStreams = relatedStreams.deArrow() + lifecycleScope.launch(Dispatchers.Main) { + viewModel.fetchVideoInfo(requireContext(), videoId).let { (streams, errorMessage) -> + if (errorMessage != null) { + context?.toastFromMainDispatcher(errorMessage, Toast.LENGTH_LONG) + return@launch } - } catch (e: IOException) { - context?.toastFromMainDispatcher(R.string.unknown_error, Toast.LENGTH_LONG) - return@launch - } catch (e: HttpException) { - val errorMessage = e.response()?.errorBody()?.string()?.runCatching { - JsonHelper.json.decodeFromString(this).message - }?.getOrNull() ?: context?.getString(R.string.server_error).orEmpty() - context?.toastFromMainDispatcher(errorMessage, Toast.LENGTH_LONG) - return@launch + + this@PlayerFragment.streams = streams!! } val isFirstVideo = PlayingQueue.isEmpty() @@ -920,103 +909,95 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { val videoStream = streams.videoStreams.firstOrNull() val isShort = PlayingQueue.getCurrent()?.isShort == true || - (videoStream?.height ?: 0) > (videoStream?.width ?: 0) + (videoStream?.height ?: 0) > (videoStream?.width ?: 0) PlayingQueue.setOnQueueTapListener { streamItem -> streamItem.url?.toID()?.let { playNextVideo(it) } } - withContext(Dispatchers.Main) { - // hide the button to skip SponsorBlock segments manually - binding.sbSkipBtn.isGone = true + // hide the button to skip SponsorBlock segments manually + binding.sbSkipBtn.isGone = true - // set media sources for the player - initStreamSources() + // set media sources for the player + if (!viewModel.shouldUseExistingPlayer) initStreamSources() - if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) && - isShort && binding.playerMotionLayout.progress == 0f - ) { - setFullscreen() - playerBinding.fullscreen.isVisible = true - } else { - // disable the fullscreen button for auto fullscreen - playerBinding.fullscreen.isVisible = !PlayerHelper.autoFullscreenEnabled - } + if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) && + isShort && binding.playerMotionLayout.progress == 0f + ) { + setFullscreen() + playerBinding.fullscreen.isVisible = true + } else { + // disable the fullscreen button for auto fullscreen + playerBinding.fullscreen.isVisible = !PlayerHelper.autoFullscreenEnabled + } - binding.player.apply { - useController = false - player = exoPlayer - } + binding.player.apply { + useController = false + player = exoPlayer + } - playerBinding.exoProgress.setPlayer(exoPlayer) + playerBinding.exoProgress.setPlayer(exoPlayer) - initializePlayerView() + initializePlayerView() - exoPlayer.playWhenReady = PlayerHelper.playAutomatically - exoPlayer.prepare() + exoPlayer.playWhenReady = PlayerHelper.playAutomatically + exoPlayer.prepare() - if (binding.playerMotionLayout.progress != 1.0f) { - // show controllers when not in picture in picture mode - val inPipMode = PlayerHelper.pipEnabled && + if (binding.playerMotionLayout.progress != 1.0f) { + // show controllers when not in picture in picture mode + val inPipMode = PlayerHelper.pipEnabled && PictureInPictureCompat.isInPictureInPictureMode(requireActivity()) - if (!inPipMode) { - binding.player.useController = true - } + if (!inPipMode) { + binding.player.useController = true } - // show the player notification - initializePlayerNotification() - - // Since the highlight is also a chapter, we need to fetch the other segments - // first - fetchSponsorBlockSegments() - - // enable the chapters dialog in the player - playerBinding.chapterName.setOnClickListener { - updateMaxSheetHeight() - val sheet = - chaptersBottomSheet ?: ChaptersBottomSheet().also { - chaptersBottomSheet = it - } - if (sheet.isVisible) { - sheet.dismiss() - } else { - sheet.show(childFragmentManager) + } + // show the player notification + initializePlayerNotification() + + // enable the chapters dialog in the player + playerBinding.chapterName.setOnClickListener { + updateMaxSheetHeight() + val sheet = + chaptersBottomSheet ?: ChaptersBottomSheet().also { + chaptersBottomSheet = it } + if (sheet.isVisible) { + sheet.dismiss() + } else { + sheet.show(childFragmentManager) } + } - setCurrentChapterName() + setCurrentChapterName() - if (streams.category == Streams.categoryMusic) { - exoPlayer.setPlaybackSpeed(1f) - } + fetchSponsorBlockSegments() + + if (streams.category == Streams.categoryMusic) { + exoPlayer.setPlaybackSpeed(1f) } + + viewModel.shouldUseExistingPlayer = false } } - /** - * fetch the segments for SponsorBlock - */ - private fun fetchSponsorBlockSegments() { - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - if (sponsorBlockConfig.isEmpty()) return@launch - segments = - RetrofitInstance.api.getSegments( - videoId, - JsonHelper.json.encodeToString(sponsorBlockConfig.keys) - ).segments - if (segments.isEmpty()) return@launch - - withContext(Dispatchers.Main) { - playerBinding.exoProgress.setSegments(segments) - playerBinding.sbToggle.isVisible = true - updateDisplayedDuration() - } - segments.firstOrNull { it.category == SPONSOR_HIGHLIGHT_CATEGORY }?.let { - initializeHighlight(it) - } - } + private suspend fun fetchSponsorBlockSegments() { + viewModel.sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() + + // Since the highlight is also a chapter, we need to fetch the other segments + // first + viewModel.fetchSponsorBlockSegments(videoId) + + if (viewModel.segments.isEmpty()) return + + withContext(Dispatchers.Main) { + playerBinding.exoProgress.setSegments(viewModel.segments) + playerBinding.sbToggle.isVisible = true + updateDisplayedDuration() } + viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY } + ?.let { + initializeHighlight(it) + } } // used for autoplay and skipping to next video @@ -1151,10 +1132,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { * Update the displayed duration of the video */ private fun updateDisplayedDuration() { + if (!this::streams.isInitialized || streams.livestream || _binding == null) return + val duration = exoPlayer.duration / 1000 - if (duration < 0 || streams.livestream || _binding == null) return + if (duration < 0) return - val durationWithoutSegments = duration - segments.sumOf { + val durationWithoutSegments = duration - viewModel.segments.sumOf { val (start, end) = it.segmentStartAndEnd end.toDouble() - start.toDouble() }.toLong() @@ -1276,7 +1259,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } // set the default subtitle if available - updateCurrentSubtitle(currentSubtitle) + updateCurrentSubtitle(viewModel.currentSubtitle) // set media source and resolution in the beginning lifecycleScope.launch(Dispatchers.IO) { @@ -1384,9 +1367,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } private fun createExoPlayer() { - // control for the track sources like subtitles and audio source - trackSelector = DefaultTrackSelector(requireContext()) + viewModel.keepOrCreatePlayer(requireContext()).let { (player, trackSelector) -> + this.exoPlayer = player + this.trackSelector = trackSelector + } + exoPlayer.setWakeMode(C.WAKE_MODE_NETWORK) + exoPlayer.addListener(playerListener) + + // control for the track sources like subtitles and audio source trackSelector.updateParameters { val enabledVideoCodecs = PlayerHelper.enabledVideoCodecs if (enabledVideoCodecs != "all") { @@ -1399,21 +1388,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { this.setPreferredVideoMimeType(mimeType) } } - PlayerHelper.applyPreferredAudioQuality(requireContext(), trackSelector) - - exoPlayer = PlayerHelper.createPlayer(requireContext(), trackSelector, false) - exoPlayer.setWakeMode(C.WAKE_MODE_NETWORK) - exoPlayer.addListener(playerListener) - viewModel.player = exoPlayer } /** * show the [NowPlayingNotification] for the current video */ private fun initializePlayerNotification() { - if (!this::nowPlayingNotification.isInitialized) { - nowPlayingNotification = NowPlayingNotification( + if (viewModel.nowPlayingNotification == null) { + viewModel.nowPlayingNotification = NowPlayingNotification( requireContext(), exoPlayer, NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_ONLINE @@ -1424,7 +1407,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { streams.uploader, streams.thumbnailUrl ) - nowPlayingNotification.updatePlayerNotification(videoId, playerNotificationData) + viewModel.nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData) } /** @@ -1462,7 +1445,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { ) { index -> val subtitle = subtitles.getOrNull(index) ?: return@setSimpleItems updateCurrentSubtitle(subtitle) - this.currentSubtitle = subtitle + viewModel.currentSubtitle = subtitle } .show(childFragmentManager) } @@ -1571,11 +1554,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // pause the video and keep the app alive if (lifecycle.currentState == Lifecycle.State.CREATED) { exoPlayer.pause() - nowPlayingNotification.cancelNotification() + viewModel.nowPlayingNotification?.cancelNotification() closedVideo = true } - updateCurrentSubtitle(currentSubtitle) + updateCurrentSubtitle(viewModel.currentSubtitle) // unset fullscreen if it's not been enabled before the start of PiP if (viewModel.isFullscreen.value != true) { @@ -1670,10 +1653,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { val orientation = resources.configuration.orientation if (viewModel.isFullscreen.value != true && orientation != playerLayoutOrientation) { + // remember the current position before recreating the activity if (this::exoPlayer.isInitialized) { arguments?.putLong(IntentData.timeStamp, exoPlayer.currentPosition / 1000) } playerLayoutOrientation = orientation + + viewModel.shouldUseExistingPlayer = true activity?.recreate() } } diff --git a/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt index 37b481dc4b..7cf9d84c72 100644 --- a/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt @@ -1,12 +1,46 @@ + package com.github.libretube.ui.models +import android.content.Context +import androidx.annotation.OptIn import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import com.github.libretube.R +import com.github.libretube.api.JsonHelper +import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.ChapterSegment +import com.github.libretube.api.obj.Message +import com.github.libretube.api.obj.Segment +import com.github.libretube.api.obj.Streams +import com.github.libretube.api.obj.Subtitle +import com.github.libretube.helpers.PlayerHelper +import com.github.libretube.util.NowPlayingNotification +import com.github.libretube.util.deArrow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import retrofit2.HttpException +import java.io.IOException class PlayerViewModel : ViewModel() { var player: ExoPlayer? = null + var trackSelector: DefaultTrackSelector? = null + + // data to remember for recovery on orientation change + private var streamsInfo: Streams? = null + var nowPlayingNotification: NowPlayingNotification? = null + var segments = listOf() + var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode) + var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() + + /** + * Whether to continue using the current player + * Set to true if the activity will be recreated due to an orientation change + */ + var shouldUseExistingPlayer = false val isMiniPlayerVisible = MutableLiveData(false) val isFullscreen = MutableLiveData(false) @@ -16,4 +50,49 @@ class PlayerViewModel : ViewModel() { val chaptersLiveData = MutableLiveData>() val chapters get() = chaptersLiveData.value.orEmpty() + + /** + * @return pair of the stream info and the error message if the request was not successful + */ + suspend fun fetchVideoInfo(context: Context, videoId: String): Pair = + withContext(Dispatchers.IO) { + if (shouldUseExistingPlayer && streamsInfo != null) return@withContext streamsInfo to null + + streamsInfo = try { + RetrofitInstance.api.getStreams(videoId).apply { + relatedStreams = relatedStreams.deArrow() + } + } catch (e: IOException) { + return@withContext null to context.getString(R.string.unknown_error) + } catch (e: HttpException) { + val errorMessage = e.response()?.errorBody()?.string()?.runCatching { + JsonHelper.json.decodeFromString(this).message + }?.getOrNull() ?: context.getString(R.string.server_error) + return@withContext null to errorMessage + } + + return@withContext streamsInfo to null + } + + suspend fun fetchSponsorBlockSegments(videoId: String) = withContext(Dispatchers.IO) { + if (sponsorBlockConfig.isEmpty() || shouldUseExistingPlayer) return@withContext + + runCatching { + segments = + RetrofitInstance.api.getSegments( + videoId, + JsonHelper.json.encodeToString(sponsorBlockConfig.keys) + ).segments + } + } + + @OptIn(UnstableApi::class) + fun keepOrCreatePlayer(context: Context): Pair { + if (!shouldUseExistingPlayer || player == null || trackSelector == null) { + this.trackSelector = DefaultTrackSelector(context) + this.player = PlayerHelper.createPlayer(context, trackSelector!!, false) + } + + return this.player!! to this.trackSelector!! + } } diff --git a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt index e1d9b5dc37..81a32a016c 100644 --- a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -391,12 +391,9 @@ class NowPlayingNotification( /** * Destroy the [NowPlayingNotification] */ - fun destroySelfAndPlayer() { + fun destroySelf() { mediaSession.release() - player.stop() - player.release() - runCatching { context.unregisterReceiver(notificationActionReceiver) }