Discover artwork + Library icon + notification tap routing #73
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user