Rewrite PlayerService

This commit is contained in:
vfsfitvnm 2022-07-11 10:47:40 +02:00
parent 32aeadf87d
commit abd942d5da
6 changed files with 229 additions and 242 deletions

View file

@ -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)

View file

@ -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>

View file

@ -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()

View file

@ -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"
} }
} }

View file

@ -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)

View file

@ -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")