LibreTube/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt

340 lines
12 KiB
Kotlin

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.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.Log
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.SubscriptionHelper
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.toastFromMainDispatcher
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.PlayerHelper.getSubtitleRoleFlags
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.YoutubeHlsPlaylistParser
import com.github.libretube.util.deArrow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import java.io.IOException
/**
* Loads the selected videos audio in background mode with a notification area.
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
open class OnlinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = false
// PlaylistId/ChannelId for autoplay
private var playlistId: String? = null
private var channelId: String? = null
private var startTimestampSeconds: Long? = null
/**
* The response that gets when called the Api.
*/
private var streams: Streams? = null
// SponsorBlock Segment data
private var sponsorBlockAutoSkip = true
private var sponsorBlockSegments = listOf<Segment>()
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
private var autoPlayCountdownEnabled = false
private val scope = CoroutineScope(Dispatchers.IO)
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_ENDED -> {
if (!isTransitioning) playNextVideo()
}
Player.STATE_IDLE -> {
onDestroy()
}
Player.STATE_BUFFERING -> {}
Player.STATE_READY -> {
// save video to watch history when the video starts playing or is being resumed
// waiting for the player to be ready since the video can't be claimed to be watched
// while it did not yet start actually, but did buffer only so far
scope.launch(Dispatchers.IO) {
streams?.let { streams ->
val watchHistoryItem =
streams.toStreamItem(videoId).toWatchHistoryItem(videoId)
DatabaseHelper.addToWatchHistory(watchHistoryItem)
}
}
}
}
}
}
override suspend fun onServiceCreated(args: Bundle) {
val playerData = args.parcelable<PlayerData>(IntentData.playerData)
if (playerData == null) {
stopSelf()
return
}
isAudioOnlyPlayer = args.getBoolean(IntentData.audioOnly)
// get the intent arguments
setVideoId(playerData.videoId)
playlistId = playerData.playlistId
channelId = playerData.channelId
startTimestampSeconds = playerData.timestamp
if (!playerData.keepQueue) PlayingQueue.clear()
exoPlayer?.addListener(playerListener)
trackSelector?.updateParameters {
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioOnlyPlayer)
}
}
override suspend fun startPlayback() {
super.startPlayback()
val timestampMs = startTimestampSeconds?.times(1000) ?: 0L
startTimestampSeconds = null
streams = withContext(Dispatchers.IO) {
try {
MediaServiceRepository.instance.getStreams(videoId).deArrow(videoId)
} catch (e: IOException) {
toastFromMainDispatcher(getString(R.string.unknown_error))
return@withContext null
} catch (e: Exception) {
toastFromMainDispatcher(e.message ?: getString(R.string.server_error))
return@withContext null
}
} ?: return
if (PlayingQueue.isEmpty()) {
PlayingQueue.updateQueue(
streams!!.toStreamItem(videoId),
playlistId,
channelId,
streams!!.relatedStreams
)
} else if (PlayingQueue.isLast() && playlistId == null && channelId == null) {
PlayingQueue.insertRelatedStreams(streams!!.relatedStreams)
}
streams?.toStreamItem(videoId)?.let {
// save the current stream to the queue
PlayingQueue.updateCurrent(it)
// update feed item with newer information, e.g. more up-to-date views
SubscriptionHelper.submitFeedItemChange(
it.toFeedItem()
)
}
withContext(Dispatchers.Main) {
playAudio(timestampMs)
}
}
private fun playAudio(seekToPositionMs: Long) {
setStreamSource()
// seek to the previous position if available
if (seekToPositionMs != 0L) {
exoPlayer?.seekTo(seekToPositionMs)
} else if (watchPositionsEnabled) {
DatabaseHelper.getWatchPositionBlocking(videoId)?.let {
if (!DatabaseHelper.isVideoWatched(it, streams?.duration)) exoPlayer?.seekTo(it)
}
}
exoPlayer?.apply {
playWhenReady = PlayerHelper.playAutomatically
prepare()
}
if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments()
}
/**
* Plays the next video from the queue
*/
private fun playNextVideo(nextId: String? = null) {
if (nextId == null) {
if (PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
exoPlayer?.seekTo(0)
return
}
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) || autoPlayCountdownEnabled) return
}
val nextVideo = nextId ?: PlayingQueue.getNext() ?: return
// play new video on background
setVideoId(nextVideo)
this.streams = null
this.sponsorBlockSegments = emptyList()
Log.e("play next", "play next")
scope.launch {
startPlayback()
}
}
/**
* fetch the segments for SponsorBlock
*/
private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) {
runCatching {
if (sponsorBlockConfig.isEmpty()) return@runCatching
sponsorBlockSegments = MediaServiceRepository.instance.getSegments(
videoId,
sponsorBlockConfig.keys.toList(),
listOf("skip","mute","full","poi","chapter")
).segments
withContext(Dispatchers.Main) {
updatePlaylistMetadata {
// JSON-encode as work-around for https://github.com/androidx/media/issues/564
val segments = JsonHelper.json.encodeToString(sponsorBlockSegments)
setExtras(bundleOf(IntentData.segments to segments))
}
checkForSegments()
}
}
}
/**
* check for SponsorBlock segments
*/
private fun checkForSegments() {
handler.postDelayed(this::checkForSegments, 100)
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)
} else if (args.containsKey(PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name)) {
autoPlayCountdownEnabled =
args.getBoolean(PlayerCommand.SET_AUTOPLAY_COUNTDOWN_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.rewriteUrlUsingProxyPreference(
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(DefaultDataSource.Factory(this))
.setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
val mediaItem = createMediaItem(
ProxyHelper.rewriteUrlUsingProxyPreference(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()
}