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.database.SQLException 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 android.text.format.DateUtils 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.Event 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.Innertube import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody import it.vfsfitvnm.youtubemusic.requests.player 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.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking @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.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS) 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().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 > 5000) { query { Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) } } if (totalPlayTimeMs > 30000) { query { try { Database.insert( Event( songId = mediaItem.mediaId, timestamp = System.currentTimeMillis(), playTime = totalPlayTimeMs ) ) } catch (_: SQLException) { } } } } 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().apply { mediaMetadata.extras?.putBoolean("isFromPersistentQueue", true) } }, index, queuedSong[index].position ?: C.TIME_UNSET ) player.prepare() isNotificationStarted = true startForegroundService(this@PlayerService, intent()) 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.Main) { Database .loudnessDb(songId) .cancellable() .distinctUntilChanged() .filterNotNull() .flowOn(Dispatchers.IO) .collect { loudnessDb -> val x = loudnessDb.coerceIn(-10f, 10f) val x2 = x * x val x3 = x2 * x val x4 = x2 * x2 player.volume = 0.0000452661f * x4 - 0.0000870966f * x3 - 0.00251095f * x2 - 0.0336928f * x + 0.427456f } } } } 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()) 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( flags = PendingIntent.FLAG_UPDATE_CURRENT ) { putExtra("expandPlayerBottomSheet", true) }) .setDeleteIntent(broadCastPendingIntent()) .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?>(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) { Innertube.player(PlayerBody(videoId = videoId)) }?.mapCatching { body -> if (body.videoDetails?.videoId != videoId) { throw VideoIdMismatchException() } 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) } if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) { format.approxDurationMs?.div(1000) ?.let(DateUtils::formatElapsedTime)?.removePrefix("0") ?.let { durationText -> mediaItem?.mediaMetadata?.extras?.putString( "durationText", durationText ) Database.updateDurationText(videoId, durationText) } } 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() ?.plus(7), 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 mediaSession get() = this@PlayerService.mediaSession val sleepTimerMillisLeft: StateFlow? 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()) } } @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" } }