381 lines
15 KiB
Kotlin
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
|