492 lines
20 KiB
Kotlin
492 lines
20 KiB
Kotlin
package it.vfsfitvnm.vimusic.services
|
|
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
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.os.Bundle
|
|
import android.os.SystemClock
|
|
import androidx.annotation.DrawableRes
|
|
import androidx.annotation.StringRes
|
|
import androidx.compose.animation.ExperimentalAnimationApi
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.core.graphics.drawable.IconCompat
|
|
import androidx.core.graphics.drawable.toBitmap
|
|
import androidx.core.net.toUri
|
|
import androidx.core.os.bundleOf
|
|
import androidx.media3.common.*
|
|
import androidx.media3.common.util.Util
|
|
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.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 androidx.media3.session.*
|
|
import androidx.media3.session.MediaNotification.ActionFactory
|
|
import coil.Coil
|
|
import coil.request.ImageRequest
|
|
import com.google.common.util.concurrent.Futures
|
|
import com.google.common.util.concurrent.ListenableFuture
|
|
import it.vfsfitvnm.vimusic.Database
|
|
import it.vfsfitvnm.vimusic.MainActivity
|
|
import it.vfsfitvnm.vimusic.R
|
|
import it.vfsfitvnm.vimusic.utils.*
|
|
import it.vfsfitvnm.youtubemusic.Outcome
|
|
import kotlinx.coroutines.*
|
|
import kotlin.math.roundToInt
|
|
import kotlin.system.exitProcess
|
|
|
|
|
|
val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY)
|
|
val StartArtistRadioCommand = SessionCommand("StartArtistRadioCommand", Bundle.EMPTY)
|
|
val StopRadioCommand = SessionCommand("StopRadioCommand", Bundle.EMPTY)
|
|
|
|
val GetCacheSizeCommand = SessionCommand("GetCacheSizeCommand", Bundle.EMPTY)
|
|
|
|
val DeleteSongCacheCommand = SessionCommand("DeleteSongCacheCommand", Bundle.EMPTY)
|
|
|
|
val SetSkipSilenceCommand = SessionCommand("SetSkipSilenceCommand", Bundle.EMPTY)
|
|
|
|
val GetAudioSessionIdCommand = SessionCommand("GetAudioSessionIdCommand", Bundle.EMPTY)
|
|
|
|
val SetSleepTimerCommand = SessionCommand("SetSleepTimerCommand", Bundle.EMPTY)
|
|
val GetSleepTimerMillisLeftCommand = SessionCommand("GetSleepTimerMillisLeftCommand", Bundle.EMPTY)
|
|
val CancelSleepTimerCommand = SessionCommand("CancelSleepTimerCommand", Bundle.EMPTY)
|
|
|
|
|
|
@ExperimentalAnimationApi
|
|
@ExperimentalFoundationApi
|
|
class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
|
|
MediaNotification.Provider,
|
|
MediaSession.SessionCallback,
|
|
PlaybackStatsListener.Callback, Player.Listener {
|
|
|
|
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"
|
|
}
|
|
|
|
private lateinit var cache: SimpleCache
|
|
|
|
private lateinit var player: ExoPlayer
|
|
|
|
private lateinit var mediaSession: MediaSession
|
|
|
|
private lateinit var notificationManager: NotificationManager
|
|
|
|
private var notificationThumbnailSize: Int = 0
|
|
private var lastArtworkUri: Uri? = null
|
|
private var lastBitmap: Bitmap? = null
|
|
|
|
private var radio: YoutubePlayer.Radio? = null
|
|
|
|
private var sleepTimerJob: Job? = null
|
|
private var sleepTimerRealtime: Long? = null
|
|
|
|
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
|
|
notificationThumbnailSize = (96 * resources.displayMetrics.density).roundToInt()
|
|
|
|
createNotificationChannel()
|
|
setMediaNotificationProvider(this)
|
|
|
|
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.CONTENT_TYPE_MUSIC)
|
|
.build(),
|
|
true
|
|
)
|
|
.build()
|
|
|
|
player.repeatMode = preferences.repeatMode
|
|
player.skipSilenceEnabled = preferences.skipSilence
|
|
player.playWhenReady = true
|
|
player.addAnalyticsListener(PlaybackStatsListener(false, this))
|
|
|
|
mediaSession = MediaSession.Builder(this, player)
|
|
.withSessionActivity()
|
|
.setSessionCallback(this)
|
|
.setMediaItemFiller(this)
|
|
.build()
|
|
|
|
player.addListener(this)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
player.release()
|
|
mediaSession.release()
|
|
cache.release()
|
|
super.onDestroy()
|
|
}
|
|
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession {
|
|
return mediaSession
|
|
}
|
|
|
|
override fun onConnect(
|
|
session: MediaSession,
|
|
controller: MediaSession.ControllerInfo
|
|
): MediaSession.ConnectionResult {
|
|
val sessionCommands = SessionCommands.Builder()
|
|
.add(StartRadioCommand)
|
|
.add(StartArtistRadioCommand)
|
|
.add(StopRadioCommand)
|
|
.add(GetCacheSizeCommand)
|
|
.add(DeleteSongCacheCommand)
|
|
.add(SetSkipSilenceCommand)
|
|
.add(GetAudioSessionIdCommand)
|
|
.add(SetSleepTimerCommand)
|
|
.add(GetSleepTimerMillisLeftCommand)
|
|
.add(CancelSleepTimerCommand)
|
|
.build()
|
|
val playerCommands = Player.Commands.Builder().addAllCommands().build()
|
|
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
|
|
}
|
|
|
|
override fun onCustomCommand(
|
|
session: MediaSession,
|
|
controller: MediaSession.ControllerInfo,
|
|
customCommand: SessionCommand,
|
|
args: Bundle
|
|
): ListenableFuture<SessionResult> {
|
|
when (customCommand) {
|
|
StartRadioCommand, StartArtistRadioCommand -> {
|
|
radio = null
|
|
YoutubePlayer.Radio(
|
|
videoId = args.getString("videoId"),
|
|
playlistId = args.getString("playlistId"),
|
|
playlistSetVideoId = args.getString("playlistSetVideoId"),
|
|
parameters = args.getString("params"),
|
|
).let {
|
|
coroutineScope.launch(Dispatchers.Main) {
|
|
when (customCommand) {
|
|
StartRadioCommand -> player.addMediaItems(it.process().drop(1))
|
|
StartArtistRadioCommand -> player.forcePlayFromBeginning(it.process())
|
|
}
|
|
radio = it
|
|
}
|
|
}
|
|
}
|
|
StopRadioCommand -> radio = null
|
|
GetCacheSizeCommand -> {
|
|
return Futures.immediateFuture(
|
|
SessionResult(
|
|
SessionResult.RESULT_SUCCESS,
|
|
bundleOf("cacheSize" to cache.cacheSpace)
|
|
)
|
|
)
|
|
}
|
|
DeleteSongCacheCommand -> {
|
|
args.getString("videoId")?.let { videoId ->
|
|
cache.removeResource(videoId)
|
|
}
|
|
}
|
|
SetSkipSilenceCommand -> {
|
|
player.skipSilenceEnabled = args.getBoolean("skipSilence")
|
|
}
|
|
GetAudioSessionIdCommand -> {
|
|
return Futures.immediateFuture(
|
|
SessionResult(
|
|
SessionResult.RESULT_SUCCESS,
|
|
bundleOf("audioSessionId" to player.audioSessionId)
|
|
)
|
|
)
|
|
}
|
|
SetSleepTimerCommand -> {
|
|
val delayMillis = args.getLong("delayMillis", 2000)
|
|
|
|
sleepTimerJob = coroutineScope.launch {
|
|
sleepTimerRealtime = SystemClock.elapsedRealtime() + delayMillis
|
|
delay(delayMillis)
|
|
|
|
withContext(Dispatchers.Main) {
|
|
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)
|
|
}
|
|
}
|
|
GetSleepTimerMillisLeftCommand -> {
|
|
return Futures.immediateFuture(sleepTimerRealtime?.let {
|
|
(SessionResult(SessionResult.RESULT_SUCCESS, bundleOf("millisLeft" to it - SystemClock.elapsedRealtime())))
|
|
} ?: SessionResult(SessionResult.RESULT_ERROR_INVALID_STATE))
|
|
}
|
|
CancelSleepTimerCommand -> {
|
|
sleepTimerJob?.cancel()
|
|
sleepTimerJob = null
|
|
sleepTimerRealtime = null
|
|
}
|
|
}
|
|
|
|
return super.onCustomCommand(session, controller, customCommand, args)
|
|
}
|
|
|
|
override fun onPlaybackStatsReady(
|
|
eventTime: AnalyticsListener.EventTime,
|
|
playbackStats: PlaybackStats
|
|
) {
|
|
val mediaItem =
|
|
eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem
|
|
|
|
coroutineScope.launch(Dispatchers.IO) {
|
|
Database.insert(mediaItem)
|
|
Database.incrementTotalPlayTimeMs(mediaItem.mediaId, playbackStats.totalPlayTimeMs)
|
|
}
|
|
}
|
|
|
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
radio?.let { radio ->
|
|
if (player.mediaItemCount - player.currentMediaItemIndex <= 3) {
|
|
coroutineScope.launch(Dispatchers.Main) {
|
|
player.addMediaItems(radio.process())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun fillInLocalConfiguration(
|
|
session: MediaSession,
|
|
controller: MediaSession.ControllerInfo,
|
|
mediaItem: MediaItem
|
|
): MediaItem {
|
|
return mediaItem.buildUpon()
|
|
.setUri(mediaItem.mediaId)
|
|
.setCustomCacheKey(mediaItem.mediaId)
|
|
.build()
|
|
}
|
|
|
|
override fun createNotification(
|
|
mediaController: MediaController,
|
|
actionFactory: ActionFactory,
|
|
onNotificationChangedCallback: MediaNotification.Provider.Callback
|
|
): MediaNotification {
|
|
fun invalidate() {
|
|
onNotificationChangedCallback.onNotificationChanged(
|
|
createNotification(
|
|
mediaController,
|
|
actionFactory,
|
|
onNotificationChangedCallback
|
|
)
|
|
)
|
|
}
|
|
|
|
fun NotificationCompat.Builder.addMediaAction(
|
|
@DrawableRes resId: Int,
|
|
@StringRes stringId: Int,
|
|
@Player.Command command: Long
|
|
): NotificationCompat.Builder {
|
|
return addAction(
|
|
actionFactory.createMediaAction(
|
|
IconCompat.createWithResource(this@PlayerService, resId),
|
|
getString(stringId),
|
|
command
|
|
)
|
|
)
|
|
}
|
|
|
|
val mediaMetadata = mediaController.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(mediaController.sessionActivity)
|
|
.setDeleteIntent(actionFactory.createMediaActionPendingIntent(ActionFactory.COMMAND_STOP))
|
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
.setStyle(
|
|
androidx.media.app.NotificationCompat.MediaStyle()
|
|
.setShowActionsInCompactView(0, 1, 2)
|
|
.setMediaSession(mediaSession.sessionCompatToken as android.support.v4.media.session.MediaSessionCompat.Token)
|
|
)
|
|
.addMediaAction(
|
|
R.drawable.play_skip_back,
|
|
R.string.media3_controls_seek_to_previous_description,
|
|
ActionFactory.COMMAND_SKIP_TO_PREVIOUS
|
|
).run {
|
|
if (mediaController.playbackState == Player.STATE_ENDED || !mediaController.playWhenReady) {
|
|
addMediaAction(
|
|
R.drawable.play,
|
|
R.string.media3_controls_play_description,
|
|
ActionFactory.COMMAND_PLAY
|
|
)
|
|
} else {
|
|
addMediaAction(
|
|
R.drawable.pause,
|
|
R.string.media3_controls_pause_description,
|
|
ActionFactory.COMMAND_PAUSE
|
|
)
|
|
}
|
|
}.addMediaAction(
|
|
R.drawable.play_skip_forward,
|
|
R.string.media3_controls_seek_to_next_description,
|
|
ActionFactory.COMMAND_SKIP_TO_NEXT
|
|
)
|
|
|
|
if (lastArtworkUri != mediaMetadata.artworkUri) {
|
|
coroutineScope.launch(Dispatchers.IO) {
|
|
lastBitmap = Coil.imageLoader(applicationContext).execute(
|
|
ImageRequest.Builder(applicationContext)
|
|
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
|
|
.build()
|
|
).drawable?.let {
|
|
lastArtworkUri = mediaMetadata.artworkUri
|
|
(it as BitmapDrawable).bitmap
|
|
} ?: resources.getDrawable(R.drawable.disc_placeholder, null)
|
|
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
|
|
|
|
withContext(Dispatchers.Main) {
|
|
invalidate()
|
|
}
|
|
}
|
|
}
|
|
|
|
return MediaNotification(NotificationId, builder.build())
|
|
}
|
|
|
|
override fun handleCustomAction(
|
|
mediaController: MediaController,
|
|
action: String,
|
|
extras: Bundle
|
|
) = Unit
|
|
|
|
private fun createNotificationChannel() {
|
|
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
if (Util.SDK_INT < 26) return
|
|
|
|
with(notificationManager) {
|
|
if (getNotificationChannel(NotificationChannelId) == null) {
|
|
createNotificationChannel(NotificationChannel(NotificationChannelId, getString(R.string.default_notification_channel_name), 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 ->
|
|
when (val status = body.playabilityStatus.status) {
|
|
"OK" -> body.streamingData?.adaptiveFormats?.findLast { format ->
|
|
format.itag == 251 || format.itag == 140
|
|
}?.url?.let { Outcome.Success(it) } ?: 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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun MediaSession.Builder.withSessionActivity(): MediaSession.Builder {
|
|
return setSessionActivity(
|
|
PendingIntent.getActivity(
|
|
this@PlayerService,
|
|
0,
|
|
Intent(this@PlayerService, MainActivity::class.java),
|
|
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
|
|
)
|
|
)
|
|
}
|
|
}
|