Files
memby/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt
T
2026-08-23 18:21:18 +12:00

381 lines
15 KiB
Kotlin

package com.ponzischeme89.memby.ui
import android.text.format.DateFormat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.mark
import java.util.Date
import kotlinx.coroutines.delay
/**
* Everything a resumable-media card needs to render, with no screen or repository
* dependency. The adapter from [BaseItem] is deliberately separate so another Memby
* surface can supply the same contract from cached or locally produced media data.
*/
data class ResumableMediaCardModel(
val id: String,
val title: String,
val episodeName: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val playbackPositionTicks: Long? = null,
val runtimeTicks: Long? = null,
val backdropUrl: String? = null,
val primaryUrl: String? = null,
val played: Boolean = false,
val favourite: Boolean = false,
val isNextUp: Boolean = false,
) {
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
val showProgressTrack: Boolean get() = progress > 0f || isNextUp
val nextUpLabel: String? get() = "Next up".takeIf { isNextUp }
}
internal fun BaseItem.toResumableMediaCardModel(
backdropUrl: String?,
primaryUrl: String?,
): ResumableMediaCardModel {
val episodeName = name.trim().takeIf(String::isNotEmpty)
val seriesTitle = seriesName?.trim().orEmpty()
val playbackPosition = userData?.playbackPositionTicks
val played = userData?.played == true
return ResumableMediaCardModel(
id = id,
title = if (isEpisode) {
seriesTitle.ifEmpty { episodeName ?: "Episode" }
} else {
name.trim().ifEmpty { "Unknown title" }
},
episodeName = episodeName.takeIf { isEpisode && it != seriesTitle },
seasonNumber = parentIndexNumber.takeIf { isEpisode },
episodeNumber = indexNumber.takeIf { isEpisode },
playbackPositionTicks = playbackPosition,
runtimeTicks = runTimeTicks,
backdropUrl = backdropUrl,
primaryUrl = primaryUrl,
played = played,
favourite = isFavorite,
// This adapter is used by the Continue Watching card. Within that merged row, an
// unplayed episode with no playhead is the Next Up half; resumable episodes have a
// positive playhead and films are never supplied by Emby's Next Up feed.
isNextUp = isNextUpInContinueWatching(),
)
}
/** The Next Up half of Memby's merged Continue Watching row. */
internal fun BaseItem.isNextUpInContinueWatching(): Boolean =
isEpisode && userData?.played != true && (userData?.playbackPositionTicks ?: 0L) <= 0L
internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
val cleanName = name?.trim().orEmpty()
val code = if (season != null && season >= 0 && episode != null && episode >= 0) {
"S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
} else {
null
}
return when {
code != null && cleanName.isNotEmpty() -> "$code · $cleanName"
code != null -> code
cleanName.isNotEmpty() -> cleanName
else -> null
}
}
internal fun resumableProgress(positionTicks: Long?, runtimeTicks: Long?): Float {
val runtime = runtimeTicks ?: return 0f
if (runtime <= 0L) return 0f
return ((positionTicks ?: 0L).coerceAtLeast(0L).toDouble() / runtime.toDouble())
.coerceIn(0.0, 1.0)
.toFloat()
}
/** The local-clock instant at which this title would finish if resumed now. */
internal fun expectedFinishEpochMillis(
nowEpochMillis: Long,
positionTicks: Long?,
runtimeTicks: Long?,
): Long? {
val runtime = runtimeTicks ?: return null
if (runtime <= 0L) return null
val position = (positionTicks ?: 0L).coerceIn(0L, runtime)
val remainingMillis = (runtime - position) / TICKS_PER_MILLISECOND
if (remainingMillis <= 0L) return null
return if (Long.MAX_VALUE - nowEpochMillis < remainingMillis) {
Long.MAX_VALUE
} else {
nowEpochMillis + remainingMillis
}
}
/**
* A self-contained card for anything that can be resumed.
*
* The component owns the progress, episode identity and expected-finish presentation;
* callers own placement, artwork shape, actions and optional content beneath the title.
*/
@Composable
fun ResumableMediaCard(
model: ResumableMediaCardModel,
width: Dp,
portraitArtwork: Boolean,
modifier: Modifier = Modifier,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null,
) {
val context = LocalContext.current
val density = LocalDensity.current
val artworkUrl = if (portraitArtwork) {
model.primaryUrl ?: model.backdropUrl
} else {
model.backdropUrl ?: model.primaryUrl
}
val usesPrimaryArtwork = artworkUrl != null && artworkUrl == model.primaryUrl
val nowEpochMillis by produceState(initialValue = System.currentTimeMillis()) {
while (true) {
val now = System.currentTimeMillis()
value = now
delay(MINUTE_MILLIS - now % MINUTE_MILLIS)
}
}
val finishAt = remember(
nowEpochMillis,
model.isNextUp,
model.playbackPositionTicks,
model.runtimeTicks,
) {
if (model.isNextUp) {
null
} else {
expectedFinishEpochMillis(
nowEpochMillis = nowEpochMillis,
positionTicks = model.playbackPositionTicks,
runtimeTicks = model.runtimeTicks,
)
}
}
val endsAt = remember(finishAt, context) {
finishAt?.let {
val localTime = DateFormat.getTimeFormat(context)
.format(Date(it))
.replace("am", "AM", ignoreCase = true)
.replace("pm", "PM", ignoreCase = true)
"Ends at: $localTime"
}
}
val progressLabel = model.nextUpLabel ?: endsAt
val description = remember(model, progressLabel) {
listOfNotNull(
model.title.takeIf(String::isNotBlank),
model.episodeLabel,
progressLabel,
model.progress.takeIf { it > 0f }?.let { "${(it * 100).toInt()} percent watched" },
).joinToString(", ")
}
val aspectRatio = if (portraitArtwork) 2f / 3f else 16f / 9f
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1)
var failed by remember(model.id, artworkUrl) { mutableStateOf(false) }
var loading by remember(model.id, artworkUrl) { mutableStateOf(artworkUrl != null) }
val imageRequest = remember(artworkUrl, widthPx, heightPx, context) {
artworkUrl?.let {
ImageRequest.Builder(context)
.data(it)
// Continue Watching is the busiest Home row. Avoid decoding the server's
// full artwork when the card is only a few hundred pixels wide.
.size(widthPx, heightPx)
.allowHardware(true)
.crossfade(false)
.build()
}
}
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
onLongClick = onLongClick,
contentDescription = description,
modifier = modifier.width(width),
) { focused ->
Column {
Box(
modifier = Modifier
.width(width)
.aspectRatio(aspectRatio)
.shadow(
elevation = if (focused) 7.dp else 0.dp,
shape = RoundedCornerShape(MembyCardCorner),
)
.clip(RoundedCornerShape(MembyCardCorner))
.background(MembySurfaceRaised)
.border(
width = 2.dp,
color = if (focused) Color.White else Color.White.copy(alpha = 0.07f),
shape = RoundedCornerShape(MembyCardCorner),
),
contentAlignment = Alignment.Center,
) {
if (loading) ArtworkLoadingSkeleton(Modifier.fillMaxSize())
if (imageRequest != null) {
AsyncImage(
model = imageRequest,
contentDescription = null,
contentScale = if (usesPrimaryArtwork) ContentScale.Fit else ContentScale.Crop,
onLoading = { loading = true },
onSuccess = {
failed = false
loading = false
},
onError = {
failed = true
loading = false
},
modifier = Modifier.fillMaxSize(),
)
}
if (artworkUrl == null || failed) {
Icon(
MembyIcon.BrokenImage.mark,
contentDescription = "Artwork unavailable",
tint = MembyQuietText,
modifier = Modifier.size(30.dp),
)
}
if (model.showProgressTrack) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(5.dp)
.background(Color.Black.copy(alpha = 0.65f)),
) {
if (model.progress > 0f) {
Box(
Modifier
.fillMaxWidth(model.progress)
.height(5.dp)
.background(MembyAccent),
)
}
}
}
if (model.played || model.favourite) {
Row(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (model.played) {
ResumableStatusIcon(MembyIcon.CheckCircle.mark, "Watched", MembyAccent)
}
if (model.favourite) {
ResumableStatusIcon(
MembyIcon.Favourite.mark,
"Favourite",
Color(0xFFFF6B81),
)
}
}
}
if (focused) MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
Text(
text = model.title,
color = if (focused) Color.White else MembyOnSurface,
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
)
titleSupplement?.invoke(focused)
model.episodeLabel?.let { label ->
Text(
text = label,
color = MembyMutedText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
progressLabel?.let { label ->
Text(
text = label,
color = MembyQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
}
}
}
@Composable
private fun ResumableStatusIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
tint: Color,
) {
Box(
modifier = Modifier
.size(29.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.78f))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = description, tint = tint, modifier = Modifier.size(18.dp))
}
}
private const val TICKS_PER_MILLISECOND = 10_000L
private const val MINUTE_MILLIS = 60_000L