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
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])
}

View File

@ -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"
}

View File

@ -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,
}

View File

@ -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()
)
}

View File

@ -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<Segment>,
sponsorBlockConfig: MutableMap<String, SbSkipOptions>
sponsorBlockConfig: MutableMap<String, SbSkipOptions>,
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) 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()
}
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

View File

@ -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
}
}

View File

@ -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<Segment>()
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()
setStreamSource()
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)
}
}
// 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
@ -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
*/
private fun fetchSponsorBlockSegments() {
scope.launch(Dispatchers.IO) {
runCatching {
if (sponsorBlockConfig.isEmpty()) return@runCatching
sponsorBlockSegments = RetrofitInstance.api.getSegments(
videoId,
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
).segments
private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) {
runCatching {
if (sponsorBlockConfig.isEmpty()) return@runCatching
sponsorBlockSegments = RetrofitInstance.api.getSegments(
videoId,
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
).segments
withContext(Dispatchers.Main) {
exoPlayer?.playlistMetadata = MediaMetadata.Builder()
.setExtras(bundleOf(IntentData.segments to ArrayList(sponsorBlockSegments)))
.build()
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<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
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<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.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)
}

View File

@ -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<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
*/
@ -369,12 +365,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onCreate(savedInstanceState: Bundle?) {
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
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<PlayerData>(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)
)
}
// 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
)
)
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)
}
// set the default subtitle if available
updateCurrentSubtitle(viewModel.currentSubtitle)
val videoStream = streams.videoStreams.firstOrNull()
isShort = PlayingQueue.getCurrent()?.isShort == true ||
(videoStream?.height ?: 0) > (videoStream?.width ?: 0)
// set media source and resolution in the beginning
updateResolution(commonPlayerViewModel.isFullscreen.value == true)
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
}
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) {
// show controllers when not in picture in picture mode
val inPipMode = PlayerHelper.pipEnabled &&
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
if (!inPipMode) {
binding.player.useController = true
}
}
fetchSponsorBlockSegments()
if (streams.category == Streams.categoryMusic) {
playerController.setPlaybackSpeed(1f)
}
viewModel.isOrientationChangeInProgress = false
if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) &&
isShort && binding.playerMotionLayout.progress == 0f
) {
setFullscreen()
}
}
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
binding.player.apply {
useController = false
player = playerController
}
viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY }
?.let {
initializeHighlight(it)
initializePlayerView()
if (binding.playerMotionLayout.progress != 1.0f) {
// show controllers when not in picture in picture mode
val inPipMode = PlayerHelper.pipEnabled &&
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
if (!inPipMode) {
binding.player.useController = true
}
}
if (streams.category == Streams.categoryMusic) {
playerController.setPlaybackSpeed(1f)
}
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<PlayerData>(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()

View File

@ -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<Segment>()
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<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.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<BottomSheetItem> {
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