mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-28 07:50:31 +05:30
322 lines
11 KiB
Kotlin
322 lines
11 KiB
Kotlin
package com.github.libretube.services
|
|
|
|
import android.content.Intent
|
|
import android.os.Binder
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.IBinder
|
|
import android.os.Looper
|
|
import android.widget.Toast
|
|
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.ChapterSegment
|
|
import com.github.libretube.api.obj.StreamItem
|
|
import com.github.libretube.enums.PlayerCommand
|
|
import com.github.libretube.enums.PlayerEvent
|
|
import com.github.libretube.extensions.updateParameters
|
|
import com.github.libretube.helpers.PlayerHelper
|
|
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 nowPlayingNotification: NowPlayingNotification? = null
|
|
var trackSelector: DefaultTrackSelector? = null
|
|
|
|
lateinit var videoId: String
|
|
var isTransitioning = true
|
|
|
|
val handler = Handler(Looper.getMainLooper())
|
|
|
|
private val binder = LocalBinder()
|
|
|
|
/**
|
|
* Listener for passing playback state changes to the AudioPlayerFragment
|
|
*/
|
|
var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null
|
|
var onNewVideoStarted: ((streamItem: StreamItem) -> Unit)? = null
|
|
|
|
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()
|
|
}
|
|
|
|
onStateOrPlayingChanged?.let { it(isPlaying) }
|
|
}
|
|
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
super.onPlaybackStateChanged(playbackState)
|
|
|
|
onStateOrPlayingChanged?.let { it(exoPlayer?.isPlaying ?: false) }
|
|
|
|
this@AbstractPlayerService.onPlaybackStateChanged(playbackState)
|
|
}
|
|
|
|
override fun onPlayerError(error: PlaybackException) {
|
|
// show a toast on errors
|
|
Handler(Looper.getMainLooper()).post {
|
|
Toast.makeText(
|
|
applicationContext,
|
|
error.localizedMessage,
|
|
Toast.LENGTH_SHORT
|
|
).show()
|
|
}
|
|
}
|
|
|
|
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> {
|
|
if (customCommand.customAction == START_SERVICE_ACTION) {
|
|
PlayingQueue.resetToDefaults()
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
onServiceCreated(args)
|
|
startPlayback()
|
|
}
|
|
|
|
return super.onCustomCommand(session, controller, customCommand, args)
|
|
}
|
|
|
|
if (customCommand.customAction == RUN_PLAYER_COMMAND_ACTION) {
|
|
runPlayerCommand(args)
|
|
|
|
return super.onCustomCommand(session, controller, customCommand, args)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
abstract val intentActivity: Class<*>
|
|
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
|
|
mediaLibrarySession
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
|
|
val notificationProvider = NowPlayingNotification(
|
|
this,
|
|
backgroundOnly = true,
|
|
offlinePlayer = isOfflinePlayer,
|
|
intentActivity = intentActivity
|
|
)
|
|
setMediaNotificationProvider(notificationProvider)
|
|
|
|
createPlayerAndMediaSession()
|
|
}
|
|
|
|
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.add(startServiceCommand)
|
|
builder.add(runPlayerActionCommand)
|
|
customLayout.forEach { commandButton ->
|
|
commandButton.sessionCommand?.let { builder.add(it) }
|
|
}
|
|
}
|
|
.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).build()
|
|
}
|
|
|
|
abstract suspend fun startPlayback()
|
|
|
|
fun saveWatchPosition() {
|
|
if (isTransitioning || !PlayerHelper.watchPositionsVideo) return
|
|
|
|
exoPlayer?.let { PlayerHelper.saveWatchPosition(it, videoId) }
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
PlayingQueue.resetToDefaults()
|
|
|
|
saveWatchPosition()
|
|
|
|
nowPlayingNotification = 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()
|
|
}
|
|
|
|
/**
|
|
* Stop the service when app is removed from the task manager.
|
|
*/
|
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
super.onTaskRemoved(rootIntent)
|
|
onDestroy()
|
|
}
|
|
|
|
abstract fun onPlaybackStateChanged(playbackState: Int)
|
|
|
|
abstract fun getChapters(): List<ChapterSegment>
|
|
|
|
fun getCurrentPosition() = exoPlayer?.currentPosition
|
|
|
|
fun getDuration() = exoPlayer?.duration
|
|
|
|
fun seekToPosition(position: Long) = exoPlayer?.seekTo(position)
|
|
|
|
inner class LocalBinder : Binder() {
|
|
// Return this instance of [AbstractPlayerService] so clients can call public methods
|
|
fun getService(): AbstractPlayerService = this@AbstractPlayerService
|
|
}
|
|
|
|
override fun onBind(intent: Intent?): IBinder {
|
|
// attempt to return [MediaLibraryServiceBinder] first if matched
|
|
return super.onBind(intent) ?: binder
|
|
}
|
|
|
|
companion object {
|
|
private const val START_SERVICE_ACTION = "start_service_action"
|
|
private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action"
|
|
|
|
val startServiceCommand = SessionCommand(START_SERVICE_ACTION, Bundle.EMPTY)
|
|
val runPlayerActionCommand = SessionCommand(RUN_PLAYER_COMMAND_ACTION, Bundle.EMPTY)
|
|
}
|
|
}
|