feat(android): media-notification tap opens NowPlaying
android / Build + lint + test (push) Failing after 1m32s
android / Build + lint + test (push) Failing after 1m32s
Tapping the system media notification previously landed on whatever shell route MainActivity last rendered (Home / Library / Search) because MinstrelPlayerService never configured the session-activity PendingIntent, so Media3 defaulted to the launcher activity entry point. Operator request: tap should go straight to the full player. MinstrelPlayerService.onCreate now builds a PendingIntent targeting MainActivity with an EXTRA_OPEN_NOW_PLAYING flag and passes it to MediaSession.Builder.setSessionActivity. The flag also covers the lock-screen card and the Pixel Watch tile — both use the same session-activity PendingIntent. MainActivity reads the extra in onCreate AND onNewIntent (so a warm app gets the navigation too, not just cold launches), flips a pendingOpenNowPlaying StateFlow, then strips the extra so a config-change recreation doesn't re-trigger. The App composable observes the flag and runs a LaunchedEffect to navigate once the NavHost is mounted — handles both cold start (BootSplash → resolved → navigate) and warm start. launchSingleTop avoids stacking copies if NowPlaying is already on top, and the onOpenedNowPlaying callback clears the flag post-navigation so later recompositions don't re-fire. Divergence from Flutter (intentional): audio_service's default notification tap behavior just opens the launcher activity at whatever screen it was on — exactly the behavior the operator asked to improve.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,10 +24,14 @@ import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.nav.NowPlaying
|
||||
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -33,10 +39,46 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
// composable observes this, navigates to NowPlaying once the
|
||||
// NavHost is ready, then calls back to reset the flag so the
|
||||
// navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
setContent {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||
pendingOpenNowPlaying.value = true
|
||||
// Strip the extra so a subsequent config-change recreation
|
||||
// doesn't re-trigger the navigation.
|
||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** PendingIntent extra set by [com.fabledsword.minstrel.player.MinstrelPlayerService]
|
||||
* so a media-notification tap lands on the full NowPlaying screen
|
||||
* instead of whatever shell route MainActivity last rendered. */
|
||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +86,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
@@ -65,6 +110,18 @@ private fun App(
|
||||
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
// Honour a pending notification-tap once the NavHost is
|
||||
// mounted. launchSingleTop avoids stacking copies of
|
||||
// NowPlaying if the user taps the notification while
|
||||
// already on it; the callback clears the flag so a later
|
||||
// recomposition (config change, theme switch) doesn't
|
||||
// re-navigate.
|
||||
LaunchedEffect(pending, navController) {
|
||||
if (pending) {
|
||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||
onOpenedNowPlaying()
|
||||
}
|
||||
}
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
startDestination = resolved,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -38,7 +40,36 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player = playerFactory.build()
|
||||
mediaSession = MediaSession.Builder(this, player).build()
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(buildNowPlayingPendingIntent())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent that the media notification / lock-screen card / Pixel
|
||||
* Watch tile launches when tapped. Carries an extra MainActivity
|
||||
* consumes to navigate the NavController straight to the full
|
||||
* NowPlaying screen — without it, Media3 defaults to the launcher
|
||||
* activity entry point, which lands on whatever shell route
|
||||
* MainActivity last rendered.
|
||||
*
|
||||
* FLAG_ACTIVITY_SINGLE_TOP keeps the existing MainActivity instance
|
||||
* alive when one is already foregrounded; onNewIntent fires and
|
||||
* picks up the fresh extra. FLAG_IMMUTABLE is required on
|
||||
* Android 12+; FLAG_UPDATE_CURRENT keeps the extra in sync if the
|
||||
* Builder rebuilds the PendingIntent.
|
||||
*/
|
||||
private fun buildNowPlayingPendingIntent(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, true)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
REQUEST_OPEN_NOW_PLAYING,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
||||
@@ -61,4 +92,11 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
mediaSession = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Stable request code for the session-activity PendingIntent.
|
||||
// Arbitrary but distinct from any other PendingIntent we ever
|
||||
// build against MainActivity.
|
||||
const val REQUEST_OPEN_NOW_PLAYING = 0x100
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user