Rewrite PlayerService
This commit is contained in:
parent
32aeadf87d
commit
abd942d5da
6 changed files with 229 additions and 242 deletions
|
@ -85,7 +85,6 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.accompanist.systemuicontroller)
|
implementation(libs.accompanist.systemuicontroller)
|
||||||
|
|
||||||
implementation(libs.android.media)
|
|
||||||
implementation(libs.exoplayer)
|
implementation(libs.exoplayer)
|
||||||
|
|
||||||
implementation(libs.room)
|
implementation(libs.room)
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
@ -79,21 +80,10 @@
|
||||||
android:name=".service.PlayerService"
|
android:name=".service.PlayerService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback">
|
android:foregroundServiceType="mediaPlayback">
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="androidx.media.session.MediaButtonReceiver"
|
android:name=".service.PlayerService$NotificationDismissReceiver"
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".service.PlayerService$StopServiceBroadcastReceiver"
|
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
package it.vfsfitvnm.vimusic
|
package it.vfsfitvnm.vimusic
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
@ -22,11 +27,12 @@ import androidx.compose.material.ripple.RippleAlpha
|
||||||
import androidx.compose.material.ripple.RippleTheme
|
import androidx.compose.material.ripple.RippleTheme
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.core.content.getSystemService
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||||
import com.valentinilk.shimmer.LocalShimmerTheme
|
import com.valentinilk.shimmer.LocalShimmerTheme
|
||||||
import com.valentinilk.shimmer.defaultShimmerTheme
|
import com.valentinilk.shimmer.defaultShimmerTheme
|
||||||
|
@ -35,6 +41,7 @@ import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu
|
||||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||||
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
|
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
|
||||||
import it.vfsfitvnm.vimusic.ui.components.rememberMenuState
|
import it.vfsfitvnm.vimusic.ui.components.rememberMenuState
|
||||||
|
import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog
|
||||||
import it.vfsfitvnm.vimusic.ui.screens.HomeScreen
|
import it.vfsfitvnm.vimusic.ui.screens.HomeScreen
|
||||||
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen
|
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen
|
||||||
import it.vfsfitvnm.vimusic.ui.styling.*
|
import it.vfsfitvnm.vimusic.ui.styling.*
|
||||||
|
@ -71,6 +78,7 @@ class MainActivity : ComponentActivity() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -138,6 +146,43 @@ class MainActivity : ComponentActivity() {
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colorPalette.background)
|
.background(colorPalette.background)
|
||||||
) {
|
) {
|
||||||
|
var isIgnoringBatteryOptimizations by rememberSaveable {
|
||||||
|
mutableStateOf(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(packageName) ?: true
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isIgnoringBatteryOptimizations) {
|
||||||
|
ConfirmationDialog(
|
||||||
|
text = "(Temporary) ViMusic needs to ignore battery optimizations to avoid being killed when the playback is paused.",
|
||||||
|
confirmText = "Grant",
|
||||||
|
onDismiss = {
|
||||||
|
isIgnoringBatteryOptimizations = true
|
||||||
|
},
|
||||||
|
onConfirm = {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return@ConfirmationDialog
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.resolveActivity(packageManager) != null) {
|
||||||
|
startActivity(intent)
|
||||||
|
} else {
|
||||||
|
val fallbackIntent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||||
|
|
||||||
|
if (fallbackIntent.resolveActivity(packageManager) != null) {
|
||||||
|
startActivity(fallbackIntent)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this@MainActivity, "Couldn't find battery optimization settings, please whitelist ViMusic manually", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
when (val uri = uri) {
|
when (val uri = uri) {
|
||||||
null -> {
|
null -> {
|
||||||
HomeScreen()
|
HomeScreen()
|
||||||
|
|
|
@ -1,32 +1,21 @@
|
||||||
package it.vfsfitvnm.vimusic.service
|
package it.vfsfitvnm.vimusic.service
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.*
|
||||||
import android.app.NotificationChannel
|
import android.content.*
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.media.MediaMetadata
|
||||||
|
import android.media.session.MediaSession
|
||||||
|
import android.media.session.PlaybackState
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
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.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat.startForegroundService
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media.session.MediaButtonReceiver
|
|
||||||
import androidx.media3.common.*
|
import androidx.media3.common.*
|
||||||
import androidx.media3.database.StandaloneDatabaseProvider
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
|
@ -60,28 +49,29 @@ import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
import android.os.Binder as AndroidBinder
|
||||||
|
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback,
|
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
private lateinit var mediaSession: MediaSession
|
||||||
private lateinit var cache: SimpleCache
|
private lateinit var cache: SimpleCache
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
|
|
||||||
private val stateBuilder = Builder()
|
private val stateBuilder = PlaybackState.Builder()
|
||||||
.setState(STATE_NONE, 0, 1f)
|
|
||||||
.setActions(
|
.setActions(
|
||||||
ACTION_PLAY
|
PlaybackState.ACTION_PLAY
|
||||||
or ACTION_PAUSE
|
or PlaybackState.ACTION_PAUSE
|
||||||
or ACTION_SKIP_TO_PREVIOUS
|
or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||||
or ACTION_SKIP_TO_NEXT
|
or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||||
or ACTION_PLAY_PAUSE
|
or PlaybackState.ACTION_PLAY_PAUSE
|
||||||
or ACTION_SEEK_TO
|
or PlaybackState.ACTION_SEEK_TO
|
||||||
)
|
)
|
||||||
|
|
||||||
private val metadataBuilder = MediaMetadataCompat.Builder()
|
private val metadataBuilder = MediaMetadata.Builder()
|
||||||
|
|
||||||
private lateinit var notificationManager: NotificationManager
|
private var notificationManager: NotificationManager? = null
|
||||||
|
|
||||||
private var timerJob: TimerJob? = null
|
private var timerJob: TimerJob? = null
|
||||||
|
|
||||||
|
@ -93,74 +83,19 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
|
|
||||||
private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
|
private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
|
||||||
|
|
||||||
private var hack: Hack? = null
|
|
||||||
|
|
||||||
private var isTaskRemoved = false
|
|
||||||
|
|
||||||
private var isVolumeNormalizationEnabled = false
|
private var isVolumeNormalizationEnabled = false
|
||||||
private var isPersistentQueueEnabled = false
|
private var isPersistentQueueEnabled = false
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
|
|
||||||
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
|
||||||
when (state?.state) {
|
|
||||||
STATE_PLAYING -> {
|
|
||||||
ContextCompat.startForegroundService(
|
|
||||||
this@PlayerService,
|
|
||||||
intent<PlayerService>()
|
|
||||||
)
|
|
||||||
startForeground(NotificationId, notification())
|
|
||||||
|
|
||||||
hack?.stop()
|
|
||||||
}
|
|
||||||
STATE_PAUSED -> {
|
|
||||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) {
|
|
||||||
if (isPersistentQueueEnabled) {
|
|
||||||
if (isTaskRemoved) {
|
|
||||||
stopForeground(false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stopForeground(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationManager.notify(NotificationId, notification())
|
|
||||||
|
|
||||||
hack?.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
STATE_NONE -> {
|
|
||||||
onPlaybackStateChanged(Player.STATE_READY)
|
|
||||||
onIsPlayingChanged(player.playWhenReady)
|
|
||||||
}
|
|
||||||
STATE_ERROR -> {
|
|
||||||
notificationManager.notify(NotificationId, notification())
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val binder = Binder()
|
private val binder = Binder()
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): Binder {
|
private var isNotificationStarted = false
|
||||||
hack?.stop()
|
|
||||||
hack = null
|
private lateinit var notificationActionReceiver: NotificationActionReceiver
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): AndroidBinder {
|
||||||
return binder
|
return binder
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRebind(intent: Intent?) {
|
|
||||||
isTaskRemoved = false
|
|
||||||
hack?.stop()
|
|
||||||
hack = null
|
|
||||||
super.onRebind(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUnbind(intent: Intent?): Boolean {
|
|
||||||
hack = Hack()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
@ -207,22 +142,30 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
|
|
||||||
maybeRestorePlayerQueue()
|
maybeRestorePlayerQueue()
|
||||||
|
|
||||||
mediaSession = MediaSessionCompat(baseContext, "PlayerService")
|
mediaSession = MediaSession(baseContext, "PlayerService")
|
||||||
mediaSession.setCallback(SessionCallback(player))
|
mediaSession.setCallback(SessionCallback(player))
|
||||||
mediaSession.setPlaybackState(stateBuilder.build())
|
mediaSession.setPlaybackState(stateBuilder.build())
|
||||||
mediaSession.isActive = true
|
mediaSession.isActive = true
|
||||||
mediaSession.controller.registerCallback(mediaControllerCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
notificationActionReceiver = NotificationActionReceiver(player)
|
||||||
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
|
||||||
return START_STICKY
|
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?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
isTaskRemoved = true
|
|
||||||
if (!player.playWhenReady) {
|
if (!player.playWhenReady) {
|
||||||
stopSelf()
|
if (isPersistentQueueEnabled) {
|
||||||
|
broadCastPendingIntent<NotificationDismissReceiver>().send()
|
||||||
|
} else {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
super.onTaskRemoved(rootIntent)
|
super.onTaskRemoved(rootIntent)
|
||||||
}
|
}
|
||||||
|
@ -235,14 +178,12 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
Context.MODE_PRIVATE
|
Context.MODE_PRIVATE
|
||||||
).unregisterOnSharedPreferenceChangeListener(this)
|
).unregisterOnSharedPreferenceChangeListener(this)
|
||||||
|
|
||||||
hack?.stop()
|
|
||||||
hack = null
|
|
||||||
|
|
||||||
player.removeListener(this)
|
player.removeListener(this)
|
||||||
player.stop()
|
player.stop()
|
||||||
player.release()
|
player.release()
|
||||||
|
|
||||||
mediaSession.controller.unregisterCallback(mediaControllerCallback)
|
unregisterReceiver(notificationActionReceiver)
|
||||||
|
|
||||||
mediaSession.isActive = false
|
mediaSession.isActive = false
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
cache.release()
|
cache.release()
|
||||||
|
@ -252,7 +193,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) {
|
if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) {
|
||||||
notificationManager.notify(NotificationId, notification())
|
notificationManager?.notify(NotificationId, notification())
|
||||||
}
|
}
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
}
|
}
|
||||||
|
@ -304,29 +245,29 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
private fun maybeRestorePlayerQueue() {
|
private fun maybeRestorePlayerQueue() {
|
||||||
if (!isPersistentQueueEnabled) return
|
if (!isPersistentQueueEnabled) return
|
||||||
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
query {
|
||||||
val queuedSong = Database.queue()
|
val queuedSong = Database.queue()
|
||||||
Database.clearQueue()
|
Database.clearQueue()
|
||||||
|
|
||||||
if (queuedSong.isEmpty()) return@launch
|
if (queuedSong.isEmpty()) return@query
|
||||||
|
|
||||||
val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0)
|
val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
runBlocking(Dispatchers.Main) {
|
||||||
player.setMediaItems(
|
player.setMediaItems(
|
||||||
queuedSong
|
queuedSong.map { mediaItem ->
|
||||||
.map { mediaItem ->
|
mediaItem.mediaItem.buildUpon()
|
||||||
mediaItem.mediaItem.buildUpon()
|
.setUri(mediaItem.mediaItem.mediaId)
|
||||||
.setUri(mediaItem.mediaItem.mediaId)
|
.setCustomCacheKey(mediaItem.mediaItem.mediaId)
|
||||||
.setCustomCacheKey(mediaItem.mediaItem.mediaId)
|
.build()
|
||||||
.build()
|
},
|
||||||
},
|
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
player.seekTo(index, queuedSong[index].position ?: 0)
|
player.seekTo(index, queuedSong[index].position ?: 0)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
|
|
||||||
ContextCompat.startForegroundService(this@PlayerService, intent<PlayerService>())
|
isNotificationStarted = true
|
||||||
|
startForegroundService(this@PlayerService, intent<PlayerService>())
|
||||||
startForeground(NotificationId, notification())
|
startForeground(NotificationId, notification())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -344,53 +285,64 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(@Player.State playbackState: Int) {
|
private val Player.androidPlaybackState: Int
|
||||||
if (playbackState == Player.STATE_READY) {
|
get() = when (playbackState) {
|
||||||
if (player.duration != C.TIME_UNSET) {
|
Player.STATE_BUFFERING -> if (playWhenReady) PlaybackState.STATE_BUFFERING else PlaybackState.STATE_PAUSED
|
||||||
metadataBuilder
|
Player.STATE_READY -> if (playWhenReady) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED
|
||||||
.putText(
|
Player.STATE_ENDED -> PlaybackState.STATE_STOPPED
|
||||||
MediaMetadataCompat.METADATA_KEY_TITLE,
|
Player.STATE_IDLE -> PlaybackState.STATE_NONE
|
||||||
player.currentMediaItem?.mediaMetadata?.title
|
else -> PlaybackState.STATE_NONE
|
||||||
)
|
}
|
||||||
.putText(
|
|
||||||
MediaMetadataCompat.METADATA_KEY_ARTIST,
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
player.currentMediaItem?.mediaMetadata?.artist
|
if (player.duration != C.TIME_UNSET) {
|
||||||
)
|
metadataBuilder
|
||||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, player.duration)
|
.putText(
|
||||||
mediaSession.setMetadata(metadataBuilder.build())
|
MediaMetadata.METADATA_KEY_TITLE,
|
||||||
|
player.currentMediaItem?.mediaMetadata?.title
|
||||||
|
)
|
||||||
|
.putText(
|
||||||
|
MediaMetadata.METADATA_KEY_ARTIST,
|
||||||
|
player.currentMediaItem?.mediaMetadata?.artist
|
||||||
|
)
|
||||||
|
.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) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.shouldBePlaying && !isNotificationStarted) {
|
||||||
|
isNotificationStarted = true
|
||||||
|
startForegroundService(this@PlayerService, intent<PlayerService>())
|
||||||
|
startForeground(NotificationId, notification())
|
||||||
|
} else {
|
||||||
|
if (!player.shouldBePlaying) {
|
||||||
|
isNotificationStarted = false
|
||||||
|
stopForeground(false)
|
||||||
|
}
|
||||||
|
notificationManager?.notify(NotificationId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerErrorChanged(error: PlaybackException?) {
|
|
||||||
if (error != null) {
|
|
||||||
stateBuilder
|
|
||||||
.setState(STATE_ERROR, player.currentPosition, 1f)
|
|
||||||
|
|
||||||
mediaSession.setPlaybackState(stateBuilder.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())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||||
when (key) {
|
when (key) {
|
||||||
Preferences.Keys.persistentQueue -> isPersistentQueueEnabled =
|
Preferences.Keys.persistentQueue -> isPersistentQueueEnabled =
|
||||||
|
@ -400,24 +352,21 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notification(): Notification {
|
private fun notification(): Notification? {
|
||||||
fun NotificationCompat.Builder.addMediaAction(
|
if (player.currentMediaItem == null) return null
|
||||||
@DrawableRes resId: Int,
|
|
||||||
description: String,
|
val playIntent = Action.play.pendingIntent
|
||||||
@MediaKeyAction command: Long
|
val pauseIntent = Action.pause.pendingIntent
|
||||||
): NotificationCompat.Builder {
|
val nextIntent = Action.next.pendingIntent
|
||||||
return addAction(
|
val prevIntent = Action.previous.pendingIntent
|
||||||
NotificationCompat.Action(
|
|
||||||
resId,
|
|
||||||
description,
|
|
||||||
MediaButtonReceiver.buildMediaButtonPendingIntent(this@PlayerService, command)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaMetadata = player.mediaMetadata
|
val mediaMetadata = player.mediaMetadata
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId)
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(applicationContext, NotificationChannelId)
|
||||||
|
} else {
|
||||||
|
Notification.Builder(applicationContext)
|
||||||
|
}
|
||||||
.setContentTitle(mediaMetadata.title)
|
.setContentTitle(mediaMetadata.title)
|
||||||
.setContentText(mediaMetadata.artist)
|
.setContentText(mediaMetadata.artist)
|
||||||
.setSubText(player.playerError?.message)
|
.setSubText(player.playerError?.message)
|
||||||
|
@ -429,45 +378,46 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
?: R.drawable.app_icon)
|
?: R.drawable.app_icon)
|
||||||
.setOngoing(false)
|
.setOngoing(false)
|
||||||
.setContentIntent(activityPendingIntent<MainActivity>())
|
.setContentIntent(activityPendingIntent<MainActivity>())
|
||||||
.setDeleteIntent(broadCastPendingIntent<StopServiceBroadcastReceiver>())
|
.setDeleteIntent(broadCastPendingIntent<NotificationDismissReceiver>())
|
||||||
.setChannelId(NotificationChannelId)
|
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
|
||||||
.setSilent(true)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
androidx.media.app.NotificationCompat.MediaStyle()
|
Notification.MediaStyle()
|
||||||
.setShowActionsInCompactView(0, 1, 2)
|
.setShowActionsInCompactView(0, 1, 2)
|
||||||
.setMediaSession(mediaSession.sessionToken)
|
.setMediaSession(mediaSession.sessionToken)
|
||||||
)
|
)
|
||||||
.addMediaAction(R.drawable.play_skip_back, "Skip back", ACTION_SKIP_TO_PREVIOUS)
|
.addAction(R.drawable.play_skip_back, "Skip back", prevIntent)
|
||||||
.addMediaAction(
|
.addAction(
|
||||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) R.drawable.play else R.drawable.pause,
|
if (player.shouldBePlaying) R.drawable.pause else R.drawable.play,
|
||||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) "Play" else "Pause",
|
if (player.shouldBePlaying) "Pause" else "Play",
|
||||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) ACTION_PLAY else ACTION_PAUSE
|
if (player.shouldBePlaying) pauseIntent else playIntent
|
||||||
)
|
)
|
||||||
.addMediaAction(R.drawable.play_skip_forward, "Skip forward", ACTION_SKIP_TO_NEXT)
|
.addAction(R.drawable.play_skip_forward, "Skip forward", nextIntent)
|
||||||
|
|
||||||
bitmapProvider.load(mediaMetadata.artworkUri) { bitmap ->
|
bitmapProvider.load(mediaMetadata.artworkUri) { bitmap ->
|
||||||
notificationManager.notify(NotificationId, builder.setLargeIcon(bitmap).build())
|
notificationManager?.notify(NotificationId, builder.setLargeIcon(bitmap).build())
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
notificationManager = getSystemService()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
|
||||||
with(notificationManager) {
|
notificationManager?.run {
|
||||||
if (getNotificationChannel(NotificationChannelId) == null) {
|
if (getNotificationChannel(NotificationChannelId) == null) {
|
||||||
createNotificationChannel(
|
createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
NotificationChannelId,
|
NotificationChannelId,
|
||||||
"Now playing",
|
"Now playing",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
NotificationManager.IMPORTANCE_LOW
|
||||||
)
|
).apply {
|
||||||
|
setSound(null, null)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -476,8 +426,12 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
SleepTimerNotificationChannelId,
|
SleepTimerNotificationChannelId,
|
||||||
"Sleep timer",
|
"Sleep timer",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
NotificationManager.IMPORTANCE_LOW
|
||||||
)
|
).apply {
|
||||||
|
setSound(null, null)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -602,7 +556,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class Binder : android.os.Binder() {
|
inner class Binder : AndroidBinder() {
|
||||||
val player: ExoPlayer
|
val player: ExoPlayer
|
||||||
get() = this@PlayerService.player
|
get() = this@PlayerService.player
|
||||||
|
|
||||||
|
@ -631,7 +585,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
.setSmallIcon(R.drawable.app_icon)
|
.setSmallIcon(R.drawable.app_icon)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
notificationManager.notify(SleepTimerNotificationId, notification)
|
notificationManager?.notify(SleepTimerNotificationId, notification)
|
||||||
|
|
||||||
exitProcess(0)
|
exitProcess(0)
|
||||||
}
|
}
|
||||||
|
@ -677,7 +631,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class SessionCallback(private val player: Player) : MediaSessionCompat.Callback() {
|
private class SessionCallback(private val player: Player) : MediaSession.Callback() {
|
||||||
override fun onPlay() = player.play()
|
override fun onPlay() = player.play()
|
||||||
override fun onPause() = player.pause()
|
override fun onPause() = player.pause()
|
||||||
override fun onSkipToPrevious() = player.seekToPrevious()
|
override fun onSkipToPrevious() = player.seekToPrevious()
|
||||||
|
@ -685,48 +639,47 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
||||||
override fun onSeekTo(pos: Long) = player.seekTo(pos)
|
override fun onSeekTo(pos: Long) = player.seekTo(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
class StopServiceBroadcastReceiver : BroadcastReceiver() {
|
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.seekToNext()
|
||||||
|
Action.previous.value -> player.seekToPrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationDismissReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
context.stopService(context.intent<PlayerService>())
|
context.stopService(context.intent<PlayerService>())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/q/53502244/16885569
|
@JvmInline
|
||||||
private inner class Hack : Runnable {
|
private value class Action(val value: String) {
|
||||||
private var isStarted = false
|
context(Context)
|
||||||
private val intervalMs = 30_000L
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
@Synchronized
|
companion object {
|
||||||
fun start() {
|
val pause = Action("it.vfsfitvnm.vimusic.pause")
|
||||||
if (!isStarted) {
|
val play = Action("it.vfsfitvnm.vimusic.play")
|
||||||
isStarted = true
|
val next = Action("it.vfsfitvnm.vimusic.next")
|
||||||
handler.postDelayed(this, intervalMs)
|
val previous = Action("it.vfsfitvnm.vimusic.previous")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun stop() {
|
|
||||||
if (isStarted) {
|
|
||||||
handler.removeCallbacks(this)
|
|
||||||
isStarted = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun run() {
|
|
||||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) {
|
|
||||||
startForeground(NotificationId, notification())
|
|
||||||
stopForeground(false)
|
|
||||||
handler.postDelayed(this, intervalMs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val NotificationId = 1001
|
const val NotificationId = 1001
|
||||||
private const val NotificationChannelId = "default_channel_id"
|
const val NotificationChannelId = "default_channel_id"
|
||||||
|
|
||||||
private const val SleepTimerNotificationId = 1002
|
const val SleepTimerNotificationId = 1002
|
||||||
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
|
const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ val Timeline.mediaItems: List<MediaItem>
|
||||||
getWindow(index, Timeline.Window()).mediaItem
|
getWindow(index, Timeline.Window()).mediaItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val Player.shouldBePlaying: Boolean
|
||||||
|
get() = !(playbackState == Player.STATE_ENDED || !playWhenReady)
|
||||||
|
|
||||||
fun Player.forcePlay(mediaItem: MediaItem) {
|
fun Player.forcePlay(mediaItem: MediaItem) {
|
||||||
setMediaItem(mediaItem, true)
|
setMediaItem(mediaItem, true)
|
||||||
|
|
|
@ -14,8 +14,6 @@ dependencyResolutionManagement {
|
||||||
version("kotlin", "1.7.0")
|
version("kotlin", "1.7.0")
|
||||||
plugin("kotlin-serialization","org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
|
plugin("kotlin-serialization","org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
|
||||||
|
|
||||||
library("android-media", "androidx.media", "media").version("1.6.0")
|
|
||||||
|
|
||||||
version("compose-compiler", "1.2.0")
|
version("compose-compiler", "1.2.0")
|
||||||
|
|
||||||
version("compose", "1.3.0-alpha01")
|
version("compose", "1.3.0-alpha01")
|
||||||
|
|
Loading…
Reference in a new issue