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 package com.fabledsword.minstrel
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -33,10 +39,46 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() 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( private fun App(
seedCache: DetailSeedCache, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(), gate: AuthGateViewModel = hiltViewModel(),
) { ) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle() val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) { MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalDetailSeedCache provides seedCache, LocalDetailSeedCache provides seedCache,
@@ -65,6 +110,18 @@ private fun App(
// `ShellScaffold` wrap; full-screen routes (NowPlaying / // `ShellScaffold` wrap; full-screen routes (NowPlaying /
// Queue / unauthenticated) bypass the shell entirely. // Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController() 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( MinstrelNavGraph(
navController = navController, navController = navController,
startDestination = resolved, startDestination = resolved,
@@ -1,9 +1,11 @@
package com.fabledsword.minstrel.player package com.fabledsword.minstrel.player
import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.fabledsword.minstrel.MainActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@@ -38,7 +40,36 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val player = playerFactory.build() 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? = override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
@@ -61,4 +92,11 @@ class MinstrelPlayerService : MediaSessionService() {
mediaSession = null mediaSession = null
super.onDestroy() 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
}
} }