From e244ded084f4d95d3a841704ddfefba9d5e2f771 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Mon, 18 Nov 2024 18:25:41 +0100 Subject: [PATCH] refactor: merge VideoOnlinePlayerService with OnlinePlayerService --- .../com/github/libretube/api/obj/Segment.kt | 7 +- .../github/libretube/constants/IntentData.kt | 2 +- .../github/libretube/enums/PlayerCommand.kt | 4 +- .../libretube/extensions/SetMetadata.kt | 9 +- .../github/libretube/helpers/PlayerHelper.kt | 44 +-- .../services/AbstractPlayerService.kt | 51 ++- .../libretube/services/OnlinePlayerService.kt | 181 ++++++++--- .../services/VideoOnlinePlayerService.kt | 167 +--------- .../ui/activities/OfflinePlayerActivity.kt | 52 --- .../libretube/ui/fragments/PlayerFragment.kt | 301 ++++++------------ .../libretube/ui/models/PlayerViewModel.kt | 40 --- .../libretube/ui/views/OnlinePlayerView.kt | 83 +++-- 12 files changed, 365 insertions(+), 576 deletions(-) diff --git a/app/src/main/java/com/github/libretube/api/obj/Segment.kt b/app/src/main/java/com/github/libretube/api/obj/Segment.kt index 74a871dff0..c092a08921 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Segment.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Segment.kt @@ -1,11 +1,15 @@ package com.github.libretube.api.obj +import android.os.Parcelable import androidx.collection.FloatFloatPair +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable +@Parcelize data class Segment( @SerialName("UUID") val uuid: String? = null, val actionType: String? = null, @@ -17,7 +21,8 @@ data class Segment( val videoDuration: Double? = null, val votes: Int? = null, var skipped: Boolean = false -) { +): Parcelable { @Transient + @IgnoredOnParcel val segmentStartAndEnd = FloatFloatPair(segment[0], segment[1]) } diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 3ad4f84862..c009dd4e06 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -44,7 +44,6 @@ object IntentData { const val maxAudioQuality = "maxAudioQuality" const val audioLanguage = "audioLanguage" const val captionLanguage = "captionLanguage" - const val wasIntentStopped = "wasIntentStopped" const val tabData = "tabData" const val videoList = "videoList" const val nextPage = "nextPage" @@ -57,4 +56,5 @@ object IntentData { const val downloadInfo = "downloadInfo" const val streams = "streams" const val chapters = "chapters" + const val segments = "segments" } diff --git a/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt b/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt index fbc95b975a..ed1dfce9b0 100644 --- a/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt +++ b/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt @@ -1,11 +1,11 @@ package com.github.libretube.enums enum class PlayerCommand { - START_PLAYBACK, SKIP_SILENCE, SET_VIDEO_TRACK_TYPE_DISABLED, SET_AUDIO_ROLE_FLAGS, SET_RESOLUTION, SET_AUDIO_LANGUAGE, - SET_SUBTITLE + SET_SUBTITLE, + SET_SB_AUTO_SKIP_ENABLED, } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt b/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt index 0cf680e6ec..2cd8ab42b9 100644 --- a/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt +++ b/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt @@ -13,10 +13,12 @@ import com.github.libretube.db.obj.DownloadChapter import com.github.libretube.db.obj.DownloadWithItems @OptIn(UnstableApi::class) -fun MediaItem.Builder.setMetadata(streams: Streams) = apply { +fun MediaItem.Builder.setMetadata(streams: Streams, videoId: String) = apply { val extras = bundleOf( MediaMetadataCompat.METADATA_KEY_TITLE to streams.title, MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader, + IntentData.videoId to videoId, + IntentData.streams to streams, IntentData.chapters to streams.chapters ) setMediaMetadata( @@ -27,6 +29,8 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply { .setArtworkUri(streams.thumbnailUrl.toUri()) .setComposer(streams.uploaderUrl.toID()) .setExtras(extras) + // send a unique timestamp to notify that the metadata changed, even if playing the same video twice + .setTrackNumber(System.currentTimeMillis().mod(Int.MAX_VALUE)) .build() ) } @@ -38,6 +42,7 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply val extras = bundleOf( MediaMetadataCompat.METADATA_KEY_TITLE to download.title, MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader, + IntentData.videoId to download.videoId, IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment) ) setMediaMetadata( @@ -47,6 +52,8 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply .setDurationMs(download.duration?.times(1000)) .setArtworkUri(download.thumbnailPath?.toAndroidUri()) .setExtras(extras) + // send a unique timestamp to notify that the metadata changed, even if playing the same video twice + .setTrackNumber(System.currentTimeMillis().mod(Int.MAX_VALUE)) .build() ) } diff --git a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt index cf27e629d2..2b645ffac0 100644 --- a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt @@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo import android.net.Uri import android.util.Base64 import android.view.accessibility.CaptioningManager -import android.widget.Toast import androidx.annotation.OptIn import androidx.annotation.StringRes import androidx.core.app.PendingIntentCompat @@ -42,6 +41,7 @@ import com.github.libretube.db.obj.WatchPosition import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.SbSkipOptions import com.github.libretube.extensions.seekBy +import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.extensions.updateParameters import com.github.libretube.obj.VideoStats @@ -333,13 +333,13 @@ object PlayerHelper { false ) - val enabledVideoCodecs: String + private val enabledVideoCodecs: String get() = PreferenceHelper.getString( PreferenceKeys.ENABLED_VIDEO_CODECS, "all" ) - val enabledAudioCodecs: String + private val enabledAudioCodecs: String get() = PreferenceHelper.getString( PreferenceKeys.ENABLED_AUDIO_CODECS, "all" @@ -601,7 +601,8 @@ object PlayerHelper { fun Player.checkForSegments( context: Context, segments: List, - sponsorBlockConfig: MutableMap + sponsorBlockConfig: MutableMap, + skipAutomaticallyIfEnabled: Boolean ): Segment? { for (segment in segments.filter { it.category != SPONSOR_HIGHLIGHT_CATEGORY }) { val (start, end) = segment.segmentStartAndEnd @@ -609,25 +610,26 @@ object PlayerHelper { // avoid seeking to the same segment multiple times, e.g. when the SB segment is at the end of the video if ((duration - currentPosition).absoluteValue < 500) continue - - if (currentPosition in segmentStart until segmentEnd) { - val key = sponsorBlockConfig[segment.category] - if (key == SbSkipOptions.AUTOMATIC || - (key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped) - ) { - if (sponsorBlockNotifications) { - runCatching { - Toast.makeText(context, R.string.segment_skipped, Toast.LENGTH_SHORT) - .show() - } + if (currentPosition !in segmentStart until segmentEnd) continue + + val key = sponsorBlockConfig[segment.category] + + if (!skipAutomaticallyIfEnabled || key == SbSkipOptions.MANUAL || + (key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped) + ) { + return segment + } else if (key == SbSkipOptions.AUTOMATIC || + (key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped) + ) { + if (sponsorBlockNotifications) { + runCatching { + context.toastFromMainThread(R.string.segment_skipped) } - seekTo(segmentEnd) - segment.skipped = true - } else if (key == SbSkipOptions.MANUAL || - (key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped) - ) { - return segment } + seekTo(segmentEnd) + segment.skipped = true + } else { + return null } } return null diff --git a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt index 36c600258d..af60da7fe5 100644 --- a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt @@ -19,8 +19,10 @@ import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import com.github.libretube.R +import com.github.libretube.api.obj.Subtitle import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerEvent +import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.extensions.updateParameters import com.github.libretube.helpers.PlayerHelper @@ -110,8 +112,53 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio open fun runPlayerCommand(args: Bundle) { when { - args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> - exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name) + args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled = + args.getBoolean(PlayerCommand.SKIP_SILENCE.name) + + args.containsKey(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) -> trackSelector?.updateParameters { + setTrackTypeDisabled( + C.TRACK_TYPE_VIDEO, + args.getBoolean(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) + ) + } + + args.containsKey(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name) -> { + trackSelector?.updateParameters { + setPreferredAudioRoleFlags(args.getInt(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name)) + } + } + + args.containsKey(PlayerCommand.SET_AUDIO_LANGUAGE.name) -> { + trackSelector?.updateParameters { + setPreferredAudioLanguage(args.getString(PlayerCommand.SET_AUDIO_LANGUAGE.name)) + } + } + + args.containsKey(PlayerCommand.SET_RESOLUTION.name) -> { + trackSelector?.updateParameters { + val resolution = args.getInt(PlayerCommand.SET_RESOLUTION.name) + setMinVideoSize(Int.MIN_VALUE, resolution) + setMaxVideoSize(Int.MAX_VALUE, resolution) + } + } + + args.containsKey(PlayerCommand.SET_SUBTITLE.name) -> { + val subtitle: Subtitle? = args.parcelable(PlayerCommand.SET_SUBTITLE.name) + + trackSelector?.updateParameters { + val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0 + setPreferredTextRoleFlags(roleFlags) + setPreferredTextLanguage(subtitle?.code) + } + } + } + } + + fun getSubtitleRoleFlags(subtitle: Subtitle?): Int { + return if (subtitle?.autoGenerated != true) { + C.ROLE_FLAG_CAPTION + } else { + PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE } } 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 7d80c04fc9..795131c67c 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -1,38 +1,52 @@ package com.github.libretube.services +import android.net.Uri import android.os.Bundle import androidx.core.net.toUri +import androidx.core.os.bundleOf import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.SubtitleConfiguration +import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.Player +import androidx.media3.datasource.cronet.CronetDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource +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.StreamsExtractor import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams import com.github.libretube.constants.IntentData +import com.github.libretube.constants.PreferenceKeys import com.github.libretube.db.DatabaseHelper +import com.github.libretube.enums.PlayerCommand import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.toID import com.github.libretube.extensions.toastFromMainDispatcher +import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper.checkForSegments +import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.ProxyHelper import com.github.libretube.parcelable.PlayerData import com.github.libretube.ui.activities.MainActivity import com.github.libretube.util.PlayingQueue +import com.github.libretube.util.YoutubeHlsPlaylistParser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString +import java.util.concurrent.Executors /** * Loads the selected videos audio in background mode with a notification area. */ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) -class OnlinePlayerService : AbstractPlayerService() { +open class OnlinePlayerService : AbstractPlayerService() { override val isOfflinePlayer: Boolean = false override val isAudioOnlyPlayer: Boolean = true override val intentActivity: Class<*> = MainActivity::class.java @@ -42,6 +56,11 @@ class OnlinePlayerService : AbstractPlayerService() { private var channelId: String? = null private var startTimestamp: Long? = null + private val cronetDataSourceFactory = CronetDataSource.Factory( + CronetHelper.cronetEngine, + Executors.newCachedThreadPool() + ) + /** * The response that gets when called the Api. */ @@ -49,6 +68,7 @@ class OnlinePlayerService : AbstractPlayerService() { private set // SponsorBlock Segment data + private var sponsorBlockAutoSkip = true private var sponsorBlockSegments = listOf() private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() @@ -90,6 +110,7 @@ class OnlinePlayerService : AbstractPlayerService() { // get the intent arguments videoId = playerData.videoId playlistId = playerData.playlistId + channelId = playerData.channelId startTimestamp = playerData.timestamp if (!playerData.keepQueue) PlayingQueue.clear() @@ -111,7 +132,8 @@ class OnlinePlayerService : AbstractPlayerService() { try { StreamsExtractor.extractStreams(videoId) } catch (e: Exception) { - val errorMessage = StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e) + val errorMessage = + StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e) this@OnlinePlayerService.toastFromMainDispatcher(errorMessage) return@withContext null } @@ -139,18 +161,14 @@ class OnlinePlayerService : AbstractPlayerService() { } private fun playAudio(seekToPosition: Long) { - scope.launch { - setMediaItem() - - withContext(Dispatchers.Main) { - // seek to the previous position if available - if (seekToPosition != 0L) { - exoPlayer?.seekTo(seekToPosition) - } else if (PlayerHelper.watchPositionsAudio) { - PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let { - exoPlayer?.seekTo(it) - } - } + setStreamSource() + + // seek to the previous position if available + if (seekToPosition != 0L) { + exoPlayer?.seekTo(seekToPosition) + } else if (PlayerHelper.watchPositionsAudio) { + PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let { + exoPlayer?.seekTo(it) } } @@ -174,6 +192,7 @@ class OnlinePlayerService : AbstractPlayerService() { saveWatchPosition() if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return + if (!isAudioOnlyPlayer && PlayerHelper.autoPlayCountdown) return val nextVideo = nextId ?: PlayingQueue.getNext() ?: return @@ -188,49 +207,123 @@ class OnlinePlayerService : AbstractPlayerService() { } /** - * Sets the [MediaItem] with the [streams] into the [exoPlayer] + * fetch the segments for SponsorBlock */ - private suspend fun setMediaItem() { - val streams = streams ?: return - - val (uri, mimeType) = - if (!PlayerHelper.useHlsOverDash && streams.audioStreams.isNotEmpty()) { - PlayerHelper.createDashSource(streams, this) to MimeTypes.APPLICATION_MPD - } else { - ProxyHelper.unwrapStreamUrl(streams.hls.orEmpty()) - .toUri() to MimeTypes.APPLICATION_M3U8 - } + private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) { + runCatching { + if (sponsorBlockConfig.isEmpty()) return@runCatching + sponsorBlockSegments = RetrofitInstance.api.getSegments( + videoId, + JsonHelper.json.encodeToString(sponsorBlockConfig.keys) + ).segments - val mediaItem = MediaItem.Builder() - .setUri(uri) - .setMimeType(mimeType) - .setMetadata(streams) - .build() - withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) } - } + withContext(Dispatchers.Main) { + exoPlayer?.playlistMetadata = MediaMetadata.Builder() + .setExtras(bundleOf(IntentData.segments to ArrayList(sponsorBlockSegments))) + .build() - /** - * fetch the segments for SponsorBlock - */ - private fun fetchSponsorBlockSegments() { - scope.launch(Dispatchers.IO) { - runCatching { - if (sponsorBlockConfig.isEmpty()) return@runCatching - sponsorBlockSegments = RetrofitInstance.api.getSegments( - videoId, - JsonHelper.json.encodeToString(sponsorBlockConfig.keys) - ).segments checkForSegments() } } } + /** * check for SponsorBlock segments */ private fun checkForSegments() { handler.postDelayed(this::checkForSegments, 100) - exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig) + exoPlayer?.checkForSegments( + this, + sponsorBlockSegments, + sponsorBlockConfig, + skipAutomaticallyIfEnabled = sponsorBlockAutoSkip + ) } + + override fun runPlayerCommand(args: Bundle) { + super.runPlayerCommand(args) + + if (args.containsKey(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name)) { + sponsorBlockAutoSkip = args.getBoolean(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name) + } + } + + /** + * Sets the [MediaItem] with the [streams] into the [exoPlayer] + */ + private fun setStreamSource() { + val streams = streams ?: return + + when { + // LBRY HLS + PreferenceHelper.getBoolean( + PreferenceKeys.LBRY_HLS, + false + ) && streams.videoStreams.any { + it.quality.orEmpty().contains("LBRY HLS") + } -> { + val lbryHlsUrl = streams.videoStreams.first { + it.quality!!.contains("LBRY HLS") + }.url!! + + val mediaItem = + createMediaItem(lbryHlsUrl.toUri(), MimeTypes.APPLICATION_M3U8, streams) + exoPlayer?.setMediaItem(mediaItem) + } + // DASH + !PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> { + // only use the dash manifest generated by YT if either it's a livestream or no other source is available + val dashUri = + if (streams.isLive && streams.dash != null) { + ProxyHelper.unwrapStreamUrl( + streams.dash + ).toUri() + } else { + // skip LBRY urls when checking whether the stream source is usable + PlayerHelper.createDashSource(streams, this) + } + + val mediaItem = createMediaItem(dashUri, MimeTypes.APPLICATION_MPD, streams) + exoPlayer?.setMediaItem(mediaItem) + } + // HLS + streams.hls != null -> { + val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory) + .setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory()) + + val mediaItem = createMediaItem( + ProxyHelper.unwrapStreamUrl(streams.hls).toUri(), + MimeTypes.APPLICATION_M3U8, + streams + ) + val mediaSource = hlsMediaSourceFactory.createMediaSource(mediaItem) + + exoPlayer?.setMediaSource(mediaSource) + return + } + // NO STREAM FOUND + else -> { + toastFromMainThread(R.string.unknown_error) + return + } + } + } + + private fun getSubtitleConfigs(): List = streams?.subtitles?.map { + val roleFlags = getSubtitleRoleFlags(it) + SubtitleConfiguration.Builder(it.url!!.toUri()) + .setRoleFlags(roleFlags) + .setLanguage(it.code) + .setMimeType(it.mimeType).build() + }.orEmpty() + + private fun createMediaItem(uri: Uri, mimeType: String, streams: Streams) = + MediaItem.Builder() + .setUri(uri) + .setMimeType(mimeType) + .setSubtitleConfigurations(getSubtitleConfigs()) + .setMetadata(streams, videoId) + .build() } diff --git a/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt index 0d4284876f..1d6608af10 100644 --- a/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt @@ -1,174 +1,9 @@ package com.github.libretube.services -import android.net.Uri -import android.os.Bundle import androidx.annotation.OptIn -import androidx.core.net.toUri -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaItem.SubtitleConfiguration -import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.cronet.CronetDataSource -import androidx.media3.exoplayer.hls.HlsMediaSource -import com.github.libretube.R -import com.github.libretube.api.CronetHelper -import com.github.libretube.api.obj.Streams -import com.github.libretube.api.obj.Subtitle -import com.github.libretube.constants.IntentData -import com.github.libretube.constants.PreferenceKeys -import com.github.libretube.enums.PlayerCommand -import com.github.libretube.extensions.parcelable -import com.github.libretube.extensions.setMetadata -import com.github.libretube.extensions.toastFromMainThread -import com.github.libretube.extensions.updateParameters -import com.github.libretube.helpers.PlayerHelper -import com.github.libretube.helpers.PreferenceHelper -import com.github.libretube.helpers.ProxyHelper -import com.github.libretube.ui.activities.MainActivity -import com.github.libretube.util.YoutubeHlsPlaylistParser -import java.util.concurrent.Executors @OptIn(UnstableApi::class) -class VideoOnlinePlayerService : AbstractPlayerService() { - override val isOfflinePlayer: Boolean = false +class VideoOnlinePlayerService : OnlinePlayerService() { override val isAudioOnlyPlayer: Boolean = false - override val intentActivity: Class<*> = MainActivity::class.java - - private val cronetDataSourceFactory = CronetDataSource.Factory( - CronetHelper.cronetEngine, - Executors.newCachedThreadPool() - ) - - private lateinit var streams: Streams - - override suspend fun onServiceCreated(args: Bundle) { - this.streams = args.parcelable(IntentData.streams) ?: return - - startPlayback() - } - - override suspend fun startPlayback() = Unit - - override fun runPlayerCommand(args: Bundle) { - when { - args.containsKey(PlayerCommand.START_PLAYBACK.name) -> setStreamSource() - args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name) - args.containsKey(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) -> trackSelector?.updateParameters { - setTrackTypeDisabled( - C.TRACK_TYPE_VIDEO, - args.getBoolean(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) - ) - } - args.containsKey(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name) -> { - trackSelector?.updateParameters { - setPreferredAudioRoleFlags(args.getInt(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name)) - } - } - args.containsKey(PlayerCommand.SET_AUDIO_LANGUAGE.name) -> { - trackSelector?.updateParameters { - setPreferredAudioLanguage(args.getString(PlayerCommand.SET_AUDIO_LANGUAGE.name)) - } - } - args.containsKey(PlayerCommand.SET_RESOLUTION.name) -> { - trackSelector?.updateParameters { - val resolution = args.getInt(PlayerCommand.SET_RESOLUTION.name) - setMinVideoSize(Int.MIN_VALUE, resolution) - setMaxVideoSize(Int.MAX_VALUE, resolution) - } - } - args.containsKey(PlayerCommand.SET_SUBTITLE.name) -> { - updateCurrentSubtitle(args.parcelable(PlayerCommand.SET_SUBTITLE.name)) - } - } - } - - private fun setStreamSource() { - if (!this::streams.isInitialized) return - - val (uri, mimeType) = when { - // LBRY HLS - PreferenceHelper.getBoolean( - PreferenceKeys.LBRY_HLS, - false - ) && streams.videoStreams.any { - it.quality.orEmpty().contains("LBRY HLS") - } -> { - val lbryHlsUrl = streams.videoStreams.first { - it.quality!!.contains("LBRY HLS") - }.url!! - lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8 - } - // DASH - !PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> { - // only use the dash manifest generated by YT if either it's a livestream or no other source is available - val dashUri = - if (streams.isLive && streams.dash != null) { - ProxyHelper.unwrapStreamUrl( - streams.dash!! - ).toUri() - } else { - // skip LBRY urls when checking whether the stream source is usable - PlayerHelper.createDashSource(streams, this) - } - - dashUri to MimeTypes.APPLICATION_MPD - } - // HLS - streams.hls != null -> { - val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory) - .setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory()) - - val mediaSource = hlsMediaSourceFactory.createMediaSource( - createMediaItem( - ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(), - MimeTypes.APPLICATION_M3U8 - ) - ) - exoPlayer?.setMediaSource(mediaSource) - return - } - // NO STREAM FOUND - else -> { - toastFromMainThread(R.string.unknown_error) - return - } - } - setMediaSource(uri, mimeType) - } - - private fun getSubtitleConfigs(): List = streams.subtitles.map { - val roleFlags = getSubtitleRoleFlags(it) - SubtitleConfiguration.Builder(it.url!!.toUri()) - .setRoleFlags(roleFlags) - .setLanguage(it.code) - .setMimeType(it.mimeType).build() - } - - private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder() - .setUri(uri) - .setMimeType(mimeType) - .setSubtitleConfigurations(getSubtitleConfigs()) - .setMetadata(streams) - .build() - - private fun setMediaSource(uri: Uri, mimeType: String) { - val mediaItem = createMediaItem(uri, mimeType) - exoPlayer?.setMediaItem(mediaItem) - } - - private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int { - return if (subtitle?.autoGenerated != true) { - C.ROLE_FLAG_CAPTION - } else { - PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE - } - } - - private fun updateCurrentSubtitle(subtitle: Subtitle?) = - trackSelector?.updateParameters { - val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0 - setPreferredTextRoleFlags(roleFlags) - setPreferredTextLanguage(subtitle?.code) - } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index 87050d8919..7d565d0c6d 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -24,7 +24,6 @@ import com.github.libretube.databinding.ActivityOfflinePlayerBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.obj.DownloadChapter -import com.github.libretube.db.obj.filterByTab import com.github.libretube.enums.FileType import com.github.libretube.enums.PlayerEvent import com.github.libretube.extensions.serializableExtra @@ -39,7 +38,6 @@ import com.github.libretube.ui.listeners.SeekbarPreviewListener import com.github.libretube.ui.models.ChaptersViewModel import com.github.libretube.ui.models.CommonPlayerViewModel import com.github.libretube.util.OfflineTimeFrameReceiver -import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -59,11 +57,6 @@ class OfflinePlayerActivity : BaseActivity() { private val commonPlayerViewModel: CommonPlayerViewModel by viewModels() private val chaptersViewModel: ChaptersViewModel by viewModels() - private val watchPositionTimer = PauseableTimer( - onTick = this::saveWatchPosition, - delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS - ) - private val playerListener = object : Player.Listener { override fun onEvents(player: Player, events: Player.Events) { super.onEvents(player, events) @@ -87,13 +80,6 @@ class OfflinePlayerActivity : BaseActivity() { pipParams ) } - - // Start or pause watch position timer - if (isPlaying) { - watchPositionTimer.resume() - } else { - watchPositionTimer.pause() - } } override fun onPlaybackStateChanged(playbackState: Int) { @@ -108,10 +94,6 @@ class OfflinePlayerActivity : BaseActivity() { ) ) } - - if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) { - playNextVideo(PlayingQueue.getNext() ?: return) - } } } @@ -153,9 +135,6 @@ class OfflinePlayerActivity : BaseActivity() { binding = ActivityOfflinePlayerBinding.inflate(layoutInflater) setContentView(binding.root) - PlayingQueue.resetToDefaults() - PlayingQueue.clear() - PlayingQueue.setOnQueueTapListener { streamItem -> playNextVideo(streamItem.url ?: return@setOnQueueTapListener) } @@ -180,12 +159,9 @@ class OfflinePlayerActivity : BaseActivity() { if (PlayerHelper.pipEnabled) { PictureInPictureCompat.setPictureInPictureParams(this, pipParams) } - - lifecycleScope.launch { fillQueue() } } private fun playNextVideo(videoId: String) { - saveWatchPosition() this.videoId = videoId playVideo() } @@ -234,29 +210,9 @@ class OfflinePlayerActivity : BaseActivity() { timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let { OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it) } - - if (PlayerHelper.watchPositionsVideo) { - PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.duration)?.let { - playerController.seekTo(it) - } - } } } - private suspend fun fillQueue() { - val downloads = withContext(Dispatchers.IO) { - Database.downloadDao().getAll() - }.filterByTab(DownloadTab.VIDEO) - - PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() }) - } - - private fun saveWatchPosition() { - if (!PlayerHelper.watchPositionsVideo) return - - PlayerHelper.saveWatchPosition(playerController, videoId) - } - override fun onResume() { commonPlayerViewModel.isFullscreen.value = true super.onResume() @@ -272,14 +228,6 @@ class OfflinePlayerActivity : BaseActivity() { } override fun onDestroy() { - saveWatchPosition() - - watchPositionTimer.destroy() - - runCatching { - playerController.stop() - } - runCatching { unregisterReceiver(playerActionReceiver) } 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 4fc4dd31fe..ec42288de9 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 @@ -16,6 +16,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.PowerManager +import android.util.Log import android.view.KeyEvent import android.view.LayoutInflater import android.view.PixelCopy @@ -44,6 +45,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.media3.common.C +import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.session.MediaController @@ -58,16 +60,15 @@ import com.github.libretube.compat.PictureInPictureParamsCompat import com.github.libretube.constants.IntentData import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.FragmentPlayerBinding -import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.parcelable +import com.github.libretube.extensions.parcelableList import com.github.libretube.extensions.serializableExtra import com.github.libretube.extensions.toID -import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.extensions.updateIfChanged import com.github.libretube.helpers.BackgroundHelper @@ -106,7 +107,6 @@ import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.CommentsSheet import com.github.libretube.ui.sheets.StatsSheet import com.github.libretube.util.OnlineTimeFrameReceiver -import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils.toTimeInSeconds @@ -137,13 +137,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { private lateinit var videoId: String private var playlistId: String? = null private var channelId: String? = null - private var keepQueue = false - private var timeStamp = 0L private var isShort = false // data and objects stored for the player private lateinit var streams: Streams - private var isPlayerTransitioning = true // if null, it's been set to automatic private var fullscreenResolution: Int? = null @@ -224,12 +221,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } } - // schedule task to save the watch position each second - private val watchPositionTimer = PauseableTimer( - onTick = this::saveWatchPosition, - delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS - ) - private var bufferingTimeoutTask: Runnable? = null private val playerListener = object : Player.Listener { @@ -246,26 +237,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { BackgroundHelper.stopBackgroundPlay(requireContext()) } - // add the video to the watch history when starting to play the video - if (isPlaying && PlayerHelper.watchHistoryEnabled) { - lifecycleScope.launch(Dispatchers.IO) { - DatabaseHelper.addToWatchHistory(videoId, streams) - } - } - if (isPlaying && PlayerHelper.sponsorBlockEnabled) { handler.postDelayed( this@PlayerFragment::checkForSegments, 100 ) } - - // Start or pause watch position timer - if (isPlaying) { - watchPositionTimer.resume() - } else { - watchPositionTimer.pause() - } } override fun onEvents(player: Player, events: Player.Events) { @@ -282,8 +259,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } override fun onPlaybackStateChanged(playbackState: Int) { - saveWatchPosition() - // set the playback speed to one if having reached the end of a livestream if (playbackState == Player.STATE_BUFFERING && binding.player.isLive && playerController.duration - playerController.currentPosition < 700 @@ -293,23 +268,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // check if video has ended, next video is available and autoplay is enabled/the video is part of a played playlist. if (playbackState == Player.STATE_ENDED) { - if (!isPlayerTransitioning && PlayerHelper.isAutoPlayEnabled(playlistId != null)) { - isPlayerTransitioning = true - if (PlayerHelper.autoPlayCountdown) { - showAutoPlayCountdown() - } else { - playNextVideo() - } + if (PlayerHelper.isAutoPlayEnabled(playlistId != null) && PlayerHelper.autoPlayCountdown) { + showAutoPlayCountdown() } else { binding.player.showControllerPermanently() } } - if (playbackState == Player.STATE_READY) { - // media actually playing - isPlayerTransitioning = false - } - // listen for the stop button in the notification if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled && PictureInPictureCompat.isInPictureInPictureMode(requireActivity()) @@ -334,6 +299,37 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { super.onPlaybackStateChanged(playbackState) } + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + super.onMediaMetadataChanged(mediaMetadata) + + mediaMetadata.extras?.getString(IntentData.videoId)?.let { + videoId = it + } + + val maybeStreams: Streams? = mediaMetadata.extras?.parcelable(IntentData.streams) + maybeStreams?.let { + streams = it + viewModel.segments = emptyList() + playVideo() + } + } + + override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) { + super.onPlaylistMetadataChanged(mediaMetadata) + + val segments: List? = mediaMetadata.extras?.parcelableList(IntentData.segments) + viewModel.segments = segments.orEmpty() + + playerBinding.exoProgress.setSegments(viewModel.segments) + playerBinding.sbToggle.isVisible = true + viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY } + ?.let { + lifecycleScope.launch(Dispatchers.IO) { initializeHighlight(it) } + } + + Log.e("rec", "segments received") + } + /** * Catch player errors to prevent the app from stopping */ @@ -369,12 +365,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val playerData = requireArguments().parcelable(IntentData.playerData)!! - videoId = playerData.videoId - playlistId = playerData.playlistId - channelId = playerData.channelId - keepQueue = playerData.keepQueue - timeStamp = playerData.timestamp // broadcast receiver for PiP actions ContextCompat.registerReceiver( @@ -386,11 +376,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true) noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false) - - BackgroundHelper.startMediaService(requireContext(), VideoOnlinePlayerService::class.java, bundleOf()) { - playerController = it - playerController.addListener(playerListener) - } } override fun onCreateView( @@ -406,11 +391,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { super.onViewCreated(view, savedInstanceState) SoftwareKeyboardControllerCompat(view).hide() - // reset the callbacks of the playing queue - PlayingQueue.resetToDefaults() - - // clear the playing queue - if (!keepQueue) PlayingQueue.clear() + val playerData = requireArguments().parcelable(IntentData.playerData)!! + videoId = playerData.videoId + playlistId = playerData.playlistId + channelId = playerData.channelId changeOrientationMode() @@ -439,7 +423,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // offline video playback started and thus the player fragment is no longer needed killPlayerFragment() } else { - playVideo() + attachToPlayerService(playerData) } } @@ -456,20 +440,19 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { ) }.show(childFragmentManager, null) } else { - playVideo() + attachToPlayerService(playerData) } - - showBottomBar() } - /** - * somehow the bottom bar is invisible on low screen resolutions, this fixes it - */ - private fun showBottomBar() { - if (_binding?.player?.isPlayerLocked == false) { - playerBinding.bottomBar.isVisible = true + private fun attachToPlayerService(playerData: PlayerData) { + BackgroundHelper.startMediaService( + requireContext(), + VideoOnlinePlayerService::class.java, + bundleOf(IntentData.playerData to playerData) + ) { + playerController = it + playerController.addListener(playerListener) } - handler.postDelayed(this::showBottomBar, 100) } @SuppressLint("ClickableViewAccessibility") @@ -838,13 +821,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { playerController.pause() } - // the app was put somewhere in the background - remember to not automatically continue - // playing on re-creation of the app - // only run if the re-creation is not caused by an orientation change - if (!viewModel.isOrientationChangeInProgress) { - requireArguments().putBoolean(IntentData.wasIntentStopped, true) - } - super.onPause() } @@ -868,9 +844,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onDestroy() { super.onDestroy() - saveWatchPosition() - - watchPositionTimer.destroy() handler.removeCallbacksAndMessages(null) playerController.removeListener(playerListener) @@ -927,26 +900,22 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } } - // save the watch position if video isn't finished and option enabled - private fun saveWatchPosition() { - if (!isPlayerTransitioning && PlayerHelper.watchPositionsVideo) { - PlayerHelper.saveWatchPosition(playerController, videoId) - } - } - private fun checkForSegments() { if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return handler.postDelayed(this::checkForSegments, 100) - if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return + if (!PlayerHelper.sponsorBlockEnabled || viewModel.segments.isEmpty()) return playerController.checkForSegments( requireContext(), viewModel.segments, - viewModel.sponsorBlockConfig + viewModel.sponsorBlockConfig, + // skipping is done by player service + skipAutomaticallyIfEnabled = false ) ?.let { segment -> if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let + binding.sbSkipBtn.isVisible = true binding.sbSkipBtn.setOnClickListener { playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong()) @@ -954,8 +923,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } return } - if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = - true + + if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true } private fun playVideo() { @@ -966,103 +935,49 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // reset the comments to become reloaded later commentsViewModel.reset() - lifecycleScope.launch(Dispatchers.Main) { - viewModel.fetchVideoInfo(requireContext(), videoId).let { (streams, errorMessage) -> - if (errorMessage != null) { - context?.toastFromMainDispatcher(errorMessage, Toast.LENGTH_LONG) - return@launch - } + // hide the button to skip SponsorBlock segments manually + binding.sbSkipBtn.isGone = true - this@PlayerFragment.streams = streams!! - playerController.sendCustomCommand( - AbstractPlayerService.startServiceCommand, - bundleOf(IntentData.streams to streams) - ) - } - - val isFirstVideo = PlayingQueue.isEmpty() - if (isFirstVideo) { - PlayingQueue.updateQueue(streams.toStreamItem(videoId), playlistId, channelId) - } else { - PlayingQueue.updateCurrent(streams.toStreamItem(videoId)) - } - val isLastVideo = !isFirstVideo && PlayingQueue.isLast() - val isAutoQueue = playlistId == null && channelId == null - if ((isFirstVideo || isLastVideo) && isAutoQueue) { - PlayingQueue.insertRelatedStreams(streams.relatedStreams) - } - - val videoStream = streams.videoStreams.firstOrNull() - isShort = PlayingQueue.getCurrent()?.isShort == true || - (videoStream?.height ?: 0) > (videoStream?.width ?: 0) - - PlayingQueue.setOnQueueTapListener { streamItem -> - streamItem.url?.toID()?.let { playNextVideo(it) } - } - - // hide the button to skip SponsorBlock segments manually - binding.sbSkipBtn.isGone = true - - // set media sources for the player - if (!viewModel.isOrientationChangeInProgress) initStreamSources() - - if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) && - isShort && binding.playerMotionLayout.progress == 0f - ) { - setFullscreen() - } - - binding.player.apply { - useController = false - player = playerController - } + // use the video's default audio track when starting playback + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN + ) + ) - initializePlayerView() + // set the default subtitle if available + updateCurrentSubtitle(viewModel.currentSubtitle) - // don't continue playback when the fragment is re-created after Android killed it - val wasIntentStopped = requireArguments().getBoolean(IntentData.wasIntentStopped, false) - playerController.playWhenReady = - PlayerHelper.playAutomatically && !wasIntentStopped - requireArguments().putBoolean(IntentData.wasIntentStopped, false) + // set media source and resolution in the beginning + updateResolution(commonPlayerViewModel.isFullscreen.value == true) - playerController.prepare() + if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) && + isShort && binding.playerMotionLayout.progress == 0f + ) { + setFullscreen() + } - 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 - } - } + binding.player.apply { + useController = false + player = playerController + } - fetchSponsorBlockSegments() + initializePlayerView() - if (streams.category == Streams.categoryMusic) { - playerController.setPlaybackSpeed(1f) + 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 } - - viewModel.isOrientationChangeInProgress = false } - } - - 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 + if (streams.category == Streams.categoryMusic) { + playerController.setPlaybackSpeed(1f) } - viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY } - ?.let { - initializeHighlight(it) - } + + viewModel.isOrientationChangeInProgress = false } /** @@ -1076,19 +991,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return - // save the current watch position before starting the next video - saveWatchPosition() - videoId = nextId ?: PlayingQueue.getNext() ?: return - isPlayerTransitioning = true // fix: if the fragment is recreated, play the current video, and not the initial one arguments?.run { val playerData = parcelable(IntentData.playerData)!!.copy(videoId = videoId) putParcelable(IntentData.playerData, playerData) - // make sure that autoplay continues without issues as the activity is obviously still alive - // when starting to play the next video - putBoolean(IntentData.wasIntentStopped, false) } // start to play the next video @@ -1245,37 +1153,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { return resolutions.toList() } - private fun initStreamSources() { - // use the video's default audio track when starting playback - playerController.sendCustomCommand( - AbstractPlayerService.runPlayerActionCommand, bundleOf( - PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN - ) - ) - - // set the default subtitle if available - updateCurrentSubtitle(viewModel.currentSubtitle) - - // set media source and resolution in the beginning - updateResolution(commonPlayerViewModel.isFullscreen.value == true) - playerController.sendCustomCommand( - AbstractPlayerService.runPlayerActionCommand, - bundleOf(PlayerCommand.START_PLAYBACK.name to true) - ) - - // support for time stamped links - if (timeStamp != 0L) { - playerController.seekTo(timeStamp * 1000) - // delete the time stamp because it already got consumed - timeStamp = 0L - } else if (!streams.isLive) { - // seek to the saved watch position - PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let { - playerController.seekTo(it) - } - } - } - private fun setPlayerResolution(resolution: Int, isSelectedByUser: Boolean = false) { val transformedResolution = if (!isSelectedByUser && isShort) { ceil(resolution * 16.0 / 9.0).toInt() 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 d03e7c252c..c0e5b2e753 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,27 +1,14 @@ package com.github.libretube.ui.models -import android.content.Context import androidx.lifecycle.ViewModel import androidx.media3.common.util.UnstableApi -import com.github.libretube.api.JsonHelper -import com.github.libretube.api.RetrofitInstance -import com.github.libretube.api.StreamsExtractor 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 @UnstableApi class PlayerViewModel : ViewModel() { - // 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() @@ -32,31 +19,4 @@ class PlayerViewModel : ViewModel() { * Set to true if the activity will be recreated due to an orientation change */ var isOrientationChangeInProgress = false - var sponsorBlockEnabled = PlayerHelper.sponsorBlockEnabled - - /** - * @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 (isOrientationChangeInProgress && streamsInfo != null) return@withContext streamsInfo to null - - return@withContext try { - StreamsExtractor.extractStreams(videoId).deArrow(videoId) to null - } catch (e: Exception) { - return@withContext null to StreamsExtractor.getExtractorErrorMessageString(context, e) - } - } - - suspend fun fetchSponsorBlockSegments(videoId: String) = withContext(Dispatchers.IO) { - if (sponsorBlockConfig.isEmpty() || isOrientationChangeInProgress) return@withContext - - runCatching { - segments = - RetrofitInstance.api.getSegments( - videoId, - JsonHelper.json.encodeToString(sponsorBlockConfig.keys) - ).segments - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt index f910dea025..1c3349fe67 100644 --- a/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt @@ -14,14 +14,17 @@ import androidx.lifecycle.LifecycleOwner import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.enums.PlayerCommand import com.github.libretube.extensions.toID import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.WindowHelper import com.github.libretube.obj.BottomSheetItem +import com.github.libretube.services.AbstractPlayerService import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.dialogs.SubmitDeArrowDialog import com.github.libretube.ui.dialogs.SubmitSegmentDialog @@ -49,39 +52,43 @@ class OnlinePlayerView( var currentWindow: Window? = null var selectedResolution: Int? = null + private var sponsorBlockAutoSkip = true @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) override fun getOptionsMenuItems(): List { return super.getOptionsMenuItems() + - listOf( - BottomSheetItem( - context.getString(R.string.quality), - R.drawable.ic_hd, - this::getCurrentResolutionSummary - ) { - playerOptions?.onQualityClicked() - }, - BottomSheetItem( - context.getString(R.string.audio_track), - R.drawable.ic_audio, - this::getCurrentAudioTrackTitle - ) { - playerOptions?.onAudioStreamClicked() - }, - BottomSheetItem( - context.getString(R.string.captions), - R.drawable.ic_caption, - { playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) } - ) { - playerOptions?.onCaptionsClicked() - }, - BottomSheetItem( - context.getString(R.string.stats_for_nerds), - R.drawable.ic_info - ) { - playerOptions?.onStatsClicked() - } - ) + listOf( + BottomSheetItem( + context.getString(R.string.quality), + R.drawable.ic_hd, + this::getCurrentResolutionSummary + ) { + playerOptions?.onQualityClicked() + }, + BottomSheetItem( + context.getString(R.string.audio_track), + R.drawable.ic_audio, + this::getCurrentAudioTrackTitle + ) { + playerOptions?.onAudioStreamClicked() + }, + BottomSheetItem( + context.getString(R.string.captions), + R.drawable.ic_caption, + { + playerViewModel?.currentSubtitle?.code + ?: context.getString(R.string.none) + } + ) { + playerOptions?.onCaptionsClicked() + }, + BottomSheetItem( + context.getString(R.string.stats_for_nerds), + R.drawable.ic_info + ) { + playerOptions?.onStatsClicked() + } + ) } @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @@ -153,7 +160,8 @@ class OnlinePlayerView( updateTopBarMargin() binding.fullscreen.isInvisible = PlayerHelper.autoFullscreenEnabled - val fullscreenDrawable = if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen + val fullscreenDrawable = + if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen binding.fullscreen.setImageResource(fullscreenDrawable) binding.exoTitle.isInvisible = !isFullscreen @@ -161,25 +169,32 @@ class OnlinePlayerView( val updateSbImageResource = { binding.sbToggle.setImageResource( - if (playerViewModel.sponsorBlockEnabled) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled + if (sponsorBlockAutoSkip) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled ) } updateSbImageResource() binding.sbToggle.setOnClickListener { - playerViewModel.sponsorBlockEnabled = !playerViewModel.sponsorBlockEnabled + sponsorBlockAutoSkip = !sponsorBlockAutoSkip + (player as? MediaController)?.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name to sponsorBlockAutoSkip + ) + ) updateSbImageResource() } syncQueueButtons() - binding.sbSubmit.isVisible = PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false) + binding.sbSubmit.isVisible = + PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false) binding.sbSubmit.setOnClickListener { val submitSegmentDialog = SubmitSegmentDialog() submitSegmentDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener submitSegmentDialog.show((context as BaseActivity).supportFragmentManager, null) } - binding.dearrowSubmit.isVisible = PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_DEARROW, false) + binding.dearrowSubmit.isVisible = + PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_DEARROW, false) binding.dearrowSubmit.setOnClickListener { val submitDialog = SubmitDeArrowDialog() submitDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener