mirror of
https://github.com/libre-tube/LibreTube.git
synced 2024-12-14 06:10:31 +05:30
refactor: move common PlayerService code to AbstractPlayerService
This commit is contained in:
parent
ad1be01fcd
commit
db8ec51b12
@ -0,0 +1,233 @@
|
|||||||
|
package com.github.libretube.services
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
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 com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
|
||||||
|
import com.github.libretube.R
|
||||||
|
import com.github.libretube.constants.IntentData
|
||||||
|
import com.github.libretube.db.DatabaseHolder
|
||||||
|
import com.github.libretube.db.obj.DownloadWithItems
|
||||||
|
import com.github.libretube.enums.FileType
|
||||||
|
import com.github.libretube.enums.NotificationId
|
||||||
|
import com.github.libretube.enums.PlayerEvent
|
||||||
|
import com.github.libretube.extensions.serializableExtra
|
||||||
|
import com.github.libretube.extensions.toAndroidUri
|
||||||
|
import com.github.libretube.extensions.updateParameters
|
||||||
|
import com.github.libretube.helpers.PlayerHelper
|
||||||
|
import com.github.libretube.obj.PlayerNotificationData
|
||||||
|
import com.github.libretube.util.NowPlayingNotification
|
||||||
|
import com.github.libretube.util.PauseableTimer
|
||||||
|
import com.github.libretube.util.PlayingQueue
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
abstract class AbstractPlayerService : LifecycleService() {
|
||||||
|
var player: ExoPlayer? = null
|
||||||
|
var nowPlayingNotification: 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 onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
super.onPlaybackStateChanged(playbackState)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playerActionReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
|
||||||
|
val player = player ?: return
|
||||||
|
|
||||||
|
if (PlayerHelper.handlePlayerAction(player, event)) return
|
||||||
|
|
||||||
|
when (event) {
|
||||||
|
PlayerEvent.Next -> {
|
||||||
|
PlayingQueue.navigateNext()
|
||||||
|
}
|
||||||
|
PlayerEvent.Prev -> {
|
||||||
|
PlayingQueue.navigatePrev()
|
||||||
|
}
|
||||||
|
PlayerEvent.Stop -> {
|
||||||
|
onDestroy()
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME)
|
||||||
|
.setContentTitle(getString(R.string.app_name))
|
||||||
|
.setContentText(getString(R.string.playingOnBackground))
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
startForeground(NotificationId.PLAYER_PLAYBACK.id, notification)
|
||||||
|
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
playerActionReceiver,
|
||||||
|
IntentFilter(PlayerHelper.getIntentActionName(this)),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
if (intent != null) {
|
||||||
|
createPlayerAndNotification()
|
||||||
|
onServiceCreated(intent)
|
||||||
|
startPlaybackAndUpdateNotification()
|
||||||
|
}
|
||||||
|
else stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onStartCommand(intent, flags, startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract suspend fun onServiceCreated(intent: Intent)
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private fun createPlayerAndNotification() {
|
||||||
|
val trackSelector = DefaultTrackSelector(this)
|
||||||
|
this.trackSelector = trackSelector
|
||||||
|
|
||||||
|
trackSelector.updateParameters {
|
||||||
|
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
player = PlayerHelper.createPlayer(this, trackSelector, true)
|
||||||
|
// prevent android from putting LibreTube to sleep when locked
|
||||||
|
player!!.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||||
|
player!!.addListener(playerListener)
|
||||||
|
|
||||||
|
PlayerHelper.setPreferredCodecs(trackSelector)
|
||||||
|
|
||||||
|
nowPlayingNotification = NowPlayingNotification(
|
||||||
|
this,
|
||||||
|
player!!,
|
||||||
|
NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_OFFLINE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract suspend fun startPlaybackAndUpdateNotification()
|
||||||
|
|
||||||
|
fun saveWatchPosition() {
|
||||||
|
if (isTransitioning || !PlayerHelper.watchPositionsVideo) return
|
||||||
|
|
||||||
|
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
PlayingQueue.resetToDefaults()
|
||||||
|
|
||||||
|
saveWatchPosition()
|
||||||
|
|
||||||
|
nowPlayingNotification?.destroySelf()
|
||||||
|
nowPlayingNotification = null
|
||||||
|
watchPositionTimer.destroy()
|
||||||
|
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
player?.stop()
|
||||||
|
player?.release()
|
||||||
|
}
|
||||||
|
player = null
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
unregisterReceiver(playerActionReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
super.onBind(intent)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
fun getCurrentPosition() = player?.currentPosition
|
||||||
|
|
||||||
|
fun getDuration() = player?.duration
|
||||||
|
|
||||||
|
fun seekToPosition(position: Long) = player?.seekTo(position)
|
||||||
|
}
|
@ -1,37 +1,18 @@
|
|||||||
package com.github.libretube.services
|
package com.github.libretube.services
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.LifecycleService
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
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.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
|
||||||
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
|
|
||||||
import com.github.libretube.R
|
|
||||||
import com.github.libretube.constants.IntentData
|
import com.github.libretube.constants.IntentData
|
||||||
import com.github.libretube.db.DatabaseHolder
|
import com.github.libretube.db.DatabaseHolder
|
||||||
import com.github.libretube.db.obj.DownloadWithItems
|
import com.github.libretube.db.obj.DownloadWithItems
|
||||||
import com.github.libretube.enums.FileType
|
import com.github.libretube.enums.FileType
|
||||||
import com.github.libretube.enums.NotificationId
|
|
||||||
import com.github.libretube.enums.PlayerEvent
|
|
||||||
import com.github.libretube.extensions.serializableExtra
|
|
||||||
import com.github.libretube.extensions.toAndroidUri
|
import com.github.libretube.extensions.toAndroidUri
|
||||||
import com.github.libretube.extensions.updateParameters
|
|
||||||
import com.github.libretube.helpers.PlayerHelper
|
import com.github.libretube.helpers.PlayerHelper
|
||||||
import com.github.libretube.obj.PlayerNotificationData
|
import com.github.libretube.obj.PlayerNotificationData
|
||||||
import com.github.libretube.util.NowPlayingNotification
|
|
||||||
import com.github.libretube.util.PauseableTimer
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -40,87 +21,20 @@ import kotlin.io.path.exists
|
|||||||
/**
|
/**
|
||||||
* A service to play downloaded audio in the background
|
* A service to play downloaded audio in the background
|
||||||
*/
|
*/
|
||||||
class OfflinePlayerService : LifecycleService() {
|
@UnstableApi
|
||||||
private var player: ExoPlayer? = null
|
class OfflinePlayerService : AbstractPlayerService() {
|
||||||
private var nowPlayingNotification: NowPlayingNotification? = null
|
|
||||||
private lateinit var videoId: String
|
|
||||||
private var downloadsWithItems: List<DownloadWithItems> = emptyList()
|
private var downloadsWithItems: List<DownloadWithItems> = emptyList()
|
||||||
|
|
||||||
private val watchPositionTimer = PauseableTimer(
|
override suspend fun onServiceCreated(intent: Intent) {
|
||||||
onTick = this::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 onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
super.onPlaybackStateChanged(playbackState)
|
|
||||||
|
|
||||||
// automatically go to the next video/audio when the current one ended
|
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
|
||||||
val currentIndex = downloadsWithItems.indexOfFirst { it.download.videoId == videoId }
|
|
||||||
downloadsWithItems.getOrNull(currentIndex + 1)?.let {
|
|
||||||
this@OfflinePlayerService.videoId = it.download.videoId
|
|
||||||
startAudioPlayer(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val playerActionReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
|
|
||||||
val player = player ?: return
|
|
||||||
|
|
||||||
if (PlayerHelper.handlePlayerAction(player, event)) return
|
|
||||||
|
|
||||||
when (event) {
|
|
||||||
PlayerEvent.Stop -> onDestroy()
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME)
|
|
||||||
.setContentTitle(getString(R.string.app_name))
|
|
||||||
.setContentText(getString(R.string.playingOnBackground))
|
|
||||||
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
startForeground(NotificationId.PLAYER_PLAYBACK.id, notification)
|
|
||||||
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
this,
|
|
||||||
playerActionReceiver,
|
|
||||||
IntentFilter(PlayerHelper.getIntentActionName(this)),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
downloadsWithItems = withContext(Dispatchers.IO) {
|
downloadsWithItems = withContext(Dispatchers.IO) {
|
||||||
DatabaseHolder.Database.downloadDao().getAll()
|
DatabaseHolder.Database.downloadDao().getAll()
|
||||||
}
|
}
|
||||||
if (downloadsWithItems.isEmpty()) {
|
if (downloadsWithItems.isEmpty()) {
|
||||||
onDestroy()
|
onDestroy()
|
||||||
return@launch
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val videoId = intent?.getStringExtra(IntentData.videoId)
|
val videoId = intent.getStringExtra(IntentData.videoId)
|
||||||
|
|
||||||
val downloadToPlay = if (videoId == null) {
|
val downloadToPlay = if (videoId == null) {
|
||||||
downloadsWithItems = downloadsWithItems.shuffled()
|
downloadsWithItems = downloadsWithItems.shuffled()
|
||||||
@ -130,41 +44,18 @@ class OfflinePlayerService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this@OfflinePlayerService.videoId = downloadToPlay.download.videoId
|
this@OfflinePlayerService.videoId = downloadToPlay.download.videoId
|
||||||
|
|
||||||
createPlayerAndNotification()
|
|
||||||
|
|
||||||
// destroy the service if there was no success playing the selected audio/video
|
|
||||||
if (!startAudioPlayer(downloadToPlay)) onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
private fun createPlayerAndNotification() {
|
|
||||||
val trackSelector = DefaultTrackSelector(this@OfflinePlayerService)
|
|
||||||
trackSelector.updateParameters {
|
|
||||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
player = PlayerHelper.createPlayer(this@OfflinePlayerService, trackSelector, true)
|
|
||||||
// prevent android from putting LibreTube to sleep when locked
|
|
||||||
player!!.setWakeMode(C.WAKE_MODE_LOCAL)
|
|
||||||
player!!.addListener(playerListener)
|
|
||||||
|
|
||||||
nowPlayingNotification = NowPlayingNotification(
|
|
||||||
this,
|
|
||||||
player!!,
|
|
||||||
NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_OFFLINE
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to start an audio player with the given download items
|
* Attempt to start an audio player with the given download items
|
||||||
* @param downloadWithItems The database download to play from
|
|
||||||
* @return whether starting the audio player succeeded
|
|
||||||
*/
|
*/
|
||||||
private fun startAudioPlayer(downloadWithItems: DownloadWithItems): Boolean {
|
override suspend fun startPlaybackAndUpdateNotification() {
|
||||||
|
val downloadWithItems = downloadsWithItems.firstOrNull { it.download.videoId == videoId }
|
||||||
|
if (downloadWithItems == null) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val notificationData = PlayerNotificationData(
|
val notificationData = PlayerNotificationData(
|
||||||
title = downloadWithItems.download.title,
|
title = downloadWithItems.download.title,
|
||||||
uploaderName = downloadWithItems.download.uploader,
|
uploaderName = downloadWithItems.download.uploader,
|
||||||
@ -176,7 +67,11 @@ class OfflinePlayerService : LifecycleService() {
|
|||||||
.firstOrNull { it.type == FileType.AUDIO }
|
.firstOrNull { it.type == FileType.AUDIO }
|
||||||
?: // in some rare cases, video files can contain audio
|
?: // in some rare cases, video files can contain audio
|
||||||
downloadWithItems.downloadItems.firstOrNull { it.type == FileType.VIDEO }
|
downloadWithItems.downloadItems.firstOrNull { it.type == FileType.VIDEO }
|
||||||
?: return false
|
|
||||||
|
if (audioItem == null) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val mediaItem = MediaItem.Builder()
|
val mediaItem = MediaItem.Builder()
|
||||||
.setUri(audioItem.path.toAndroidUri())
|
.setUri(audioItem.path.toAndroidUri())
|
||||||
@ -191,37 +86,6 @@ class OfflinePlayerService : LifecycleService() {
|
|||||||
player?.seekTo(it)
|
player?.seekTo(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveWatchPosition() {
|
|
||||||
if (!PlayerHelper.watchPositionsVideo) return
|
|
||||||
|
|
||||||
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
saveWatchPosition()
|
|
||||||
|
|
||||||
nowPlayingNotification?.destroySelf()
|
|
||||||
nowPlayingNotification = null
|
|
||||||
watchPositionTimer.destroy()
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
player?.stop()
|
|
||||||
player?.release()
|
|
||||||
}
|
|
||||||
player = null
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
unregisterReceiver(playerActionReceiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelf()
|
|
||||||
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
@ -236,4 +100,18 @@ class OfflinePlayerService : LifecycleService() {
|
|||||||
super.onTaskRemoved(rootIntent)
|
super.onTaskRemoved(rootIntent)
|
||||||
onDestroy()
|
onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
// automatically go to the next video/audio when the current one ended
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
val currentIndex = downloadsWithItems.indexOfFirst { it.download.videoId == videoId }
|
||||||
|
downloadsWithItems.getOrNull(currentIndex + 1)?.let {
|
||||||
|
this@OfflinePlayerService.videoId = it.download.videoId
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
startPlaybackAndUpdateNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,7 @@ import com.github.libretube.extensions.parcelableExtra
|
|||||||
import com.github.libretube.extensions.serializableExtra
|
import com.github.libretube.extensions.serializableExtra
|
||||||
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.updateParameters
|
import com.github.libretube.extensions.updateParameters
|
||||||
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
|
||||||
@ -57,17 +58,13 @@ import kotlinx.serialization.encodeToString
|
|||||||
* 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 : LifecycleService() {
|
class OnlinePlayerService : AbstractPlayerService() {
|
||||||
/**
|
|
||||||
* VideoId of the video
|
|
||||||
*/
|
|
||||||
private lateinit var videoId: String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlaylistId/ChannelId for autoplay
|
* PlaylistId/ChannelId for autoplay
|
||||||
*/
|
*/
|
||||||
private var playlistId: String? = null
|
private var playlistId: String? = null
|
||||||
private var channelId: String? = null
|
private var channelId: String? = null
|
||||||
|
private var startTimestamp: Long? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The response that gets when called the Api.
|
* The response that gets when called the Api.
|
||||||
@ -75,29 +72,12 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
var streams: Streams? = null
|
var streams: Streams? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
|
||||||
* The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro)
|
|
||||||
*/
|
|
||||||
var player: ExoPlayer? = null
|
|
||||||
private var trackSelector: DefaultTrackSelector? = null
|
|
||||||
private var isTransitioning = true
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SponsorBlock Segment data
|
* SponsorBlock Segment data
|
||||||
*/
|
*/
|
||||||
private var segments = listOf<Segment>()
|
private var segments = listOf<Segment>()
|
||||||
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
||||||
|
|
||||||
/**
|
|
||||||
* [Notification] for the player
|
|
||||||
*/
|
|
||||||
private lateinit var nowPlayingNotification: NowPlayingNotification
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Autoplay Preference
|
|
||||||
*/
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used for connecting to the AudioPlayerFragment
|
* Used for connecting to the AudioPlayerFragment
|
||||||
*/
|
*/
|
||||||
@ -109,156 +89,43 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null
|
var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null
|
||||||
var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null
|
var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null
|
||||||
|
|
||||||
private val watchPositionTimer = PauseableTimer(
|
override suspend fun onServiceCreated(intent: Intent) {
|
||||||
onTick = this::saveWatchPosition,
|
|
||||||
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
|
|
||||||
)
|
|
||||||
|
|
||||||
private val playerListener = object : Player.Listener {
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
||||||
super.onIsPlayingChanged(isPlaying)
|
|
||||||
onStateOrPlayingChanged?.invoke(isPlaying)
|
|
||||||
|
|
||||||
// Start or pause watch position timer
|
|
||||||
if (isPlaying) {
|
|
||||||
watchPositionTimer.resume()
|
|
||||||
} else {
|
|
||||||
watchPositionTimer.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(state: Int) {
|
|
||||||
onStateOrPlayingChanged?.invoke(player?.isPlaying ?: false)
|
|
||||||
|
|
||||||
when (state) {
|
|
||||||
Player.STATE_ENDED -> {
|
|
||||||
if (!isTransitioning) playNextVideo()
|
|
||||||
}
|
|
||||||
|
|
||||||
Player.STATE_IDLE -> {
|
|
||||||
onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
Player.STATE_BUFFERING -> {}
|
|
||||||
Player.STATE_READY -> {
|
|
||||||
isTransitioning = false
|
|
||||||
|
|
||||||
// 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
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
|
||||||
// show a toast on errors
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
Toast.makeText(
|
|
||||||
this@OnlinePlayerService.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@OnlinePlayerService, player, trackSelector ?: return)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val playerActionReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
|
|
||||||
val player = player ?: return
|
|
||||||
|
|
||||||
if (PlayerHelper.handlePlayerAction(player, event)) return
|
|
||||||
|
|
||||||
when (event) {
|
|
||||||
PlayerEvent.Next -> {
|
|
||||||
PlayingQueue.navigateNext()
|
|
||||||
}
|
|
||||||
PlayerEvent.Prev -> {
|
|
||||||
PlayingQueue.navigatePrev()
|
|
||||||
}
|
|
||||||
PlayerEvent.Stop -> {
|
|
||||||
onDestroy()
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setting the required [Notification] for running as a foreground service
|
|
||||||
*/
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME)
|
|
||||||
.setContentTitle(getString(R.string.app_name))
|
|
||||||
.setContentText(getString(R.string.playingOnBackground))
|
|
||||||
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
startForeground(NotificationId.PLAYER_PLAYBACK.id, notification)
|
|
||||||
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
this,
|
|
||||||
playerActionReceiver,
|
|
||||||
IntentFilter(PlayerHelper.getIntentActionName(this)),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the [player] with the [MediaItem].
|
|
||||||
*/
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
// reset the playing queue listeners
|
// reset the playing queue listeners
|
||||||
PlayingQueue.resetToDefaults()
|
PlayingQueue.resetToDefaults()
|
||||||
|
|
||||||
intent?.parcelableExtra<PlayerData>(IntentData.playerData)?.let { playerData ->
|
val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData)
|
||||||
|
if (playerData == null) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// get the intent arguments
|
// get the intent arguments
|
||||||
videoId = playerData.videoId
|
videoId = playerData.videoId
|
||||||
playlistId = playerData.playlistId
|
playlistId = playerData.playlistId
|
||||||
|
startTimestamp = playerData.timestamp
|
||||||
|
|
||||||
// play the audio in the background
|
if (!playerData.keepQueue) PlayingQueue.clear()
|
||||||
loadAudio(playerData)
|
|
||||||
|
|
||||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||||
streamItem.url?.toID()?.let { playNextVideo(it) }
|
streamItem.url?.toID()?.let { playNextVideo(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveWatchPosition() {
|
override suspend fun startPlaybackAndUpdateNotification() {
|
||||||
if (isTransitioning || !PlayerHelper.watchPositionsAudio) return
|
val timestamp = startTimestamp ?: 0L
|
||||||
|
startTimestamp = null
|
||||||
|
|
||||||
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the video data and prepares the [player].
|
|
||||||
*/
|
|
||||||
private fun loadAudio(playerData: PlayerData) {
|
|
||||||
val (videoId, _, _, keepQueue, timestamp) = playerData
|
|
||||||
isTransitioning = true
|
isTransitioning = true
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
streams = withContext(Dispatchers.IO) {
|
||||||
streams = runCatching {
|
try {
|
||||||
StreamsExtractor.extractStreams(videoId)
|
StreamsExtractor.extractStreams(videoId)
|
||||||
}.getOrNull() ?: return@launch
|
} catch (e: Exception) {
|
||||||
|
val errorMessage = StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
|
||||||
// clear the queue if it shouldn't be kept explicitly
|
this@OnlinePlayerService.toastFromMainDispatcher(errorMessage)
|
||||||
if (!keepQueue) PlayingQueue.clear()
|
return@withContext null
|
||||||
|
}
|
||||||
|
} ?: return
|
||||||
|
|
||||||
if (PlayingQueue.isEmpty()) {
|
if (PlayingQueue.isEmpty()) {
|
||||||
PlayingQueue.updateQueue(
|
PlayingQueue.updateQueue(
|
||||||
@ -280,10 +147,8 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
playAudio(timestamp)
|
playAudio(timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun playAudio(seekToPosition: Long) {
|
private fun playAudio(seekToPosition: Long) {
|
||||||
initializePlayer()
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
setMediaItem()
|
setMediaItem()
|
||||||
|
|
||||||
@ -299,20 +164,12 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// create the notification
|
|
||||||
if (!this@OnlinePlayerService::nowPlayingNotification.isInitialized) {
|
|
||||||
nowPlayingNotification = NowPlayingNotification(
|
|
||||||
this@OnlinePlayerService,
|
|
||||||
player!!,
|
|
||||||
NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_ONLINE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val playerNotificationData = PlayerNotificationData(
|
val playerNotificationData = PlayerNotificationData(
|
||||||
streams?.title,
|
streams?.title,
|
||||||
streams?.uploader,
|
streams?.uploader,
|
||||||
streams?.thumbnailUrl
|
streams?.thumbnailUrl
|
||||||
)
|
)
|
||||||
nowPlayingNotification.updatePlayerNotification(videoId, playerNotificationData)
|
nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData)
|
||||||
streams?.let { onNewVideo?.invoke(it, videoId) }
|
streams?.let { onNewVideo?.invoke(it, videoId) }
|
||||||
|
|
||||||
player?.apply {
|
player?.apply {
|
||||||
@ -323,28 +180,6 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments()
|
if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* create the player
|
|
||||||
*/
|
|
||||||
private fun initializePlayer() {
|
|
||||||
if (player != null) return
|
|
||||||
|
|
||||||
trackSelector = DefaultTrackSelector(this)
|
|
||||||
trackSelector!!.updateParameters {
|
|
||||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
player = PlayerHelper.createPlayer(this, trackSelector!!, true)
|
|
||||||
// prevent android from putting LibreTube to sleep when locked
|
|
||||||
player!!.setWakeMode(WAKE_MODE_NETWORK)
|
|
||||||
|
|
||||||
// Listens for changed playbackStates (e.g. pause, end)
|
|
||||||
// Plays the next video when the current one ended
|
|
||||||
player?.addListener(playerListener)
|
|
||||||
|
|
||||||
PlayerHelper.setPreferredCodecs(trackSelector!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays the next video from the queue
|
* Plays the next video from the queue
|
||||||
*/
|
*/
|
||||||
@ -364,7 +199,10 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
this.videoId = nextVideo
|
this.videoId = nextVideo
|
||||||
this.streams = null
|
this.streams = null
|
||||||
this.segments = emptyList()
|
this.segments = emptyList()
|
||||||
loadAudio(PlayerData(videoId, keepQueue = true))
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
startPlaybackAndUpdateNotification()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -414,43 +252,6 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
player?.checkForSegments(this, segments, sponsorBlockConfig)
|
player?.checkForSegments(this, segments, sponsorBlockConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the service when app is removed from the task manager.
|
|
||||||
*/
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* destroy the [OnlinePlayerService] foreground service
|
|
||||||
*/
|
|
||||||
override fun onDestroy() {
|
|
||||||
// reset the playing queue
|
|
||||||
PlayingQueue.resetToDefaults()
|
|
||||||
|
|
||||||
if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelf()
|
|
||||||
watchPositionTimer.destroy()
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
player?.stop()
|
|
||||||
player?.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
unregisterReceiver(playerActionReceiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
// called when the user pressed stop in the notification
|
|
||||||
// stop the service from being in the foreground and remove the notification
|
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
||||||
// destroy the service
|
|
||||||
stopSelf()
|
|
||||||
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
// Return this instance of [BackgroundMode] so clients can call public methods
|
// Return this instance of [BackgroundMode] so clients can call public methods
|
||||||
fun getService(): OnlinePlayerService = this@OnlinePlayerService
|
fun getService(): OnlinePlayerService = this@OnlinePlayerService
|
||||||
@ -461,9 +262,29 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
return binder
|
return binder
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentPosition() = player?.currentPosition
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
onStateOrPlayingChanged?.invoke(player?.isPlaying ?: false)
|
||||||
|
|
||||||
fun getDuration() = player?.duration
|
when (playbackState) {
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
fun seekToPosition(position: Long) = player?.seekTo(position)
|
if (!isTransitioning) playNextVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
Player.STATE_IDLE -> {
|
||||||
|
onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
Player.STATE_BUFFERING -> {}
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
isTransitioning = false
|
||||||
|
|
||||||
|
// 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
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user