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.android.media)
|
||||
implementation(libs.exoplayer)
|
||||
|
||||
implementation(libs.room)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
@ -79,21 +80,10 @@
|
|||
android:name=".service.PlayerService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.media.session.MediaButtonReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.PlayerService$StopServiceBroadcastReceiver"
|
||||
android:name=".service.PlayerService$NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
package it.vfsfitvnm.vimusic
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.valentinilk.shimmer.LocalShimmerTheme
|
||||
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.rememberBottomSheetState
|
||||
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.IntentUriScreen
|
||||
import it.vfsfitvnm.vimusic.ui.styling.*
|
||||
|
@ -71,6 +78,7 @@ class MainActivity : ComponentActivity() {
|
|||
super.onStop()
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -138,6 +146,43 @@ class MainActivity : ComponentActivity() {
|
|||
.fillMaxSize()
|
||||
.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) {
|
||||
null -> {
|
||||
HomeScreen()
|
||||
|
|
|
@ -1,32 +1,21 @@
|
|||
package it.vfsfitvnm.vimusic.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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.media.session.MediaButtonReceiver
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
|
@ -60,28 +49,29 @@ import kotlinx.coroutines.*
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.system.exitProcess
|
||||
import android.os.Binder as AndroidBinder
|
||||
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var cache: SimpleCache
|
||||
private lateinit var player: ExoPlayer
|
||||
|
||||
private val stateBuilder = Builder()
|
||||
.setState(STATE_NONE, 0, 1f)
|
||||
private val stateBuilder = PlaybackState.Builder()
|
||||
.setActions(
|
||||
ACTION_PLAY
|
||||
or ACTION_PAUSE
|
||||
or ACTION_SKIP_TO_PREVIOUS
|
||||
or ACTION_SKIP_TO_NEXT
|
||||
or ACTION_PLAY_PAUSE
|
||||
or ACTION_SEEK_TO
|
||||
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 = MediaMetadataCompat.Builder()
|
||||
private val metadataBuilder = MediaMetadata.Builder()
|
||||
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private var notificationManager: NotificationManager? = null
|
||||
|
||||
private var timerJob: TimerJob? = null
|
||||
|
||||
|
@ -93,74 +83,19 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
|
||||
private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
|
||||
|
||||
private var hack: Hack? = null
|
||||
|
||||
private var isTaskRemoved = false
|
||||
|
||||
private var isVolumeNormalizationEnabled = 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()
|
||||
|
||||
override fun onBind(intent: Intent?): Binder {
|
||||
hack?.stop()
|
||||
hack = null
|
||||
private var isNotificationStarted = false
|
||||
|
||||
private lateinit var notificationActionReceiver: NotificationActionReceiver
|
||||
|
||||
override fun onBind(intent: Intent?): AndroidBinder {
|
||||
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() {
|
||||
super.onCreate()
|
||||
|
||||
|
@ -207,22 +142,30 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
|
||||
maybeRestorePlayerQueue()
|
||||
|
||||
mediaSession = MediaSessionCompat(baseContext, "PlayerService")
|
||||
mediaSession = MediaSession(baseContext, "PlayerService")
|
||||
mediaSession.setCallback(SessionCallback(player))
|
||||
mediaSession.setPlaybackState(stateBuilder.build())
|
||||
mediaSession.isActive = true
|
||||
mediaSession.controller.registerCallback(mediaControllerCallback)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
||||
return START_STICKY
|
||||
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?) {
|
||||
isTaskRemoved = true
|
||||
if (!player.playWhenReady) {
|
||||
stopSelf()
|
||||
if (isPersistentQueueEnabled) {
|
||||
broadCastPendingIntent<NotificationDismissReceiver>().send()
|
||||
} else {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
@ -235,14 +178,12 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
Context.MODE_PRIVATE
|
||||
).unregisterOnSharedPreferenceChangeListener(this)
|
||||
|
||||
hack?.stop()
|
||||
hack = null
|
||||
|
||||
player.removeListener(this)
|
||||
player.stop()
|
||||
player.release()
|
||||
|
||||
mediaSession.controller.unregisterCallback(mediaControllerCallback)
|
||||
unregisterReceiver(notificationActionReceiver)
|
||||
|
||||
mediaSession.isActive = false
|
||||
mediaSession.release()
|
||||
cache.release()
|
||||
|
@ -252,7 +193,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) {
|
||||
notificationManager.notify(NotificationId, notification())
|
||||
notificationManager?.notify(NotificationId, notification())
|
||||
}
|
||||
super.onConfigurationChanged(newConfig)
|
||||
}
|
||||
|
@ -304,29 +245,29 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
private fun maybeRestorePlayerQueue() {
|
||||
if (!isPersistentQueueEnabled) return
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
query {
|
||||
val queuedSong = Database.queue()
|
||||
Database.clearQueue()
|
||||
|
||||
if (queuedSong.isEmpty()) return@launch
|
||||
if (queuedSong.isEmpty()) return@query
|
||||
|
||||
val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
runBlocking(Dispatchers.Main) {
|
||||
player.setMediaItems(
|
||||
queuedSong
|
||||
.map { mediaItem ->
|
||||
mediaItem.mediaItem.buildUpon()
|
||||
.setUri(mediaItem.mediaItem.mediaId)
|
||||
.setCustomCacheKey(mediaItem.mediaItem.mediaId)
|
||||
.build()
|
||||
},
|
||||
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()
|
||||
|
||||
ContextCompat.startForegroundService(this@PlayerService, intent<PlayerService>())
|
||||
isNotificationStarted = true
|
||||
startForegroundService(this@PlayerService, intent<PlayerService>())
|
||||
startForeground(NotificationId, notification())
|
||||
}
|
||||
}
|
||||
|
@ -344,53 +285,64 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(@Player.State playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
if (player.duration != C.TIME_UNSET) {
|
||||
metadataBuilder
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_TITLE,
|
||||
player.currentMediaItem?.mediaMetadata?.title
|
||||
)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_ARTIST,
|
||||
player.currentMediaItem?.mediaMetadata?.artist
|
||||
)
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, player.duration)
|
||||
mediaSession.setMetadata(metadataBuilder.build())
|
||||
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 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
|
||||
)
|
||||
.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) {
|
||||
when (key) {
|
||||
Preferences.Keys.persistentQueue -> isPersistentQueueEnabled =
|
||||
|
@ -400,24 +352,21 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
}
|
||||
}
|
||||
|
||||
private fun notification(): Notification {
|
||||
fun NotificationCompat.Builder.addMediaAction(
|
||||
@DrawableRes resId: Int,
|
||||
description: String,
|
||||
@MediaKeyAction command: Long
|
||||
): NotificationCompat.Builder {
|
||||
return addAction(
|
||||
NotificationCompat.Action(
|
||||
resId,
|
||||
description,
|
||||
MediaButtonReceiver.buildMediaButtonPendingIntent(this@PlayerService, command)
|
||||
)
|
||||
)
|
||||
}
|
||||
private 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 = 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)
|
||||
.setContentText(mediaMetadata.artist)
|
||||
.setSubText(player.playerError?.message)
|
||||
|
@ -429,45 +378,46 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
?: R.drawable.app_icon)
|
||||
.setOngoing(false)
|
||||
.setContentIntent(activityPendingIntent<MainActivity>())
|
||||
.setDeleteIntent(broadCastPendingIntent<StopServiceBroadcastReceiver>())
|
||||
.setChannelId(NotificationChannelId)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setSilent(true)
|
||||
.setDeleteIntent(broadCastPendingIntent<NotificationDismissReceiver>())
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
Notification.MediaStyle()
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
.setMediaSession(mediaSession.sessionToken)
|
||||
)
|
||||
.addMediaAction(R.drawable.play_skip_back, "Skip back", ACTION_SKIP_TO_PREVIOUS)
|
||||
.addMediaAction(
|
||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) R.drawable.play else R.drawable.pause,
|
||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) "Play" else "Pause",
|
||||
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) ACTION_PLAY else ACTION_PAUSE
|
||||
.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
|
||||
)
|
||||
.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 ->
|
||||
notificationManager.notify(NotificationId, builder.setLargeIcon(bitmap).build())
|
||||
notificationManager?.notify(NotificationId, builder.setLargeIcon(bitmap).build())
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager = getSystemService()
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
with(notificationManager) {
|
||||
notificationManager?.run {
|
||||
if (getNotificationChannel(NotificationChannelId) == null) {
|
||||
createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NotificationChannelId,
|
||||
"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(
|
||||
SleepTimerNotificationChannelId,
|
||||
"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
|
||||
get() = this@PlayerService.player
|
||||
|
||||
|
@ -631,7 +585,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
.setSmallIcon(R.drawable.app_icon)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(SleepTimerNotificationId, notification)
|
||||
notificationManager?.notify(SleepTimerNotificationId, notification)
|
||||
|
||||
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 onPause() = player.pause()
|
||||
override fun onSkipToPrevious() = player.seekToPrevious()
|
||||
|
@ -685,48 +639,47 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
|
|||
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) {
|
||||
context.stopService(context.intent<PlayerService>())
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/q/53502244/16885569
|
||||
private inner class Hack : Runnable {
|
||||
private var isStarted = false
|
||||
private val intervalMs = 30_000L
|
||||
@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)
|
||||
)
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (!isStarted) {
|
||||
isStarted = true
|
||||
handler.postDelayed(this, intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NotificationId = 1001
|
||||
private const val NotificationChannelId = "default_channel_id"
|
||||
private companion object {
|
||||
const val NotificationId = 1001
|
||||
const val NotificationChannelId = "default_channel_id"
|
||||
|
||||
private const val SleepTimerNotificationId = 1002
|
||||
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
|
||||
const val SleepTimerNotificationId = 1002
|
||||
const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ val Timeline.mediaItems: List<MediaItem>
|
|||
getWindow(index, Timeline.Window()).mediaItem
|
||||
}
|
||||
|
||||
val Player.shouldBePlaying: Boolean
|
||||
get() = !(playbackState == Player.STATE_ENDED || !playWhenReady)
|
||||
|
||||
fun Player.forcePlay(mediaItem: MediaItem) {
|
||||
setMediaItem(mediaItem, true)
|
||||
|
|
|
@ -14,8 +14,6 @@ dependencyResolutionManagement {
|
|||
version("kotlin", "1.7.0")
|
||||
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", "1.3.0-alpha01")
|
||||
|
|
Loading…
Reference in a new issue