feat(parity): radio + picture Now Playing, immersive presentation (M13-B4)

- PlaybackController gains an explicit PlaybackSource (LIBRARY/RADIO) set by
  the radio screen; Now Playing renders station name/dial/live with no
  seekbar, queue, hearts or lyrics, plus a matching screensaver.
- Picture presentation: full-bleed pager with wrap-around, title/date caption,
  auto-hiding chrome (DoradoMotion), entered from both picture viewers; pinch
  zoom/double-tap untouched.
- Shell hides the MiniPlayer during full-bleed presentations via a graph
  immersive signal.
- Canon §4 + roadmap updated.
This commit is contained in:
John Smith
2026-09-11 15:26:31 -04:00
parent ef31872606
commit d28ea0ec0a
10 changed files with 563 additions and 95 deletions
@@ -52,7 +52,13 @@ class DoradoGraph(
val cloudUpdates: com.heretek.dorado_hd.cloud.CloudUpdateService,
val playCounts: com.heretek.dorado_hd.analysis.PlayCountStore,
val appState: com.heretek.dorado_hd.data.repo.AppStateRepository,
)
) {
/**
* True while a full-bleed presentation (picture viewer) owns the screen
* from a non-fullscreen route; the shell hides the MiniPlayer for it.
*/
val immersive = kotlinx.coroutines.flow.MutableStateFlow(false)
}
open class DoradoApp : Application() {
@@ -26,6 +26,14 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
/**
* Which presentation the current queue belongs to. `Radio.kt` marks its
* streams [RADIO] when it starts them; Now Playing uses that explicit signal
* to show the station identity and a live tag instead of the scrubber
* (device `GemNowPlayingRadioScene`). Every other surface stays [LIBRARY].
*/
enum class PlaybackSource { LIBRARY, RADIO }
/**
* App-side playback coordinator. Bridges the Compose UI to the Media3 session
* service and owns queue ordering (Zune-style shuffle), repeat, ratings and
@@ -74,6 +82,10 @@ class PlaybackController(
private val _currentRating = MutableStateFlow(Rating.NONE)
val currentRating: StateFlow<Rating> = _currentRating.asStateFlow()
/** Where the current queue came from (library vs. a live radio stream). */
private val _source = MutableStateFlow(PlaybackSource.LIBRARY)
val source: StateFlow<PlaybackSource> = _source.asStateFlow()
/** Sleep-timer countdown in ms; 0 when no timer is armed. */
private val _sleepRemainingMs = MutableStateFlow(0L)
val sleepRemainingMs: StateFlow<Long> = _sleepRemainingMs.asStateFlow()
@@ -276,10 +288,11 @@ class PlaybackController(
}
}
fun play(tracks: List<Track>, startIndex: Int = 0) {
fun play(tracks: List<Track>, startIndex: Int = 0, source: PlaybackSource = PlaybackSource.LIBRARY) {
if (tracks.isEmpty()) return
baseOrder = tracks
_queue.value = tracks
_source.value = source
val items = tracks.map { it.toMediaItem() }
val player = controller ?: return
player.setMediaItems(items, startIndex, 0L)
@@ -199,6 +199,7 @@ private fun MiniPlayer(canvasWidth: Dp) {
val controller = graph.controller
val nowPlaying by controller.nowPlaying.collectAsState()
val isPlaying by controller.isPlaying.collectAsState()
val immersive by graph.immersive.collectAsState()
val colors = LocalDoradoColors.current
// Canon §8: mini-apps and fullscreen viewers open without the MiniPlayer.
@@ -210,7 +211,7 @@ private fun MiniPlayer(canvasWidth: Dp) {
-> true
else -> false
}
if (fullscreenRoute || nowPlaying == null) return
if (fullscreenRoute || immersive || nowPlaying == null) return
Box(
Modifier
@@ -6,6 +6,9 @@ import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -46,6 +49,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
@@ -61,6 +65,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import coil3.compose.AsyncImage
import com.heretek.dorado_hd.design.LocalDoradoColors
import com.heretek.dorado_hd.design.Selawik
import com.heretek.dorado_hd.design.DoradoMotion
import com.heretek.dorado_hd.design.DoradoTokens
import com.heretek.dorado_hd.design.components.AlbumArt
import com.heretek.dorado_hd.design.components.CrossbarBar
@@ -71,6 +76,7 @@ import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.DetailScaffold
import com.heretek.dorado_hd.ui.nav.DoradoDestination
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -337,6 +343,12 @@ fun PicturesScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
var flat by remember { mutableStateOf<List<PictureItem>>(emptyList()) }
var selectedBucket by remember { mutableStateOf<PictureBucket?>(null) }
var viewerIndex by remember { mutableStateOf(0) }
var presenting by remember { mutableStateOf(false) }
// Hide the shell MiniPlayer while the full-bleed presentation is up.
DisposableEffect(presenting) {
graph.immersive.value = presenting
onDispose { graph.immersive.value = false }
}
val pagerState = androidx.compose.foundation.pager.rememberPagerState(initialPage = 0, pageCount = { 4 })
val pinned by graph.quickplay.pins().collectAsState(initial = emptyList())
val favoriteItems = remember(pinned, flat) {
@@ -398,6 +410,19 @@ fun PicturesScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
val pagerStateBucket = rememberPagerState(initialPage = viewerIndex.coerceIn(0, viewing.items.lastIndex), pageCount = { viewing.items.size })
// System Back closes the viewer instead of leaving Pictures entirely.
androidx.activity.compose.BackHandler { selectedBucket = null }
if (presenting) {
// Full-bleed presentation over the current bucket. Exiting puts the
// normal viewer back on the picture that was on screen.
PicturePresentation(
items = viewing.items,
initialIndex = pagerStateBucket.currentPage,
onExit = { finalIndex ->
scope.launch { pagerStateBucket.scrollToPage(finalIndex) }
presenting = false
},
)
return
}
Box(Modifier.fillMaxSize().background(LocalDoradoColors.current.background)) {
HorizontalPager(state = pagerStateBucket, modifier = Modifier.fillMaxSize()) { p ->
val item = viewing.items[p]
@@ -408,16 +433,30 @@ fun PicturesScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
)
}
Box(
Row(
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.clickable {
.padding(
end = DoradoTokens.EDGE.dp,
// Clear the MiniPlayer that floats over this route.
bottom = (DoradoTokens.EDGE + DoradoTokens.MINI_PLAYER_HEIGHT).dp,
),
horizontalArrangement = Arrangement.spacedBy(DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.clickable { presenting = true },
) {
EdgeCropText(text = "present", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
Box(
Modifier.clickable {
val item = viewing.items[pagerStateBucket.currentPage]
scope.launch { graph.quickplay.pin(com.heretek.dorado_hd.data.model.PinKind.PICTURE, item.id, item.displayName, item.uri.toString(), 0) }
},
) {
EdgeCropText(text = "pin", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
) {
EdgeCropText(text = "pin", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
}
Box(Modifier.align(Alignment.TopStart).padding(8.dp).clickable { selectedBucket = null }) {
EdgeCropText(text = "<- albums", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
@@ -550,6 +589,88 @@ private fun PictureListContent(pictures: List<PictureItem>, onPicture: (PictureI
)
}
/**
* Full-bleed picture presentation (device `GemNowPlayingPicturesScene`):
* fit-to-screen image, title/date caption in Zune typography that auto-hides,
* horizontal swipe within the current bucket (wrapping at either end), and
* tap or system back to exit. This is the presentation mode the zoom/pan
* viewer opens — it does not replace or duplicate that viewer.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PicturePresentation(
items: List<PictureItem>,
initialIndex: Int = 0,
onExit: (Int) -> Unit,
) {
if (items.isEmpty()) return
val colors = LocalDoradoColors.current
val count = items.size
val exit = rememberUpdatedState(onExit)
// Virtual pages let a swipe past either end wrap inside the bucket;
// wrapPictureIndex maps the pager's page back onto the item list.
val startPage = remember(count, initialIndex) {
val anchor = Int.MAX_VALUE / 2
anchor - (anchor % count) + wrapPictureIndex(initialIndex, count)
}
val pager = rememberPagerState(initialPage = startPage, pageCount = { Int.MAX_VALUE })
var chrome by remember { mutableStateOf(true) }
// Auto-hide the caption after the Now Playing idle dwell; each settled
// page brings it back for another few seconds.
LaunchedEffect(pager.settledPage) {
chrome = true
delay(DoradoTokens.IDLE_SCREENSAVER_MS)
chrome = false
}
androidx.activity.compose.BackHandler {
exit.value(wrapPictureIndex(pager.currentPage, count))
}
Box(Modifier.fillMaxSize().background(colors.background)) {
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
val item = items[wrapPictureIndex(page, count)]
AsyncImage(
model = item.uri,
contentDescription = item.displayName,
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
// Tap exits; the pager consumes drags, so swipes still page.
.pointerInput(Unit) {
detectTapGestures { exit.value(wrapPictureIndex(page, count)) }
},
)
}
AnimatedVisibility(
visible = chrome,
enter = fadeIn(DoradoMotion.pivot()),
exit = fadeOut(DoradoMotion.pivot()),
modifier = Modifier.align(Alignment.BottomStart),
) {
val caption = items[wrapPictureIndex(pager.currentPage, count)]
Column(
Modifier.padding(
start = DoradoTokens.EDGE.dp,
// Clear the MiniPlayer that floats over the Pictures route.
bottom = (DoradoTokens.EDGE + DoradoTokens.MINI_PLAYER_HEIGHT).dp,
),
) {
EdgeCropText(text = caption.displayName, fontSize = DoradoTokens.TYPE_LIST.dp)
pictureDateLabel(caption.dateTaken)?.let { date ->
EdgeCropText(
text = date,
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = colors.textSecondary,
alpha = 0.8f,
)
}
}
}
}
}
/* Internet */
/* ============================================================ */
@@ -748,6 +869,20 @@ fun SocialScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
fun PictureDetailScreen(uri: String) {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var presenting by remember { mutableStateOf(false) }
val item = remember(uri) {
PictureItem(
id = 0L,
displayName = uri.substringAfterLast('/').ifBlank { "picture" },
uri = android.net.Uri.parse(uri),
dateTaken = 0L,
)
}
if (presenting) {
PicturePresentation(items = listOf(item), initialIndex = 0, onExit = { presenting = false })
return
}
DetailScaffold(title = "picture") {
Box(Modifier.fillMaxSize().background(LocalDoradoColors.current.background)) {
@@ -795,6 +930,14 @@ fun PictureDetailScreen(uri: String) {
translationY = offset.y
},
)
Box(
Modifier
.align(Alignment.BottomEnd)
.padding(DoradoTokens.EDGE.dp)
.clickable { presenting = true },
) {
EdgeCropText(text = "present", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
}
}
}
@@ -105,6 +105,9 @@ fun NowPlayingScreen(canvasWidth: Dp) {
val shuffle by controller.shuffle.collectAsState()
val repeat by controller.repeat.collectAsState()
val rating by controller.currentRating.collectAsState()
val source by controller.source.collectAsState()
// Radio stations back the dial-frequency lookup for the station card.
val stations by graph.radio.stations().collectAsState(initial = emptyList())
var overlay by remember { mutableStateOf(false) }
var screensaver by remember { mutableStateOf(false) }
@@ -167,8 +170,11 @@ fun NowPlayingScreen(canvasWidth: Dp) {
return
}
val backdropFile by rememberArtistBackdropFile(current)
val washColors by rememberArtWash(current)
// Non-null when the active queue is a radio stream (B4).
val radio = radioNowPlayingMeta(source, current.title, current.mediaId, stations)
// Streams have no artist photography or album art to fetch.
val backdropFile by rememberArtistBackdropFile(current, enabled = radio == null)
val washColors by rememberArtWash(current, enabled = radio == null)
Box(
Modifier
@@ -212,31 +218,38 @@ fun NowPlayingScreen(canvasWidth: Dp) {
enter = fadeIn(DoradoMotion.pivot()),
exit = fadeOut(DoradoMotion.pivot()),
) {
Column(Modifier.fillMaxSize()) {
// Explicit back arrow, per the canon.
Row(
Modifier
.fillMaxWidth()
.padding(start = (DoradoTokens.EDGE - 13).coerceAtLeast(0).dp, top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
// Explicit back arrow, per the canon — shared by the track and
// radio presentations.
Row(
Modifier
.fillMaxWidth()
.padding(start = (DoradoTokens.EDGE - 13).coerceAtLeast(0).dp, top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(48.dp)
.clickable { graph.nav.pop() },
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(48.dp)
.clickable { graph.nav.pop() },
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = "back",
tint = colors.textPrimary.copy(alpha = 0.85f),
modifier = Modifier.size(22.dp),
)
}
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = "back",
tint = colors.textPrimary.copy(alpha = 0.85f),
modifier = Modifier.size(22.dp),
)
}
}
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(12.dp))
// Radio gets a dedicated presentation: station identity + live tag,
// no track chrome (device GemNowPlayingRadioScene / B4).
if (radio != null) {
RadioIdentity(radio)
return@AnimatedVisibility
}
Column(Modifier.fillMaxSize()) {
Row(
Modifier
.fillMaxWidth()
@@ -377,7 +390,7 @@ fun NowPlayingScreen(canvasWidth: Dp) {
)
},
) {
ScreensaverLayer(current, positionMs, durationMs, isPlaying)
ScreensaverLayer(current, positionMs, durationMs, isPlaying, radio)
}
}
@@ -401,6 +414,7 @@ fun NowPlayingScreen(canvasWidth: Dp) {
isPlaying = isPlaying,
positionMs = positionMs,
durationMs = durationMs,
isRadio = radio != null,
onDismiss = {
overlay = false
dim = false
@@ -411,6 +425,36 @@ fun NowPlayingScreen(canvasWidth: Dp) {
}
}
/**
* Radio identity card (device `GemNowPlayingRadioScene`): station name, its
* dial frequency when known, and the live tag. No art tile, no navigation to
* artist/album pages — those are track-only surfaces.
*/
@Composable
private fun RadioIdentity(meta: RadioNowPlayingMeta) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp),
) {
EdgeCropText(
text = meta.stationName,
fontSize = DoradoTokens.TYPE_NOW_TITLE.dp,
)
EdgeCropText(
text = meta.dialLine,
fontSize = DoradoTokens.TYPE_NOW_META.dp,
alpha = 0.85f,
)
EdgeCropText(
text = meta.live,
fontSize = DoradoTokens.TYPE_CAPTION.dp,
alpha = 0.6f,
modifier = Modifier.padding(top = 6.dp),
)
}
}
/** Artist photography with the Zune Ken-Burns drift, or a palette wash. */
@Composable
private fun BackdropLayer(
@@ -478,6 +522,7 @@ private fun ScreensaverLayer(
positionMs: Long,
durationMs: Long,
isPlaying: Boolean,
radio: RadioNowPlayingMeta?,
) {
val colors = LocalDoradoColors.current
val liveLevels = rememberLiveSpectrum(isPlaying, bars = 24)
@@ -498,23 +543,40 @@ private fun ScreensaverLayer(
.graphicsLayer { translationY = driftDp.dp.toPx() }
.padding(start = DoradoTokens.EDGE.dp, end = 80.dp),
) {
EdgeCropText(text = track.title, fontSize = DoradoTokens.TYPE_SAVER_TITLE.dp)
EdgeCropText(
text = track.artist,
fontSize = DoradoTokens.TYPE_SAVER_ARTIST.dp,
alpha = 0.9f,
)
EdgeCropText(
text = track.album,
fontSize = DoradoTokens.TYPE_SAVER_ALBUM.dp,
alpha = 0.7f,
)
EdgeCropText(
text = formatTime(durationMs),
fontSize = DoradoTokens.TYPE_CAPTION.dp,
alpha = 0.5f,
modifier = Modifier.padding(top = 8.dp),
text = radio?.stationName ?: track.title,
fontSize = DoradoTokens.TYPE_SAVER_TITLE.dp,
)
if (radio == null) {
EdgeCropText(
text = track.artist,
fontSize = DoradoTokens.TYPE_SAVER_ARTIST.dp,
alpha = 0.9f,
)
EdgeCropText(
text = track.album,
fontSize = DoradoTokens.TYPE_SAVER_ALBUM.dp,
alpha = 0.7f,
)
EdgeCropText(
text = formatTime(durationMs),
fontSize = DoradoTokens.TYPE_CAPTION.dp,
alpha = 0.5f,
modifier = Modifier.padding(top = 8.dp),
)
} else {
// Live stream: dial identity, then the live tag — no elapsed time.
EdgeCropText(
text = radio.dialLine,
fontSize = DoradoTokens.TYPE_SAVER_ARTIST.dp,
alpha = 0.9f,
)
EdgeCropText(
text = radio.live,
fontSize = DoradoTokens.TYPE_SAVER_ALBUM.dp,
alpha = 0.7f,
)
}
}
SpectrumVisualizer(
isPlaying = isPlaying,
@@ -524,15 +586,18 @@ private fun ScreensaverLayer(
.fillMaxWidth()
.padding(start = DoradoTokens.EDGE.dp, end = 84.dp, bottom = DoradoTokens.EDGE.dp),
)
AlbumArt(
model = track.albumArtUri,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(DoradoTokens.EDGE.dp)
.size(56.dp)
.graphicsLayer { alpha = 0.92f },
)
// Streams carry no album art; the station identity is the whole card.
if (radio == null) {
AlbumArt(
model = track.albumArtUri,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(DoradoTokens.EDGE.dp)
.size(56.dp)
.graphicsLayer { alpha = 0.92f },
)
}
}
}
@@ -545,6 +610,7 @@ private fun TransportOverlay(
isPlaying: Boolean,
positionMs: Long,
durationMs: Long,
isRadio: Boolean,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
@@ -696,16 +762,19 @@ private fun TransportOverlay(
)
}
// Showlist / queue (top-right) — the device's GemQueueList.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = (DoradoTokens.EDGE - 13).coerceAtLeast(0).dp, top = 2.dp)
.size(48.dp)
.clickable { showQueue = true },
contentAlignment = Alignment.Center,
) {
OverlayGlyph(text = "\u2261")
// Showlist / queue (top-right) — the device's GemQueueList. A radio
// stream has no meaningful queue, so the glyph is hidden.
if (!isRadio) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = (DoradoTokens.EDGE - 13).coerceAtLeast(0).dp, top = 2.dp)
.size(48.dp)
.clickable { showQueue = true },
contentAlignment = Alignment.Center,
) {
OverlayGlyph(text = "\u2261")
}
}
// Share (device GemLibrarySendCompose) — system share sheet.
@@ -732,24 +801,40 @@ private fun TransportOverlay(
)
}
// Scrubber + elapsed/remaining (device HUD ProgressSlider).
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 4.dp),
) {
SeekBar(
positionMs = positionMs,
durationMs = durationMs,
onSeek = { controller.seekTo(it) },
// Scrubber + elapsed/remaining (device HUD ProgressSlider). A live
// stream has no seekable position: show the live tag instead (device
// HudMediaControllerRadioScene).
if (isRadio) {
androidx.compose.foundation.text.BasicText(
text = "live",
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_NOW_META.sp,
color = colors.accent,
),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = (DoradoTokens.EDGE / 2).dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
} else {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 4.dp),
) {
TimeLabel(formatTime(positionMs))
TimeLabel("-" + formatTime((durationMs - positionMs).coerceAtLeast(0)))
SeekBar(
positionMs = positionMs,
durationMs = durationMs,
onSeek = { controller.seekTo(it) },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TimeLabel(formatTime(positionMs))
TimeLabel("-" + formatTime((durationMs - positionMs).coerceAtLeast(0)))
}
}
}
}
@@ -975,26 +1060,37 @@ private fun OverlayGlyph(text: String) {
)
}
/** Cached artist background photography fetch. */
/** Cached artist background photography fetch; [enabled] is false for radio. */
@Composable
private fun rememberArtistBackdropFile(track: Track): androidx.compose.runtime.State<File?> {
private fun rememberArtistBackdropFile(track: Track, enabled: Boolean): androidx.compose.runtime.State<File?> {
val graph = LocalDoradoGraph.current
val file = androidx.compose.runtime.produceState<File?>(initialValue = null, key1 = track.artist) {
val enabled = graph.artistImages.settingsSnapshot?.artistImagesEnabled ?: true
if (!enabled) {
value = null
} else {
value = graph.artistImages.backgroundFor(track.artist)
}
val file = androidx.compose.runtime.produceState<File?>(
initialValue = null,
key1 = track.artist,
key2 = enabled,
) {
val imagesEnabled = graph.artistImages.settingsSnapshot?.artistImagesEnabled ?: true
value = if (!enabled || !imagesEnabled) null else graph.artistImages.backgroundFor(track.artist)
}
return file
}
/** Palette wash extracted from the album art when no photo is available. */
/** Palette wash extracted from the album art; [enabled] is false for radio. */
@Composable
private fun rememberArtWash(track: Track): androidx.compose.runtime.State<Pair<Color, Color>?> {
private fun rememberArtWash(
track: Track,
enabled: Boolean,
): androidx.compose.runtime.State<Pair<Color, Color>?> {
val context = LocalContext.current
return androidx.compose.runtime.produceState<Pair<Color, Color>?>(initialValue = null, key1 = track.albumId) {
return androidx.compose.runtime.produceState<Pair<Color, Color>?>(
initialValue = null,
key1 = track.albumId,
key2 = enabled,
) {
if (!enabled) {
value = null
return@produceState
}
value = withContext(Dispatchers.IO) {
try {
val bitmap: Bitmap? = context.contentResolver.openInputStream(track.albumArtUri)?.use { input ->
@@ -0,0 +1,32 @@
package com.heretek.dorado_hd.ui.screens
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Wraps a picture-presentation index into `0 until count`, so a swipe past
* either end of the current bucket continues at the other end. Returns 0 for
* an empty bucket.
*/
fun wrapPictureIndex(index: Int, count: Int): Int {
if (count <= 0) return 0
val mod = index % count
return if (mod < 0) mod + count else mod
}
/**
* Zune-style caption date ("September 12, 2009"); null when the picture has no
* usable `DATE_TAKEN` (MediaStore reports 0 for unknown).
*/
fun pictureDateLabel(
dateTaken: Long,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.US,
): String? {
if (dateTaken <= 0L) return null
return DateTimeFormatter.ofPattern("MMMM d, yyyy", locale)
.withZone(zone)
.format(Instant.ofEpochMilli(dateTaken))
}
@@ -37,6 +37,7 @@ import com.heretek.dorado_hd.design.DoradoTokens
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.design.components.KineticList
import com.heretek.dorado_hd.design.components.firstLetterOf
import com.heretek.dorado_hd.media.PlaybackSource
import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.DetailScaffold
import com.heretek.dorado_hd.ui.components.LocalContextMenu
@@ -256,5 +257,7 @@ private fun playStation(graph: com.heretek.dorado_hd.DoradoGraph, station: Radio
year = "",
uri = android.net.Uri.parse(station.streamUrl),
)
graph.controller.play(listOf(track))
// Explicitly mark the queue as radio so Now Playing switches to the
// station presentation instead of the track one (B4).
graph.controller.play(listOf(track), source = PlaybackSource.RADIO)
}
@@ -0,0 +1,45 @@
package com.heretek.dorado_hd.ui.screens
import com.heretek.dorado_hd.data.db.RadioStationEntity
import com.heretek.dorado_hd.media.PlaybackSource
/**
* The dedicated radio Now Playing presentation (device
* `GemNowPlayingRadioScene`): station identity and a live tag instead of a
* seek position. Queue, heart rating, shuffle/repeat and lyrics are track-only
* chrome and stay hidden.
*/
data class RadioNowPlayingMeta(
val stationName: String,
/** "98.7 MHz" when the station carries a dial frequency; null when not. */
val dialLabel: String?,
val live: String = "live",
) {
/** Dial line shown in the radio presentation ("stream" when dial-less). */
val dialLine: String get() = dialLabel ?: "stream"
}
/**
* Resolves the radio presentation for [source]; null means "not radio".
*
* Detection is explicit: `Radio.kt` marks its queue [PlaybackSource.RADIO]
* when it starts a stream (see `PlaybackController.play`), so the unified
* Now Playing never has to guess from the track's URI or dummy album fields.
* Stations are looked up by `mediaId` to recover the dial frequency, which the
* device showed alongside the station name.
*/
fun radioNowPlayingMeta(
source: PlaybackSource,
currentTitle: String?,
currentMediaId: Long?,
stations: List<RadioStationEntity>,
): RadioNowPlayingMeta? {
if (source != PlaybackSource.RADIO || currentTitle == null) return null
val station = currentMediaId?.let { id -> stations.firstOrNull { it.id == id } }
return RadioNowPlayingMeta(
stationName = station?.name ?: currentTitle,
dialLabel = station?.frequencyKhz
?.takeIf { it > 0 }
?.let { formatDial(it) },
)
}
@@ -0,0 +1,55 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.ui.screens.pictureDateLabel
import com.heretek.dorado_hd.ui.screens.wrapPictureIndex
import java.time.LocalDate
import java.time.ZoneId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* B4 picture presentation: bucket navigation wraps at either end and the
* caption date is omitted when MediaStore has no DATE_TAKEN.
*/
class PicturePresentationLogicTest {
@Test
fun `index stays inside a populated bucket`() {
assertEquals(0, wrapPictureIndex(0, 3))
assertEquals(2, wrapPictureIndex(2, 3))
}
@Test
fun `index wraps forward past the end`() {
assertEquals(0, wrapPictureIndex(3, 3))
assertEquals(1, wrapPictureIndex(7, 3))
}
@Test
fun `index wraps backward past the start`() {
assertEquals(2, wrapPictureIndex(-1, 3))
assertEquals(1, wrapPictureIndex(-8, 3))
}
@Test
fun `empty or invalid bucket maps to zero`() {
assertEquals(0, wrapPictureIndex(5, 0))
assertEquals(0, wrapPictureIndex(-5, -2))
}
@Test
fun `unknown date has no caption label`() {
assertNull(pictureDateLabel(0L))
assertNull(pictureDateLabel(-1L))
}
@Test
fun `date label is month day year`() {
val millis = LocalDate.of(2009, 9, 12)
.atStartOfDay(ZoneId.of("UTC"))
.toInstant()
.toEpochMilli()
assertEquals("September 12, 2009", pictureDateLabel(millis, ZoneId.of("UTC")))
}
}
@@ -0,0 +1,74 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.db.RadioStationEntity
import com.heretek.dorado_hd.media.PlaybackSource
import com.heretek.dorado_hd.ui.screens.radioNowPlayingMeta
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* B4 radio Now Playing: the presentation is selected by the explicit
* [PlaybackSource] that `Radio.kt` sets, not by guessing from track fields.
*/
class RadioNowPlayingTest {
private fun station(id: Long, name: String, freqKhz: Int) = RadioStationEntity(
id = id,
name = name,
frequencyKhz = freqKhz,
streamUrl = "https://example.test/stream",
isPreset = false,
lastPlayedAt = 0L,
)
@Test
fun `library playback is not radio`() {
assertNull(
radioNowPlayingMeta(
PlaybackSource.LIBRARY,
"a song",
1L,
listOf(station(1L, "fm one", 98_700)),
),
)
}
@Test
fun `radio requires a current track`() {
assertNull(radioNowPlayingMeta(PlaybackSource.RADIO, null, null, emptyList()))
}
@Test
fun `radio shows station name, dial and live tag`() {
val meta = radioNowPlayingMeta(
PlaybackSource.RADIO,
"fallback name",
7L,
listOf(station(7L, "the zone", 98_700)),
)
assertEquals("the zone", meta?.stationName)
assertEquals("98.7 MHz", meta?.dialLabel)
assertEquals("98.7 MHz", meta?.dialLine)
assertEquals("live", meta?.live)
}
@Test
fun `stream-only station shows stream instead of a dial`() {
val meta = radioNowPlayingMeta(
PlaybackSource.RADIO,
"web radio",
9L,
listOf(station(9L, "web radio", 0)),
)
assertNull(meta?.dialLabel)
assertEquals("stream", meta?.dialLine)
}
@Test
fun `unknown station falls back to the track title`() {
val meta = radioNowPlayingMeta(PlaybackSource.RADIO, "pinned stream", 42L, emptyList())
assertEquals("pinned stream", meta?.stationName)
assertNull(meta?.dialLabel)
}
}