From 438e81a117f768a9c9269347e15c32b4fd561e52 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 09:43:45 -0400 Subject: [PATCH] feat(android): media-notification tap opens NowPlaying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/fabledsword/minstrel/MainActivity.kt | 59 ++++++++++++++++++- .../minstrel/player/MinstrelPlayerService.kt | 40 ++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index cda6d5dc..f1203b98 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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, + 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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index fba943f6..bc9cbba0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -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 + } }