587 lines
23 KiB
Kotlin
587 lines
23 KiB
Kotlin
package it.vfsfitvnm.vimusic.services
|
|
|
|
import android.app.Notification
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.Service
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.graphics.Bitmap
|
|
import android.graphics.drawable.BitmapDrawable
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.support.v4.media.MediaMetadataCompat
|
|
import android.support.v4.media.session.MediaControllerCompat
|
|
import android.support.v4.media.session.MediaSessionCompat
|
|
import android.support.v4.media.session.PlaybackStateCompat
|
|
import android.support.v4.media.session.PlaybackStateCompat.*
|
|
import androidx.annotation.DrawableRes
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.core.content.ContextCompat.startForegroundService
|
|
import androidx.core.graphics.drawable.toBitmap
|
|
import androidx.core.net.toUri
|
|
import androidx.media.session.MediaButtonReceiver
|
|
import androidx.media3.common.*
|
|
import androidx.media3.database.StandaloneDatabaseProvider
|
|
import androidx.media3.datasource.DataSource
|
|
import androidx.media3.datasource.DefaultHttpDataSource
|
|
import androidx.media3.datasource.ResolvingDataSource
|
|
import androidx.media3.datasource.cache.Cache
|
|
import androidx.media3.datasource.cache.CacheDataSource
|
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
|
import androidx.media3.datasource.cache.SimpleCache
|
|
import androidx.media3.exoplayer.ExoPlayer
|
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
|
import androidx.media3.exoplayer.analytics.PlaybackStats
|
|
import androidx.media3.exoplayer.analytics.PlaybackStatsListener
|
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
import coil.Coil
|
|
import coil.request.ImageRequest
|
|
import it.vfsfitvnm.vimusic.Database
|
|
import it.vfsfitvnm.vimusic.MainActivity
|
|
import it.vfsfitvnm.vimusic.R
|
|
import it.vfsfitvnm.vimusic.internal
|
|
import it.vfsfitvnm.vimusic.models.QueuedMediaItem
|
|
import it.vfsfitvnm.vimusic.utils.*
|
|
import it.vfsfitvnm.youtubemusic.Outcome
|
|
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlin.math.roundToInt
|
|
import kotlin.system.exitProcess
|
|
|
|
|
|
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback {
|
|
private lateinit var mediaSession: MediaSessionCompat
|
|
private lateinit var cache: SimpleCache
|
|
private lateinit var player: ExoPlayer
|
|
|
|
private val stateBuilder = Builder()
|
|
.setActions(
|
|
ACTION_PLAY
|
|
or ACTION_PAUSE
|
|
or ACTION_SKIP_TO_PREVIOUS
|
|
or ACTION_SKIP_TO_NEXT
|
|
or ACTION_PLAY_PAUSE
|
|
or ACTION_SEEK_TO
|
|
)
|
|
|
|
private val metadataBuilder = MediaMetadataCompat.Builder()
|
|
|
|
private lateinit var notificationManager: NotificationManager
|
|
|
|
private var timerJob: TimerJob? = null
|
|
|
|
private var notificationThumbnailSize: Int = 0
|
|
private var lastArtworkUri: Uri? = null
|
|
private var lastBitmap: Bitmap? = null
|
|
|
|
private var radio: YoutubePlayer.Radio? = null
|
|
|
|
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
|
|
|
|
private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
|
|
|
|
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
|
|
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
|
when (state?.state) {
|
|
STATE_PLAYING -> {
|
|
startForegroundService(this@PlayerService, intent<PlayerService>())
|
|
startForeground(NotificationId, notification())
|
|
}
|
|
STATE_PAUSED -> {
|
|
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) {
|
|
stopForeground(false)
|
|
notificationManager.notify(NotificationId, notification())
|
|
}
|
|
}
|
|
STATE_NONE -> {
|
|
onPlaybackStateChanged(Player.STATE_READY)
|
|
onIsPlayingChanged(player.playWhenReady)
|
|
}
|
|
else -> {}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onBind(intent: Intent?) = Binder()
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
|
|
notificationThumbnailSize = (256 * resources.displayMetrics.density).roundToInt()
|
|
|
|
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
|
|
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
|
|
|
|
createNotificationChannel()
|
|
|
|
val cacheEvictor = LeastRecentlyUsedCacheEvictor(preferences.exoPlayerDiskCacheMaxSizeBytes)
|
|
cache = SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(this))
|
|
|
|
player = ExoPlayer.Builder(this)
|
|
.setHandleAudioBecomingNoisy(true)
|
|
.setWakeMode(C.WAKE_MODE_LOCAL)
|
|
.setMediaSourceFactory(DefaultMediaSourceFactory(createDataSourceFactory()))
|
|
.setAudioAttributes(
|
|
AudioAttributes.Builder()
|
|
.setUsage(C.USAGE_MEDIA)
|
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
.build(),
|
|
true
|
|
)
|
|
.setUsePlatformDiagnostics(false)
|
|
.build()
|
|
|
|
player.repeatMode = preferences.repeatMode
|
|
player.skipSilenceEnabled = preferences.skipSilence
|
|
player.playWhenReady = true
|
|
player.addListener(this)
|
|
player.addAnalyticsListener(PlaybackStatsListener(false, this))
|
|
|
|
if (preferences.persistentQueue) {
|
|
coroutineScope.launch(Dispatchers.IO) {
|
|
val queuedSong = Database.queue()
|
|
Database.clearQueue()
|
|
|
|
if (queuedSong.isEmpty()) return@launch
|
|
|
|
val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0)
|
|
|
|
withContext(Dispatchers.Main) {
|
|
player.setMediaItems(
|
|
queuedSong
|
|
.map(QueuedMediaItem::mediaItem)
|
|
.map { mediaItem ->
|
|
mediaItem.buildUpon()
|
|
.setUri(mediaItem.mediaId)
|
|
.setCustomCacheKey(mediaItem.mediaId)
|
|
.build()
|
|
},
|
|
true
|
|
)
|
|
player.seekTo(index, queuedSong[index].position ?: 0)
|
|
player.playWhenReady = false
|
|
player.prepare()
|
|
}
|
|
}
|
|
}
|
|
|
|
mediaSession = MediaSessionCompat(baseContext, "PlayerService")
|
|
mediaSession.setCallback(SessionCallback(player))
|
|
mediaSession.setPlaybackState(stateBuilder.build())
|
|
mediaSession.isActive = true
|
|
mediaSession.controller.registerCallback(mediaControllerCallback)
|
|
}
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
|
return START_NOT_STICKY
|
|
}
|
|
|
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
if (!player.playWhenReady) {
|
|
notificationManager.cancel(NotificationId)
|
|
stopSelf()
|
|
}
|
|
|
|
super.onTaskRemoved(rootIntent)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
if (preferences.persistentQueue) {
|
|
val mediaItems = player.currentTimeline.mediaItems
|
|
val mediaItemIndex = player.currentMediaItemIndex
|
|
val mediaItemPosition = player.currentPosition
|
|
|
|
Database.internal.queryExecutor.execute {
|
|
Database.clearQueue()
|
|
Database.insertQueue(
|
|
mediaItems.mapIndexed { index, mediaItem ->
|
|
QueuedMediaItem(
|
|
mediaItem = mediaItem,
|
|
position = if (index == mediaItemIndex) mediaItemPosition else null
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
player.removeListener(this)
|
|
player.stop()
|
|
player.release()
|
|
|
|
mediaSession.controller.unregisterCallback(mediaControllerCallback)
|
|
mediaSession.isActive = false
|
|
mediaSession.release()
|
|
cache.release()
|
|
super.onDestroy()
|
|
}
|
|
|
|
override fun onPlaybackStatsReady(
|
|
eventTime: AnalyticsListener.EventTime,
|
|
playbackStats: PlaybackStats
|
|
) {
|
|
val mediaItem =
|
|
eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem
|
|
|
|
Database.internal.queryExecutor.execute {
|
|
Database.incrementTotalPlayTimeMs(mediaItem.mediaId, playbackStats.totalPlayTimeMs)
|
|
}
|
|
}
|
|
|
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
normalizeVolume()
|
|
|
|
radio?.let { radio ->
|
|
if (player.mediaItemCount - player.currentMediaItemIndex <= 3) {
|
|
coroutineScope.launch(Dispatchers.Main) {
|
|
player.addMediaItems(radio.process())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun normalizeVolume() {
|
|
if (preferences.volumeNormalization) {
|
|
player.volume = player.currentMediaItem?.let { mediaItem ->
|
|
songPendingLoudnessDb.getOrElse(mediaItem.mediaId) {
|
|
mediaItem.mediaMetadata.extras?.getFloat("loudnessDb")
|
|
}?.takeIf { it > 0 }?.let { loudnessDb ->
|
|
(1f - (0.01f + loudnessDb / 14)).coerceIn(0.1f, 1f)
|
|
}
|
|
} ?: 1f
|
|
}
|
|
}
|
|
|
|
override fun onPlaybackStateChanged(@Player.State playbackState: Int) {
|
|
if (playbackState == Player.STATE_READY) {
|
|
if (player.duration != C.TIME_UNSET) {
|
|
metadataBuilder
|
|
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, player.duration)
|
|
mediaSession.setMetadata(metadataBuilder.build())
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onPositionDiscontinuity(
|
|
oldPosition: Player.PositionInfo,
|
|
newPosition: Player.PositionInfo,
|
|
@Player.DiscontinuityReason reason: Int
|
|
) {
|
|
stateBuilder
|
|
.setState(STATE_NONE, newPosition.positionMs, 1f)
|
|
.setBufferedPosition(player.bufferedPosition)
|
|
|
|
mediaSession.setPlaybackState(stateBuilder.build())
|
|
}
|
|
|
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
stateBuilder
|
|
.setState(if (isPlaying) STATE_PLAYING else STATE_PAUSED, player.currentPosition, 1f)
|
|
.setBufferedPosition(player.bufferedPosition)
|
|
|
|
mediaSession.setPlaybackState(stateBuilder.build())
|
|
}
|
|
|
|
private fun notification(): Notification {
|
|
fun NotificationCompat.Builder.addMediaAction(
|
|
@DrawableRes resId: Int,
|
|
description: String,
|
|
@MediaKeyAction command: Long
|
|
): NotificationCompat.Builder {
|
|
return addAction(
|
|
NotificationCompat.Action(
|
|
resId,
|
|
description,
|
|
MediaButtonReceiver.buildMediaButtonPendingIntent(this@PlayerService, command)
|
|
)
|
|
)
|
|
}
|
|
|
|
val mediaMetadata = player.mediaMetadata
|
|
|
|
val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId)
|
|
.setContentTitle(mediaMetadata.title)
|
|
.setContentText(mediaMetadata.artist)
|
|
.setLargeIcon(lastBitmap)
|
|
.setAutoCancel(true)
|
|
.setOnlyAlertOnce(true)
|
|
.setShowWhen(false)
|
|
.setSmallIcon(R.drawable.app_icon)
|
|
.setOngoing(false)
|
|
.setContentIntent(activityPendingIntent<MainActivity>())
|
|
.setDeleteIntent(broadCastPendingIntent<StopServiceBroadcastReceiver>())
|
|
.setChannelId(NotificationChannelId)
|
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
.setStyle(
|
|
androidx.media.app.NotificationCompat.MediaStyle()
|
|
.setShowActionsInCompactView(0, 1, 2)
|
|
.setMediaSession(mediaSession.sessionToken)
|
|
)
|
|
.addMediaAction(R.drawable.play_skip_back, "Skip back", ACTION_SKIP_TO_PREVIOUS)
|
|
.addMediaAction(
|
|
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) R.drawable.play else R.drawable.pause,
|
|
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) "Play" else "Pause",
|
|
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) ACTION_PLAY else ACTION_PAUSE
|
|
)
|
|
.addMediaAction(R.drawable.play_skip_forward, "Skip forward", ACTION_SKIP_TO_NEXT)
|
|
|
|
if (lastArtworkUri != mediaMetadata.artworkUri) {
|
|
lastArtworkUri = mediaMetadata.artworkUri
|
|
|
|
Coil.imageLoader(applicationContext).enqueue(
|
|
ImageRequest.Builder(applicationContext)
|
|
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
|
|
.listener(
|
|
onError = { _, _ ->
|
|
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
|
|
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
|
|
notificationManager.notify(
|
|
NotificationId,
|
|
builder.setLargeIcon(lastBitmap).build()
|
|
)
|
|
},
|
|
onSuccess = { _, result ->
|
|
lastBitmap = (result.drawable as BitmapDrawable).bitmap
|
|
notificationManager.notify(
|
|
NotificationId,
|
|
builder.setLargeIcon(lastBitmap).build()
|
|
)
|
|
}
|
|
)
|
|
.build()
|
|
)
|
|
}
|
|
|
|
return builder.build()
|
|
}
|
|
|
|
private fun createNotificationChannel() {
|
|
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
|
|
with(notificationManager) {
|
|
if (getNotificationChannel(NotificationChannelId) == null) {
|
|
createNotificationChannel(
|
|
NotificationChannel(
|
|
NotificationChannelId,
|
|
"Now playing",
|
|
NotificationManager.IMPORTANCE_LOW
|
|
)
|
|
)
|
|
}
|
|
|
|
if (getNotificationChannel(SleepTimerNotificationChannelId) == null) {
|
|
createNotificationChannel(
|
|
NotificationChannel(
|
|
SleepTimerNotificationChannelId,
|
|
"Sleep timer",
|
|
NotificationManager.IMPORTANCE_DEFAULT
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun createCacheDataSource(): DataSource.Factory {
|
|
return CacheDataSource.Factory().setCache(cache).apply {
|
|
setUpstreamDataSourceFactory(
|
|
DefaultHttpDataSource.Factory()
|
|
.setConnectTimeoutMs(16000)
|
|
.setReadTimeoutMs(8000)
|
|
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0")
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun createDataSourceFactory(): DataSource.Factory {
|
|
val chunkLength = 512 * 1024L
|
|
val ringBuffer = RingBuffer<Pair<String, Uri>?>(2) { null }
|
|
|
|
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
|
|
val videoId = dataSpec.key ?: error("A key must be set")
|
|
|
|
if (cache.isCached(videoId, dataSpec.position, chunkLength)) {
|
|
dataSpec
|
|
} else {
|
|
when (videoId) {
|
|
ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
|
|
ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
|
|
else -> {
|
|
val url = runBlocking(Dispatchers.IO) {
|
|
it.vfsfitvnm.youtubemusic.YouTube.player(videoId)
|
|
}.flatMap { body ->
|
|
val loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat()
|
|
|
|
songPendingLoudnessDb[videoId] = loudnessDb
|
|
|
|
runBlocking(Dispatchers.Main) {
|
|
normalizeVolume()
|
|
}
|
|
|
|
when (val status = body.playabilityStatus.status) {
|
|
"OK" -> body.streamingData?.adaptiveFormats?.findLast { format ->
|
|
format.itag == 251 || format.itag == 140
|
|
}?.let { format ->
|
|
val mediaItem = runBlocking(Dispatchers.Main) {
|
|
player.findNextMediaItemById(videoId)
|
|
}
|
|
|
|
loudnessDb?.let { loudnessDb ->
|
|
mediaItem?.mediaMetadata?.extras
|
|
?.putFloat("loudnessDb", loudnessDb)
|
|
}
|
|
|
|
format.contentLength?.let { contentLength ->
|
|
mediaItem?.mediaMetadata?.extras
|
|
?.putLong("contentLength", contentLength)
|
|
}
|
|
|
|
mediaItem?.let {
|
|
Database.internal.queryExecutor.execute {
|
|
Database.insert(it)
|
|
}
|
|
}
|
|
|
|
Outcome.Success(format.url)
|
|
} ?: Outcome.Error.Unhandled(
|
|
PlaybackException(
|
|
"Couldn't find a playable audio format",
|
|
null,
|
|
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
|
)
|
|
)
|
|
else -> Outcome.Error.Unhandled(
|
|
PlaybackException(
|
|
status,
|
|
null,
|
|
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
when (url) {
|
|
is Outcome.Success -> {
|
|
ringBuffer.append(videoId to url.value.toUri())
|
|
dataSpec.withUri(url.value.toUri())
|
|
.subrange(dataSpec.uriPositionOffset, chunkLength)
|
|
}
|
|
is Outcome.Error.Network -> throw PlaybackException(
|
|
"Couldn't reach the internet",
|
|
null,
|
|
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
|
)
|
|
is Outcome.Error.Unhandled -> throw url.throwable
|
|
else -> throw PlaybackException(
|
|
"Unexpected error",
|
|
null,
|
|
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
inner class Binder : android.os.Binder() {
|
|
val player: ExoPlayer
|
|
get() = this@PlayerService.player
|
|
|
|
val cache: Cache
|
|
get() = this@PlayerService.cache
|
|
|
|
val sleepTimerMillisLeft: StateFlow<Long?>?
|
|
get() = timerJob?.millisLeft
|
|
|
|
fun startSleepTimer(delayMillis: Long) {
|
|
timerJob?.cancel()
|
|
|
|
timerJob = coroutineScope.timer(delayMillis) {
|
|
val notification = NotificationCompat
|
|
.Builder(this@PlayerService, SleepTimerNotificationChannelId)
|
|
.setContentTitle("Sleep timer ended")
|
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
.setAutoCancel(true)
|
|
.setOnlyAlertOnce(true)
|
|
.setShowWhen(true)
|
|
.setSmallIcon(R.drawable.app_icon)
|
|
.build()
|
|
|
|
notificationManager.notify(SleepTimerNotificationId, notification)
|
|
|
|
exitProcess(0)
|
|
}
|
|
}
|
|
|
|
fun cancelSleepTimer() {
|
|
timerJob?.cancel()
|
|
timerJob = null
|
|
}
|
|
|
|
fun startRadio(
|
|
endpoint: NavigationEndpoint.Endpoint.Watch?,
|
|
) {
|
|
startRadio(
|
|
videoId = endpoint?.videoId,
|
|
playlistId = endpoint?.playlistId,
|
|
playlistSetVideoId = endpoint?.playlistSetVideoId,
|
|
parameters = endpoint?.params,
|
|
justAdd = false
|
|
)
|
|
}
|
|
|
|
fun startRadio(
|
|
videoId: String?,
|
|
playlistId: String? = null,
|
|
playlistSetVideoId: String? = null,
|
|
parameters: String? = null,
|
|
justAdd: Boolean = true
|
|
) {
|
|
radio = null
|
|
YoutubePlayer.Radio(
|
|
videoId, playlistId, playlistSetVideoId, parameters
|
|
).let {
|
|
coroutineScope.launch(Dispatchers.Main) {
|
|
if (justAdd) {
|
|
player.addMediaItems(it.process().drop(1))
|
|
} else {
|
|
player.forcePlayFromBeginning(it.process())
|
|
}
|
|
radio = it
|
|
}
|
|
}
|
|
}
|
|
|
|
fun stopRadio() {
|
|
radio = null
|
|
}
|
|
}
|
|
|
|
private class SessionCallback(private val player: Player) : MediaSessionCompat.Callback() {
|
|
override fun onPlay() = player.play()
|
|
override fun onPause() = player.pause()
|
|
override fun onSkipToPrevious() = player.seekToPrevious()
|
|
override fun onSkipToNext() = player.seekToNext()
|
|
override fun onSeekTo(pos: Long) = player.seekTo(pos)
|
|
}
|
|
|
|
class StopServiceBroadcastReceiver : BroadcastReceiver() {
|
|
override fun onReceive(context: Context, intent: Intent) {
|
|
context.stopService(context.intent<PlayerService>())
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val NotificationId = 1001
|
|
private const val NotificationChannelId = "default_channel_id"
|
|
|
|
private const val SleepTimerNotificationId = 1002
|
|
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
|
|
}
|
|
}
|