Discover artwork + Library icon + notification tap routing #73

Merged
bvandeusen merged 5 commits from dev into main 2026-06-02 09:58:10 -04:00
2 changed files with 97 additions and 2 deletions
Showing only changes of commit 438e81a117 - Show all commits
@@ -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
}
}