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

341 lines
12 KiB
Kotlin

package com.github.libretube.services
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.core.app.ServiceCompat
import androidx.media3.common.C
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
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
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@UnstableApi
abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySession.Callback {
private var mediaLibrarySession: MediaLibrarySession? = null
var exoPlayer: ExoPlayer? = null
private var notificationProvider: NowPlayingNotification? = null
var trackSelector: DefaultTrackSelector? = null
lateinit var videoId: String
var isTransitioning = true
val handler = Handler(Looper.getMainLooper())
private val watchPositionTimer = PauseableTimer(
onTick = ::saveWatchPosition,
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
)
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
// Start or pause watch position timer
if (isPlaying) {
watchPositionTimer.resume()
} else {
watchPositionTimer.pause()
}
}
override fun onPlayerError(error: PlaybackException) {
// show a toast on errors
toastFromMainThread(error.localizedMessage)
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
PlayerHelper.setPreferredAudioQuality(
this@AbstractPlayerService,
player,
trackSelector ?: return
)
}
}
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
START_SERVICE_ACTION -> {
PlayingQueue.resetToDefaults()
CoroutineScope(Dispatchers.IO).launch {
onServiceCreated(args)
notificationProvider?.intentActivity = getIntentActivity()
startPlayback()
}
}
STOP_SERVICE_ACTION -> {
onDestroy()
}
RUN_PLAYER_COMMAND_ACTION -> {
runPlayerCommand(args)
}
else -> {
handlePlayerAction(PlayerEvent.valueOf(customCommand.customAction))
}
}
return super.onCustomCommand(session, controller, customCommand, args)
}
open fun runPlayerCommand(args: Bundle) {
when {
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)
}
}
args.containsKey(PlayerCommand.PLAY_VIDEO_BY_ID.name) -> {
videoId = args.getString(PlayerCommand.PLAY_VIDEO_BY_ID.name) ?: return
CoroutineScope(Dispatchers.IO).launch {
startPlayback()
}
}
}
}
fun getSubtitleRoleFlags(subtitle: Subtitle?): Int {
return if (subtitle?.autoGenerated != true) {
C.ROLE_FLAG_CAPTION
} else {
PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE
}
}
private fun handlePlayerAction(event: PlayerEvent) {
if (PlayerHelper.handlePlayerAction(exoPlayer ?: return, event)) return
when (event) {
PlayerEvent.Next -> {
PlayingQueue.navigateNext()
}
PlayerEvent.Prev -> {
PlayingQueue.navigatePrev()
}
PlayerEvent.Stop -> {
onDestroy()
}
else -> Unit
}
}
abstract val isOfflinePlayer: Boolean
abstract val isAudioOnlyPlayer: Boolean
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
mediaLibrarySession
override fun onCreate() {
super.onCreate()
notificationProvider = NowPlayingNotification(
this,
backgroundOnly = isAudioOnlyPlayer,
offlinePlayer = isOfflinePlayer,
)
setMediaNotificationProvider(notificationProvider!!)
createPlayerAndMediaSession()
}
open fun getIntentActivity(): Class<*> = MainActivity::class.java
abstract suspend fun onServiceCreated(args: Bundle)
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
// Select the button to display.
val customLayout = listOf(
CommandButton.Builder()
.setDisplayName(getString(R.string.rewind))
.setSessionCommand(SessionCommand(PlayerEvent.Prev.name, Bundle.EMPTY))
.setIconResId(R.drawable.ic_prev_outlined)
.build(),
CommandButton.Builder()
.setDisplayName(getString(R.string.play_next))
.setSessionCommand(SessionCommand(PlayerEvent.Next.name, Bundle.EMPTY))
.setIconResId(R.drawable.ic_next_outlined)
.build(),
)
val mediaNotificationSessionCommands =
connectionResult.availableSessionCommands.buildUpon()
.also { builder ->
builder.addSessionCommands(listOf(startServiceCommand, runPlayerActionCommand, stopServiceCommand))
builder.addSessionCommands(customLayout.mapNotNull(CommandButton::sessionCommand))
}
.build()
val playerCommands = connectionResult.availablePlayerCommands.buildUpon()
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
.setAvailablePlayerCommands(playerCommands)
.setCustomLayout(customLayout)
.build()
}
@OptIn(UnstableApi::class)
private fun createPlayerAndMediaSession() {
val trackSelector = DefaultTrackSelector(this)
this.trackSelector = trackSelector
if (isAudioOnlyPlayer) {
trackSelector.updateParameters {
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
}
}
val player = PlayerHelper.createPlayer(this, trackSelector, true)
// prevent android from putting LibreTube to sleep when locked
player.setWakeMode(if (isOfflinePlayer) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK)
player.addListener(playerListener)
this.exoPlayer = player
PlayerHelper.setPreferredCodecs(trackSelector)
mediaLibrarySession = MediaLibrarySession.Builder(this, player, this)
.setId(this.javaClass.name)
.build()
}
/**
* Load the stream source and start the playback.
*
* This function should base its actions on the videoId variable.
*/
abstract suspend fun startPlayback()
fun saveWatchPosition() {
if (isTransitioning || !PlayerHelper.watchPositionsVideo) return
exoPlayer?.let { PlayerHelper.saveWatchPosition(it, videoId) }
}
override fun onDestroy() {
PlayingQueue.resetToDefaults()
saveWatchPosition()
notificationProvider = null
watchPositionTimer.destroy()
handler.removeCallbacksAndMessages(null)
runCatching {
exoPlayer?.stop()
exoPlayer?.release()
}
kotlin.runCatching {
mediaLibrarySession?.release()
mediaLibrarySession = null
}
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
super.onDestroy()
}
fun isVideoIdInitialized() = this::videoId.isInitialized
/**
* Stop the service when app is removed from the task manager.
*/
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
onDestroy()
}
companion object {
private const val START_SERVICE_ACTION = "start_service_action"
private const val STOP_SERVICE_ACTION = "stop_service_action"
private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action"
val startServiceCommand = SessionCommand(START_SERVICE_ACTION, Bundle.EMPTY)
val stopServiceCommand = SessionCommand(STOP_SERVICE_ACTION, Bundle.EMPTY)
val runPlayerActionCommand = SessionCommand(RUN_PLAYER_COMMAND_ACTION, Bundle.EMPTY)
}
}