Files
muza/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt

853 lines
33 KiB
Kotlin

package it.vfsfitvnm.vimusic.service
import android.os.Binder as AndroidBinder
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.media.MediaMetadata
import android.media.audiofx.AudioEffect
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Build
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat.startForegroundService
import androidx.core.content.edit
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.core.text.isDigitsOnly
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
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.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.analytics.PlaybackStats
import androidx.media3.exoplayer.analytics.PlaybackStatsListener
import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor
import androidx.media3.exoplayer.audio.SonicAudioProcessor
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize
import it.vfsfitvnm.vimusic.models.QueuedMediaItem
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.utils.InvincibleService
import it.vfsfitvnm.vimusic.utils.RingBuffer
import it.vfsfitvnm.vimusic.utils.TimerJob
import it.vfsfitvnm.vimusic.utils.YouTubeRadio
import it.vfsfitvnm.vimusic.utils.activityPendingIntent
import it.vfsfitvnm.vimusic.utils.broadCastPendingIntent
import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey
import it.vfsfitvnm.vimusic.utils.findNextMediaItemById
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.forceSeekToNext
import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious
import it.vfsfitvnm.vimusic.utils.getEnum
import it.vfsfitvnm.vimusic.utils.intent
import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey
import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey
import it.vfsfitvnm.vimusic.utils.mediaItems
import it.vfsfitvnm.vimusic.utils.persistentQueueKey
import it.vfsfitvnm.vimusic.utils.preferences
import it.vfsfitvnm.vimusic.utils.repeatModeKey
import it.vfsfitvnm.vimusic.utils.shouldBePlaying
import it.vfsfitvnm.vimusic.utils.skipSilenceKey
import it.vfsfitvnm.vimusic.utils.timer
import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlin.math.roundToInt
import kotlin.system.exitProcess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@Suppress("DEPRECATION")
class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback,
SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var mediaSession: MediaSession
private lateinit var cache: SimpleCache
private lateinit var player: ExoPlayer
private val stateBuilder = PlaybackState.Builder()
.setActions(
PlaybackState.ACTION_PLAY
or PlaybackState.ACTION_PAUSE
or PlaybackState.ACTION_SKIP_TO_PREVIOUS
or PlaybackState.ACTION_SKIP_TO_NEXT
or PlaybackState.ACTION_PLAY_PAUSE
or PlaybackState.ACTION_SEEK_TO
)
private val metadataBuilder = MediaMetadata.Builder()
private var notificationManager: NotificationManager? = null
private var timerJob: TimerJob? = null
private var radio: YouTubeRadio? = null
private lateinit var bitmapProvider: BitmapProvider
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
private var volumeNormalizationJob: Job? = null
private var isVolumeNormalizationEnabled = false
private var isPersistentQueueEnabled = false
private var isShowingThumbnailInLockscreen = true
override var isInvincibilityEnabled = false
private val binder = Binder()
private var isNotificationStarted = false
override val notificationId: Int
get() = NotificationId
private lateinit var notificationActionReceiver: NotificationActionReceiver
override fun onBind(intent: Intent?): AndroidBinder {
super.onBind(intent)
return binder
}
override fun onCreate() {
super.onCreate()
bitmapProvider = BitmapProvider(
bitmapSize = (256 * resources.displayMetrics.density).roundToInt(),
colorProvider = { isSystemInDarkMode ->
if (isSystemInDarkMode) Color.BLACK else Color.WHITE
}
)
createNotificationChannel()
preferences.registerOnSharedPreferenceChangeListener(this)
val preferences = preferences
isPersistentQueueEnabled = preferences.getBoolean(persistentQueueKey, false)
isVolumeNormalizationEnabled = preferences.getBoolean(volumeNormalizationKey, false)
isInvincibilityEnabled = preferences.getBoolean(isInvincibilityEnabledKey, false)
isShowingThumbnailInLockscreen =
preferences.getBoolean(isShowingThumbnailInLockscreenKey, false)
val cacheEvictor = when (val size =
preferences.getEnum(exoPlayerDiskCacheMaxSizeKey, ExoPlayerDiskCacheMaxSize.`2GB`)) {
ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor()
else -> LeastRecentlyUsedCacheEvictor(size.bytes)
}
// TODO: Remove in a future release
val directory = cacheDir.resolve("exoplayer").also { directory ->
if (directory.exists()) return@also
directory.mkdir()
cacheDir.listFiles()?.forEach { file ->
if (file.isDirectory && file.name.length == 1 && file.name.isDigitsOnly() || file.extension == "uid") {
if (!file.renameTo(directory.resolve(file.name))) {
file.deleteRecursively()
}
}
}
filesDir.resolve("coil").deleteRecursively()
}
cache = SimpleCache(directory, cacheEvictor, StandaloneDatabaseProvider(this))
player = ExoPlayer.Builder(this, createRendersFactory(), createMediaSourceFactory())
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true
)
.setUsePlatformDiagnostics(false)
.build()
player.repeatMode = when (preferences.getInt(repeatModeKey, Player.REPEAT_MODE_ALL)) {
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_ALL
}
player.skipSilenceEnabled = preferences.getBoolean(skipSilenceKey, false)
player.addListener(this)
player.addAnalyticsListener(PlaybackStatsListener(false, this))
maybeRestorePlayerQueue()
mediaSession = MediaSession(baseContext, "PlayerService")
mediaSession.setCallback(SessionCallback(player))
mediaSession.setPlaybackState(stateBuilder.build())
mediaSession.isActive = true
notificationActionReceiver = NotificationActionReceiver(player)
val filter = IntentFilter().apply {
addAction(Action.play.value)
addAction(Action.pause.value)
addAction(Action.next.value)
addAction(Action.previous.value)
}
registerReceiver(notificationActionReceiver, filter)
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.shouldBePlaying) {
broadCastPendingIntent<NotificationDismissReceiver>().send()
}
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
maybeSavePlayerQueue()
preferences.unregisterOnSharedPreferenceChangeListener(this)
player.removeListener(this)
player.stop()
player.release()
unregisterReceiver(notificationActionReceiver)
mediaSession.isActive = false
mediaSession.release()
cache.release()
super.onDestroy()
}
override fun shouldBeInvincible(): Boolean {
return !player.shouldBePlaying
}
override fun onConfigurationChanged(newConfig: Configuration) {
if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) {
notificationManager?.notify(NotificationId, notification())
}
super.onConfigurationChanged(newConfig)
}
override fun onPlaybackStatsReady(
eventTime: AnalyticsListener.EventTime,
playbackStats: PlaybackStats
) {
val mediaItem =
eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem
val totalPlayTimeMs = playbackStats.totalPlayTimeMs
if (totalPlayTimeMs > 2000) {
query {
Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs)
}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
maybeRecoverPlaybackError()
maybeNormalizeVolume()
maybeProcessRadio()
if (mediaItem == null) {
bitmapProvider.listener?.invoke(null)
} else if (mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri) {
bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap)
}
}
private fun maybeRecoverPlaybackError() {
if (player.playerError != null) {
player.prepare()
}
}
private fun maybeProcessRadio() {
radio?.let { radio ->
if (player.mediaItemCount - player.currentMediaItemIndex <= 3) {
coroutineScope.launch(Dispatchers.Main) {
player.addMediaItems(radio.process())
}
}
}
}
private fun maybeSavePlayerQueue() {
if (!isPersistentQueueEnabled) return
val mediaItems = player.currentTimeline.mediaItems
val mediaItemIndex = player.currentMediaItemIndex
val mediaItemPosition = player.currentPosition
mediaItems.mapIndexed { index, mediaItem ->
QueuedMediaItem(
mediaItem = mediaItem,
position = if (index == mediaItemIndex) mediaItemPosition else null
)
}.let { queuedMediaItems ->
query {
Database.clearQueue()
Database.insert(queuedMediaItems)
}
}
}
private fun maybeRestorePlayerQueue() {
if (!isPersistentQueueEnabled) return
query {
val queuedSong = Database.queue()
Database.clearQueue()
if (queuedSong.isEmpty()) return@query
val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0)
runBlocking(Dispatchers.Main) {
player.setMediaItems(
queuedSong.map { mediaItem ->
mediaItem.mediaItem.buildUpon()
.setUri(mediaItem.mediaItem.mediaId)
.setCustomCacheKey(mediaItem.mediaItem.mediaId)
.build()
},
true
)
player.seekTo(index, queuedSong[index].position ?: 0)
player.prepare()
isNotificationStarted = true
startForegroundService(this@PlayerService, intent<PlayerService>())
startForeground(NotificationId, notification())
}
}
}
private fun maybeNormalizeVolume() {
if (!isVolumeNormalizationEnabled) {
volumeNormalizationJob?.cancel()
player.volume = 1f
return
}
player.currentMediaItem?.mediaId?.let { songId ->
volumeNormalizationJob?.cancel()
volumeNormalizationJob = coroutineScope.launch(Dispatchers.IO) {
Database.loudnessDb(songId).cancellable().distinctUntilChanged()
.collect { loudnessDb ->
withContext(Dispatchers.Main) {
player.volume = if (loudnessDb != null && loudnessDb > 0) {
(1f - (0.01f + loudnessDb / 14)).coerceIn(0.1f, 1f)
} else {
1f
}
}
}
}
}
}
private fun maybeShowSongCoverInLockScreen() {
val bitmap = if (isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null
metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap)
mediaSession.setMetadata(metadataBuilder.build())
}
private fun sendOpenEqualizerIntent() {
sendBroadcast(
Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply {
putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId)
putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
}
)
}
private fun sendCloseEqualizerIntent() {
sendBroadcast(
Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply {
putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId)
}
)
}
private val Player.androidPlaybackState: Int
get() = when (playbackState) {
Player.STATE_BUFFERING -> if (playWhenReady) PlaybackState.STATE_BUFFERING else PlaybackState.STATE_PAUSED
Player.STATE_READY -> if (playWhenReady) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED
Player.STATE_ENDED -> PlaybackState.STATE_STOPPED
Player.STATE_IDLE -> PlaybackState.STATE_NONE
else -> PlaybackState.STATE_NONE
}
override fun onRepeatModeChanged(repeatMode: Int) {
preferences.edit { putInt(repeatModeKey, repeatMode) }
}
override fun onEvents(player: Player, events: Player.Events) {
if (player.duration != C.TIME_UNSET) {
metadataBuilder
.putText(
MediaMetadata.METADATA_KEY_TITLE,
player.currentMediaItem?.mediaMetadata?.title
)
.putText(
MediaMetadata.METADATA_KEY_ARTIST,
player.currentMediaItem?.mediaMetadata?.artist
)
.putText(
MediaMetadata.METADATA_KEY_ALBUM,
player.currentMediaItem?.mediaMetadata?.albumTitle
)
.putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration)
.build().let(mediaSession::setMetadata)
}
stateBuilder
.setState(player.androidPlaybackState, player.currentPosition, 1f)
.setBufferedPosition(player.bufferedPosition)
mediaSession.setPlaybackState(stateBuilder.build())
if (events.containsAny(
Player.EVENT_PLAYBACK_STATE_CHANGED,
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY
)
) {
val notification = notification()
if (notification == null) {
isNotificationStarted = false
makeInvincible(false)
stopForeground(false)
sendCloseEqualizerIntent()
notificationManager?.cancel(NotificationId)
return
}
if (player.shouldBePlaying && !isNotificationStarted) {
isNotificationStarted = true
startForegroundService(this@PlayerService, intent<PlayerService>())
startForeground(NotificationId, notification)
makeInvincible(false)
sendOpenEqualizerIntent()
} else {
if (!player.shouldBePlaying) {
isNotificationStarted = false
stopForeground(false)
makeInvincible(true)
sendCloseEqualizerIntent()
}
notificationManager?.notify(NotificationId, notification)
}
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
persistentQueueKey -> isPersistentQueueEnabled =
sharedPreferences.getBoolean(key, isPersistentQueueEnabled)
volumeNormalizationKey -> {
isVolumeNormalizationEnabled =
sharedPreferences.getBoolean(key, isVolumeNormalizationEnabled)
maybeNormalizeVolume()
}
isInvincibilityEnabledKey -> isInvincibilityEnabled =
sharedPreferences.getBoolean(key, isInvincibilityEnabled)
skipSilenceKey -> player.skipSilenceEnabled = sharedPreferences.getBoolean(key, false)
isShowingThumbnailInLockscreenKey -> {
isShowingThumbnailInLockscreen = sharedPreferences.getBoolean(key, true)
maybeShowSongCoverInLockScreen()
}
}
}
override fun notification(): Notification? {
if (player.currentMediaItem == null) return null
val playIntent = Action.play.pendingIntent
val pauseIntent = Action.pause.pendingIntent
val nextIntent = Action.next.pendingIntent
val prevIntent = Action.previous.pendingIntent
val mediaMetadata = player.mediaMetadata
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(applicationContext, NotificationChannelId)
} else {
Notification.Builder(applicationContext)
}
.setContentTitle(mediaMetadata.title)
.setContentText(mediaMetadata.artist)
.setSubText(player.playerError?.message)
.setLargeIcon(bitmapProvider.bitmap)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setSmallIcon(player.playerError?.let { R.drawable.alert_circle }
?: R.drawable.app_icon)
.setOngoing(false)
.setContentIntent(activityPendingIntent<MainActivity>(
flags = PendingIntent.FLAG_UPDATE_CURRENT
) {
putExtra("expandPlayerBottomSheet", true)
})
.setDeleteIntent(broadCastPendingIntent<NotificationDismissReceiver>())
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setStyle(
Notification.MediaStyle()
.setShowActionsInCompactView(0, 1, 2)
.setMediaSession(mediaSession.sessionToken)
)
.addAction(R.drawable.play_skip_back, "Skip back", prevIntent)
.addAction(
if (player.shouldBePlaying) R.drawable.pause else R.drawable.play,
if (player.shouldBePlaying) "Pause" else "Play",
if (player.shouldBePlaying) pauseIntent else playIntent
)
.addAction(R.drawable.play_skip_forward, "Skip forward", nextIntent)
bitmapProvider.load(mediaMetadata.artworkUri) { bitmap ->
maybeShowSongCoverInLockScreen()
notificationManager?.notify(NotificationId, builder.setLargeIcon(bitmap).build())
}
return builder.build()
}
private fun createNotificationChannel() {
notificationManager = getSystemService()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
notificationManager?.run {
if (getNotificationChannel(NotificationChannelId) == null) {
createNotificationChannel(
NotificationChannel(
NotificationChannelId,
"Now playing",
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
enableLights(false)
enableVibration(false)
}
)
}
if (getNotificationChannel(SleepTimerNotificationChannelId) == null) {
createNotificationChannel(
NotificationChannel(
SleepTimerNotificationChannelId,
"Sleep timer",
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
enableLights(false)
enableVibration(false)
}
)
}
}
}
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 urlResult = runBlocking(Dispatchers.IO) {
YouTube.player(videoId)
}?.mapCatching { body ->
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)
}
query {
mediaItem?.let(Database::insert)
Database.insert(
it.vfsfitvnm.vimusic.models.Format(
songId = videoId,
itag = format.itag,
mimeType = format.mimeType,
bitrate = format.bitrate,
loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
contentLength = format.contentLength,
lastModified = format.lastModified
)
)
}
format.url
} ?: throw PlayableFormatNotFoundException()
"UNPLAYABLE" -> throw UnplayableException()
"LOGIN_REQUIRED" -> throw LoginRequiredException()
else -> throw PlaybackException(
status,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
}
urlResult?.getOrThrow()?.let { url ->
ringBuffer.append(videoId to url.toUri())
dataSpec.withUri(url.toUri())
.subrange(dataSpec.uriPositionOffset, chunkLength)
} ?: throw PlaybackException(
null,
urlResult?.exceptionOrNull(),
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
}
}
}
}
private fun createMediaSourceFactory(): MediaSource.Factory {
return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory())
}
private fun createExtractorsFactory(): ExtractorsFactory {
return ExtractorsFactory {
arrayOf(MatroskaExtractor(), FragmentedMp4Extractor())
}
}
private fun createRendersFactory(): RenderersFactory {
val audioSink = DefaultAudioSink.Builder()
.setEnableFloatOutput(false)
.setEnableAudioTrackPlaybackParams(false)
.setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED)
.setAudioProcessorChain(
DefaultAudioProcessorChain(
emptyArray(),
SilenceSkippingAudioProcessor(2_000_000, 20_000, 256),
SonicAudioProcessor()
)
)
.build()
return RenderersFactory { handler: Handler?, _, audioListener: AudioRendererEventListener?, _, _ ->
arrayOf(
MediaCodecAudioRenderer(
this,
MediaCodecSelector.DEFAULT,
handler,
audioListener,
audioSink
)
)
}
}
inner class Binder : AndroidBinder() {
val player: ExoPlayer
get() = this@PlayerService.player
val cache: Cache
get() = this@PlayerService.cache
val sleepTimerMillisLeft: StateFlow<Long?>?
get() = timerJob?.millisLeft
private var radioJob: Job? = null
var isLoadingRadio by mutableStateOf(false)
private set
fun setBitmapListener(listener: ((Bitmap?) -> Unit)?) {
bitmapProvider.listener = listener
}
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)
stopSelf()
exitProcess(0)
}
}
fun cancelSleepTimer() {
timerJob?.cancel()
timerJob = null
}
fun setupRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) =
startRadio(endpoint = endpoint, justAdd = true)
fun playRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) =
startRadio(endpoint = endpoint, justAdd = false)
private fun startRadio(endpoint: NavigationEndpoint.Endpoint.Watch?, justAdd: Boolean) {
radioJob?.cancel()
radio = null
YouTubeRadio(
endpoint?.videoId,
endpoint?.playlistId,
endpoint?.playlistSetVideoId,
endpoint?.params
).let {
isLoadingRadio = true
radioJob = coroutineScope.launch(Dispatchers.Main) {
if (justAdd) {
player.addMediaItems(it.process().drop(1))
} else {
player.forcePlayFromBeginning(it.process())
}
radio = it
isLoadingRadio = false
}
}
}
fun stopRadio() {
isLoadingRadio = false
radioJob?.cancel()
radio = null
}
}
private class SessionCallback(private val player: Player) : MediaSession.Callback() {
override fun onPlay() = player.play()
override fun onPause() = player.pause()
override fun onSkipToPrevious() = player.forceSeekToPrevious()
override fun onSkipToNext() = player.forceSeekToNext()
override fun onSeekTo(pos: Long) = player.seekTo(pos)
}
private class NotificationActionReceiver(private val player: Player) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Action.pause.value -> player.pause()
Action.play.value -> player.play()
Action.next.value -> player.forceSeekToNext()
Action.previous.value -> player.forceSeekToPrevious()
}
}
}
class NotificationDismissReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
context.stopService(context.intent<PlayerService>())
}
}
@JvmInline
private value class Action(val value: String) {
context(Context)
val pendingIntent: PendingIntent
get() = PendingIntent.getBroadcast(
this@Context,
100,
Intent(value).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT.or(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
)
companion object {
val pause = Action("it.vfsfitvnm.vimusic.pause")
val play = Action("it.vfsfitvnm.vimusic.play")
val next = Action("it.vfsfitvnm.vimusic.next")
val previous = Action("it.vfsfitvnm.vimusic.previous")
}
}
private companion object {
const val NotificationId = 1001
const val NotificationChannelId = "default_channel_id"
const val SleepTimerNotificationId = 1002
const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
}
}