mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-12 02:21:19 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user