refactor: merge VideoOnlinePlayerService with OnlinePlayerService

This commit is contained in:
Bnyro 2024-11-18 18:25:41 +01:00
parent 889c253393
commit e244ded084
12 changed files with 367 additions and 578 deletions

View File

@ -1,11 +1,15 @@
package com.github.libretube.api.obj package com.github.libretube.api.obj
import android.os.Parcelable
import androidx.collection.FloatFloatPair import androidx.collection.FloatFloatPair
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
@Serializable @Serializable
@Parcelize
data class Segment( data class Segment(
@SerialName("UUID") val uuid: String? = null, @SerialName("UUID") val uuid: String? = null,
val actionType: String? = null, val actionType: String? = null,
@ -17,7 +21,8 @@ data class Segment(
val videoDuration: Double? = null, val videoDuration: Double? = null,
val votes: Int? = null, val votes: Int? = null,
var skipped: Boolean = false var skipped: Boolean = false
) { ): Parcelable {
@Transient @Transient
@IgnoredOnParcel
val segmentStartAndEnd = FloatFloatPair(segment[0], segment[1]) val segmentStartAndEnd = FloatFloatPair(segment[0], segment[1])
} }

View File

@ -44,7 +44,6 @@ object IntentData {
const val maxAudioQuality = "maxAudioQuality" const val maxAudioQuality = "maxAudioQuality"
const val audioLanguage = "audioLanguage" const val audioLanguage = "audioLanguage"
const val captionLanguage = "captionLanguage" const val captionLanguage = "captionLanguage"
const val wasIntentStopped = "wasIntentStopped"
const val tabData = "tabData" const val tabData = "tabData"
const val videoList = "videoList" const val videoList = "videoList"
const val nextPage = "nextPage" const val nextPage = "nextPage"
@ -57,4 +56,5 @@ object IntentData {
const val downloadInfo = "downloadInfo" const val downloadInfo = "downloadInfo"
const val streams = "streams" const val streams = "streams"
const val chapters = "chapters" const val chapters = "chapters"
const val segments = "segments"
} }

View File

@ -1,11 +1,11 @@
package com.github.libretube.enums package com.github.libretube.enums
enum class PlayerCommand { enum class PlayerCommand {
START_PLAYBACK,
SKIP_SILENCE, SKIP_SILENCE,
SET_VIDEO_TRACK_TYPE_DISABLED, SET_VIDEO_TRACK_TYPE_DISABLED,
SET_AUDIO_ROLE_FLAGS, SET_AUDIO_ROLE_FLAGS,
SET_RESOLUTION, SET_RESOLUTION,
SET_AUDIO_LANGUAGE, SET_AUDIO_LANGUAGE,
SET_SUBTITLE SET_SUBTITLE,
SET_SB_AUTO_SKIP_ENABLED,
} }

View File

@ -13,10 +13,12 @@ import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.db.obj.DownloadWithItems
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun MediaItem.Builder.setMetadata(streams: Streams) = apply { fun MediaItem.Builder.setMetadata(streams: Streams, videoId: String) = apply {
val extras = bundleOf( val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title, MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader, MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader,
IntentData.videoId to videoId,
IntentData.streams to streams,
IntentData.chapters to streams.chapters IntentData.chapters to streams.chapters
) )
setMediaMetadata( setMediaMetadata(
@ -27,6 +29,8 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
.setArtworkUri(streams.thumbnailUrl.toUri()) .setArtworkUri(streams.thumbnailUrl.toUri())
.setComposer(streams.uploaderUrl.toID()) .setComposer(streams.uploaderUrl.toID())
.setExtras(extras) .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() .build()
) )
} }
@ -38,6 +42,7 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply
val extras = bundleOf( val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_TITLE to download.title, MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader, MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader,
IntentData.videoId to download.videoId,
IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment) IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment)
) )
setMediaMetadata( setMediaMetadata(
@ -47,6 +52,8 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply
.setDurationMs(download.duration?.times(1000)) .setDurationMs(download.duration?.times(1000))
.setArtworkUri(download.thumbnailPath?.toAndroidUri()) .setArtworkUri(download.thumbnailPath?.toAndroidUri())
.setExtras(extras) .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() .build()
) )
} }

View File

@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import android.view.accessibility.CaptioningManager import android.view.accessibility.CaptioningManager
import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.app.PendingIntentCompat 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.PlayerEvent
import com.github.libretube.enums.SbSkipOptions import com.github.libretube.enums.SbSkipOptions
import com.github.libretube.extensions.seekBy import com.github.libretube.extensions.seekBy
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.extensions.togglePlayPauseState
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.obj.VideoStats import com.github.libretube.obj.VideoStats
@ -333,13 +333,13 @@ object PlayerHelper {
false false
) )
val enabledVideoCodecs: String private val enabledVideoCodecs: String
get() = PreferenceHelper.getString( get() = PreferenceHelper.getString(
PreferenceKeys.ENABLED_VIDEO_CODECS, PreferenceKeys.ENABLED_VIDEO_CODECS,
"all" "all"
) )
val enabledAudioCodecs: String private val enabledAudioCodecs: String
get() = PreferenceHelper.getString( get() = PreferenceHelper.getString(
PreferenceKeys.ENABLED_AUDIO_CODECS, PreferenceKeys.ENABLED_AUDIO_CODECS,
"all" "all"
@ -601,7 +601,8 @@ object PlayerHelper {
fun Player.checkForSegments( fun Player.checkForSegments(
context: Context, context: Context,
segments: List<Segment>, segments: List<Segment>,
sponsorBlockConfig: MutableMap<String, SbSkipOptions> sponsorBlockConfig: MutableMap<String, SbSkipOptions>,
skipAutomaticallyIfEnabled: Boolean
): Segment? { ): Segment? {
for (segment in segments.filter { it.category != SPONSOR_HIGHLIGHT_CATEGORY }) { for (segment in segments.filter { it.category != SPONSOR_HIGHLIGHT_CATEGORY }) {
val (start, end) = segment.segmentStartAndEnd 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 // 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 ((duration - currentPosition).absoluteValue < 500) continue
if (currentPosition !in segmentStart until segmentEnd) continue
if (currentPosition in segmentStart until segmentEnd) {
val key = sponsorBlockConfig[segment.category] val key = sponsorBlockConfig[segment.category]
if (key == SbSkipOptions.AUTOMATIC ||
if (!skipAutomaticallyIfEnabled || key == SbSkipOptions.MANUAL ||
(key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped)
) {
return segment
} else if (key == SbSkipOptions.AUTOMATIC ||
(key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped) (key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped)
) { ) {
if (sponsorBlockNotifications) { if (sponsorBlockNotifications) {
runCatching { runCatching {
Toast.makeText(context, R.string.segment_skipped, Toast.LENGTH_SHORT) context.toastFromMainThread(R.string.segment_skipped)
.show()
} }
} }
seekTo(segmentEnd) seekTo(segmentEnd)
segment.skipped = true segment.skipped = true
} else if (key == SbSkipOptions.MANUAL || } else {
(key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped) return null
) {
return segment
}
} }
} }
return null return null

View File

@ -19,8 +19,10 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.Subtitle
import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerCommand
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
@ -110,8 +112,53 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
open fun runPlayerCommand(args: Bundle) { open fun runPlayerCommand(args: Bundle) {
when { when {
args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled =
exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name) 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
} }
} }

View File

@ -1,38 +1,52 @@
package com.github.libretube.services package com.github.libretube.services
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media3.common.MediaItem 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.MimeTypes
import androidx.media3.common.Player 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.JsonHelper
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.StreamsExtractor import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHelper
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.parcelable.PlayerData import com.github.libretube.parcelable.PlayerData
import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.YoutubeHlsPlaylistParser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import java.util.concurrent.Executors
/** /**
* Loads the selected videos audio in background mode with a notification area. * Loads the selected videos audio in background mode with a notification area.
*/ */
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class OnlinePlayerService : AbstractPlayerService() { open class OnlinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = false override val isOfflinePlayer: Boolean = false
override val isAudioOnlyPlayer: Boolean = true override val isAudioOnlyPlayer: Boolean = true
override val intentActivity: Class<*> = MainActivity::class.java override val intentActivity: Class<*> = MainActivity::class.java
@ -42,6 +56,11 @@ class OnlinePlayerService : AbstractPlayerService() {
private var channelId: String? = null private var channelId: String? = null
private var startTimestamp: Long? = null private var startTimestamp: Long? = null
private val cronetDataSourceFactory = CronetDataSource.Factory(
CronetHelper.cronetEngine,
Executors.newCachedThreadPool()
)
/** /**
* The response that gets when called the Api. * The response that gets when called the Api.
*/ */
@ -49,6 +68,7 @@ class OnlinePlayerService : AbstractPlayerService() {
private set private set
// SponsorBlock Segment data // SponsorBlock Segment data
private var sponsorBlockAutoSkip = true
private var sponsorBlockSegments = listOf<Segment>() private var sponsorBlockSegments = listOf<Segment>()
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
@ -90,6 +110,7 @@ class OnlinePlayerService : AbstractPlayerService() {
// get the intent arguments // get the intent arguments
videoId = playerData.videoId videoId = playerData.videoId
playlistId = playerData.playlistId playlistId = playerData.playlistId
channelId = playerData.channelId
startTimestamp = playerData.timestamp startTimestamp = playerData.timestamp
if (!playerData.keepQueue) PlayingQueue.clear() if (!playerData.keepQueue) PlayingQueue.clear()
@ -111,7 +132,8 @@ class OnlinePlayerService : AbstractPlayerService() {
try { try {
StreamsExtractor.extractStreams(videoId) StreamsExtractor.extractStreams(videoId)
} catch (e: Exception) { } catch (e: Exception) {
val errorMessage = StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e) val errorMessage =
StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
this@OnlinePlayerService.toastFromMainDispatcher(errorMessage) this@OnlinePlayerService.toastFromMainDispatcher(errorMessage)
return@withContext null return@withContext null
} }
@ -139,10 +161,8 @@ class OnlinePlayerService : AbstractPlayerService() {
} }
private fun playAudio(seekToPosition: Long) { private fun playAudio(seekToPosition: Long) {
scope.launch { setStreamSource()
setMediaItem()
withContext(Dispatchers.Main) {
// seek to the previous position if available // seek to the previous position if available
if (seekToPosition != 0L) { if (seekToPosition != 0L) {
exoPlayer?.seekTo(seekToPosition) exoPlayer?.seekTo(seekToPosition)
@ -151,8 +171,6 @@ class OnlinePlayerService : AbstractPlayerService() {
exoPlayer?.seekTo(it) exoPlayer?.seekTo(it)
} }
} }
}
}
exoPlayer?.apply { exoPlayer?.apply {
playWhenReady = PlayerHelper.playAutomatically playWhenReady = PlayerHelper.playAutomatically
@ -174,6 +192,7 @@ class OnlinePlayerService : AbstractPlayerService() {
saveWatchPosition() saveWatchPosition()
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
if (!isAudioOnlyPlayer && PlayerHelper.autoPlayCountdown) return
val nextVideo = nextId ?: PlayingQueue.getNext() ?: return val nextVideo = nextId ?: PlayingQueue.getNext() ?: return
@ -187,50 +206,124 @@ class OnlinePlayerService : AbstractPlayerService() {
} }
} }
/**
* Sets the [MediaItem] with the [streams] into the [exoPlayer]
*/
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
}
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMimeType(mimeType)
.setMetadata(streams)
.build()
withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) }
}
/** /**
* fetch the segments for SponsorBlock * fetch the segments for SponsorBlock
*/ */
private fun fetchSponsorBlockSegments() { private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
runCatching { runCatching {
if (sponsorBlockConfig.isEmpty()) return@runCatching if (sponsorBlockConfig.isEmpty()) return@runCatching
sponsorBlockSegments = RetrofitInstance.api.getSegments( sponsorBlockSegments = RetrofitInstance.api.getSegments(
videoId, videoId,
JsonHelper.json.encodeToString(sponsorBlockConfig.keys) JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
).segments ).segments
withContext(Dispatchers.Main) {
exoPlayer?.playlistMetadata = MediaMetadata.Builder()
.setExtras(bundleOf(IntentData.segments to ArrayList(sponsorBlockSegments)))
.build()
checkForSegments() checkForSegments()
} }
} }
} }
/** /**
* check for SponsorBlock segments * check for SponsorBlock segments
*/ */
private fun checkForSegments() { private fun checkForSegments() {
handler.postDelayed(this::checkForSegments, 100) 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<SubtitleConfiguration> = 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()
} }

View File

@ -1,174 +1,9 @@
package com.github.libretube.services package com.github.libretube.services
import android.net.Uri
import android.os.Bundle
import androidx.annotation.OptIn 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.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) @OptIn(UnstableApi::class)
class VideoOnlinePlayerService : AbstractPlayerService() { class VideoOnlinePlayerService : OnlinePlayerService() {
override val isOfflinePlayer: Boolean = false
override val isAudioOnlyPlayer: Boolean = false 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<Streams>(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<SubtitleConfiguration> = 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)
}
} }

View File

@ -24,7 +24,6 @@ import com.github.libretube.databinding.ActivityOfflinePlayerBinding
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.DownloadChapter 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.FileType
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.serializableExtra 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.ChaptersViewModel
import com.github.libretube.ui.models.CommonPlayerViewModel import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.util.OfflineTimeFrameReceiver import com.github.libretube.util.OfflineTimeFrameReceiver
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -59,11 +57,6 @@ class OfflinePlayerActivity : BaseActivity() {
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels() private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
private val chaptersViewModel: ChaptersViewModel 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 { private val playerListener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events) super.onEvents(player, events)
@ -87,13 +80,6 @@ class OfflinePlayerActivity : BaseActivity() {
pipParams pipParams
) )
} }
// Start or pause watch position timer
if (isPlaying) {
watchPositionTimer.resume()
} else {
watchPositionTimer.pause()
}
} }
override fun onPlaybackStateChanged(playbackState: Int) { 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) binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
PlayingQueue.resetToDefaults()
PlayingQueue.clear()
PlayingQueue.setOnQueueTapListener { streamItem -> PlayingQueue.setOnQueueTapListener { streamItem ->
playNextVideo(streamItem.url ?: return@setOnQueueTapListener) playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
} }
@ -180,12 +159,9 @@ class OfflinePlayerActivity : BaseActivity() {
if (PlayerHelper.pipEnabled) { if (PlayerHelper.pipEnabled) {
PictureInPictureCompat.setPictureInPictureParams(this, pipParams) PictureInPictureCompat.setPictureInPictureParams(this, pipParams)
} }
lifecycleScope.launch { fillQueue() }
} }
private fun playNextVideo(videoId: String) { private fun playNextVideo(videoId: String) {
saveWatchPosition()
this.videoId = videoId this.videoId = videoId
playVideo() playVideo()
} }
@ -234,28 +210,8 @@ class OfflinePlayerActivity : BaseActivity() {
timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let { timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it) 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() { override fun onResume() {
commonPlayerViewModel.isFullscreen.value = true commonPlayerViewModel.isFullscreen.value = true
@ -272,14 +228,6 @@ class OfflinePlayerActivity : BaseActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
saveWatchPosition()
watchPositionTimer.destroy()
runCatching {
playerController.stop()
}
runCatching { runCatching {
unregisterReceiver(playerActionReceiver) unregisterReceiver(playerActionReceiver)
} }

View File

@ -16,6 +16,7 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.PixelCopy import android.view.PixelCopy
@ -44,6 +45,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.MediaController 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.IntentData
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentPlayerBinding import com.github.libretube.databinding.FragmentPlayerBinding
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerCommand
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.ShareObjectType import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.parcelableList
import com.github.libretube.extensions.serializableExtra import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.extensions.togglePlayPauseState
import com.github.libretube.extensions.updateIfChanged import com.github.libretube.extensions.updateIfChanged
import com.github.libretube.helpers.BackgroundHelper 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.CommentsSheet
import com.github.libretube.ui.sheets.StatsSheet import com.github.libretube.ui.sheets.StatsSheet
import com.github.libretube.util.OnlineTimeFrameReceiver import com.github.libretube.util.OnlineTimeFrameReceiver
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils
import com.github.libretube.util.TextUtils.toTimeInSeconds import com.github.libretube.util.TextUtils.toTimeInSeconds
@ -137,13 +137,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
private lateinit var videoId: String private lateinit var videoId: String
private var playlistId: String? = null private var playlistId: String? = null
private var channelId: String? = null private var channelId: String? = null
private var keepQueue = false
private var timeStamp = 0L
private var isShort = false private var isShort = false
// data and objects stored for the player // data and objects stored for the player
private lateinit var streams: Streams private lateinit var streams: Streams
private var isPlayerTransitioning = true
// if null, it's been set to automatic // if null, it's been set to automatic
private var fullscreenResolution: Int? = null 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 var bufferingTimeoutTask: Runnable? = null
private val playerListener = object : Player.Listener { private val playerListener = object : Player.Listener {
@ -246,26 +237,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
BackgroundHelper.stopBackgroundPlay(requireContext()) 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) { if (isPlaying && PlayerHelper.sponsorBlockEnabled) {
handler.postDelayed( handler.postDelayed(
this@PlayerFragment::checkForSegments, this@PlayerFragment::checkForSegments,
100 100
) )
} }
// Start or pause watch position timer
if (isPlaying) {
watchPositionTimer.resume()
} else {
watchPositionTimer.pause()
}
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
@ -282,8 +259,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
saveWatchPosition()
// set the playback speed to one if having reached the end of a livestream // set the playback speed to one if having reached the end of a livestream
if (playbackState == Player.STATE_BUFFERING && binding.player.isLive && if (playbackState == Player.STATE_BUFFERING && binding.player.isLive &&
playerController.duration - playerController.currentPosition < 700 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. // 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 (playbackState == Player.STATE_ENDED) {
if (!isPlayerTransitioning && PlayerHelper.isAutoPlayEnabled(playlistId != null)) { if (PlayerHelper.isAutoPlayEnabled(playlistId != null) && PlayerHelper.autoPlayCountdown) {
isPlayerTransitioning = true
if (PlayerHelper.autoPlayCountdown) {
showAutoPlayCountdown() showAutoPlayCountdown()
} else {
playNextVideo()
}
} else { } else {
binding.player.showControllerPermanently() binding.player.showControllerPermanently()
} }
} }
if (playbackState == Player.STATE_READY) {
// media actually playing
isPlayerTransitioning = false
}
// listen for the stop button in the notification // listen for the stop button in the notification
if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled && if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled &&
PictureInPictureCompat.isInPictureInPictureMode(requireActivity()) PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
@ -334,6 +299,37 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
super.onPlaybackStateChanged(playbackState) 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<Segment>? = 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 * Catch player errors to prevent the app from stopping
*/ */
@ -369,12 +365,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)!!
videoId = playerData.videoId
playlistId = playerData.playlistId
channelId = playerData.channelId
keepQueue = playerData.keepQueue
timeStamp = playerData.timestamp
// broadcast receiver for PiP actions // broadcast receiver for PiP actions
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
@ -386,11 +376,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true) fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false) noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
BackgroundHelper.startMediaService(requireContext(), VideoOnlinePlayerService::class.java, bundleOf()) {
playerController = it
playerController.addListener(playerListener)
}
} }
override fun onCreateView( override fun onCreateView(
@ -406,11 +391,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
SoftwareKeyboardControllerCompat(view).hide() SoftwareKeyboardControllerCompat(view).hide()
// reset the callbacks of the playing queue val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)!!
PlayingQueue.resetToDefaults() videoId = playerData.videoId
playlistId = playerData.playlistId
// clear the playing queue channelId = playerData.channelId
if (!keepQueue) PlayingQueue.clear()
changeOrientationMode() changeOrientationMode()
@ -439,7 +423,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// offline video playback started and thus the player fragment is no longer needed // offline video playback started and thus the player fragment is no longer needed
killPlayerFragment() killPlayerFragment()
} else { } else {
playVideo() attachToPlayerService(playerData)
} }
} }
@ -456,20 +440,19 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
) )
}.show(childFragmentManager, null) }.show(childFragmentManager, null)
} else { } else {
playVideo() attachToPlayerService(playerData)
}
} }
showBottomBar() private fun attachToPlayerService(playerData: PlayerData) {
BackgroundHelper.startMediaService(
requireContext(),
VideoOnlinePlayerService::class.java,
bundleOf(IntentData.playerData to playerData)
) {
playerController = it
playerController.addListener(playerListener)
} }
/**
* 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
}
handler.postDelayed(this::showBottomBar, 100)
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
@ -838,13 +821,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
playerController.pause() 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() super.onPause()
} }
@ -868,9 +844,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
saveWatchPosition()
watchPositionTimer.destroy()
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
playerController.removeListener(playerListener) 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() { private fun checkForSegments() {
if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
handler.postDelayed(this::checkForSegments, 100) handler.postDelayed(this::checkForSegments, 100)
if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return if (!PlayerHelper.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
playerController.checkForSegments( playerController.checkForSegments(
requireContext(), requireContext(),
viewModel.segments, viewModel.segments,
viewModel.sponsorBlockConfig viewModel.sponsorBlockConfig,
// skipping is done by player service
skipAutomaticallyIfEnabled = false
) )
?.let { segment -> ?.let { segment ->
if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let
binding.sbSkipBtn.isVisible = true binding.sbSkipBtn.isVisible = true
binding.sbSkipBtn.setOnClickListener { binding.sbSkipBtn.setOnClickListener {
playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong()) playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
@ -954,8 +923,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
} }
return return
} }
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone =
true if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true
} }
private fun playVideo() { private fun playVideo() {
@ -966,45 +935,21 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// reset the comments to become reloaded later // reset the comments to become reloaded later
commentsViewModel.reset() commentsViewModel.reset()
lifecycleScope.launch(Dispatchers.Main) {
viewModel.fetchVideoInfo(requireContext(), videoId).let { (streams, errorMessage) ->
if (errorMessage != null) {
context?.toastFromMainDispatcher(errorMessage, Toast.LENGTH_LONG)
return@launch
}
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 // hide the button to skip SponsorBlock segments manually
binding.sbSkipBtn.isGone = true binding.sbSkipBtn.isGone = true
// set media sources for the player // use the video's default audio track when starting playback
if (!viewModel.isOrientationChangeInProgress) initStreamSources() 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)
if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) && if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) &&
isShort && binding.playerMotionLayout.progress == 0f isShort && binding.playerMotionLayout.progress == 0f
@ -1019,14 +964,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
initializePlayerView() initializePlayerView()
// 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)
playerController.prepare()
if (binding.playerMotionLayout.progress != 1.0f) { if (binding.playerMotionLayout.progress != 1.0f) {
// show controllers when not in picture in picture mode // show controllers when not in picture in picture mode
val inPipMode = PlayerHelper.pipEnabled && val inPipMode = PlayerHelper.pipEnabled &&
@ -1036,34 +973,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
} }
} }
fetchSponsorBlockSegments()
if (streams.category == Streams.categoryMusic) { if (streams.category == Streams.categoryMusic) {
playerController.setPlaybackSpeed(1f) playerController.setPlaybackSpeed(1f)
} }
viewModel.isOrientationChangeInProgress = false 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
}
viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY }
?.let {
initializeHighlight(it)
}
}
/** /**
* Can be used for autoplay and manually skipping to the next video. * Can be used for autoplay and manually skipping to the next video.
@ -1076,19 +991,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
// save the current watch position before starting the next video
saveWatchPosition()
videoId = nextId ?: PlayingQueue.getNext() ?: return videoId = nextId ?: PlayingQueue.getNext() ?: return
isPlayerTransitioning = true
// fix: if the fragment is recreated, play the current video, and not the initial one // fix: if the fragment is recreated, play the current video, and not the initial one
arguments?.run { arguments?.run {
val playerData = parcelable<PlayerData>(IntentData.playerData)!!.copy(videoId = videoId) val playerData = parcelable<PlayerData>(IntentData.playerData)!!.copy(videoId = videoId)
putParcelable(IntentData.playerData, playerData) 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 // start to play the next video
@ -1245,37 +1153,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
return resolutions.toList() 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) { private fun setPlayerResolution(resolution: Int, isSelectedByUser: Boolean = false) {
val transformedResolution = if (!isSelectedByUser && isShort) { val transformedResolution = if (!isSelectedByUser && isShort) {
ceil(resolution * 16.0 / 9.0).toInt() ceil(resolution * 16.0 / 9.0).toInt()

View File

@ -1,27 +1,14 @@
package com.github.libretube.ui.models package com.github.libretube.ui.models
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.media3.common.util.UnstableApi 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.Segment
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle import com.github.libretube.api.obj.Subtitle
import com.github.libretube.helpers.PlayerHelper 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 @UnstableApi
class PlayerViewModel : ViewModel() { class PlayerViewModel : ViewModel() {
// data to remember for recovery on orientation change
private var streamsInfo: Streams? = null
var nowPlayingNotification: NowPlayingNotification? = null
var segments = listOf<Segment>() var segments = listOf<Segment>()
var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode) var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode)
var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() 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 * Set to true if the activity will be recreated due to an orientation change
*/ */
var isOrientationChangeInProgress = false 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<Streams?, String?> =
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
}
}
} }

View File

@ -14,14 +14,17 @@ import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.WindowHelper import com.github.libretube.helpers.WindowHelper
import com.github.libretube.obj.BottomSheetItem import com.github.libretube.obj.BottomSheetItem
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.SubmitDeArrowDialog import com.github.libretube.ui.dialogs.SubmitDeArrowDialog
import com.github.libretube.ui.dialogs.SubmitSegmentDialog import com.github.libretube.ui.dialogs.SubmitSegmentDialog
@ -49,6 +52,7 @@ class OnlinePlayerView(
var currentWindow: Window? = null var currentWindow: Window? = null
var selectedResolution: Int? = null var selectedResolution: Int? = null
private var sponsorBlockAutoSkip = true
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
override fun getOptionsMenuItems(): List<BottomSheetItem> { override fun getOptionsMenuItems(): List<BottomSheetItem> {
@ -71,7 +75,10 @@ class OnlinePlayerView(
BottomSheetItem( BottomSheetItem(
context.getString(R.string.captions), context.getString(R.string.captions),
R.drawable.ic_caption, R.drawable.ic_caption,
{ playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) } {
playerViewModel?.currentSubtitle?.code
?: context.getString(R.string.none)
}
) { ) {
playerOptions?.onCaptionsClicked() playerOptions?.onCaptionsClicked()
}, },
@ -153,7 +160,8 @@ class OnlinePlayerView(
updateTopBarMargin() updateTopBarMargin()
binding.fullscreen.isInvisible = PlayerHelper.autoFullscreenEnabled 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.fullscreen.setImageResource(fullscreenDrawable)
binding.exoTitle.isInvisible = !isFullscreen binding.exoTitle.isInvisible = !isFullscreen
@ -161,25 +169,32 @@ class OnlinePlayerView(
val updateSbImageResource = { val updateSbImageResource = {
binding.sbToggle.setImageResource( 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() updateSbImageResource()
binding.sbToggle.setOnClickListener { 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() updateSbImageResource()
} }
syncQueueButtons() syncQueueButtons()
binding.sbSubmit.isVisible = PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false) binding.sbSubmit.isVisible =
PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false)
binding.sbSubmit.setOnClickListener { binding.sbSubmit.setOnClickListener {
val submitSegmentDialog = SubmitSegmentDialog() val submitSegmentDialog = SubmitSegmentDialog()
submitSegmentDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener submitSegmentDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
submitSegmentDialog.show((context as BaseActivity).supportFragmentManager, null) 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 { binding.dearrowSubmit.setOnClickListener {
val submitDialog = SubmitDeArrowDialog() val submitDialog = SubmitDeArrowDialog()
submitDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener submitDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener