feat(android): a title page built around one Play button

- the backdrop runs edge to edge and fades into the page, with the back
  button over it
- one full-width Play that names the episode it plays ("Resume S02E01")
- Start over, Download, Match and the provider link as one row of
  labelled icons instead of four buttons in three styles over three lines
- the synopsis is clamped to three lines and opens on a tap; the watched
  count joins the year, rating and genres
- seasons are chips showing one season at a time, opened on the season
  Play lands in, instead of stacked accordions
- episode rows head with "1. Mother and Children", or "Episode 1", rather
  than the filename; numbering, size and air date sit under it, and the
  resume bar lies on the still's lower edge
- the per-title track form is gone: the player's track chooser already
  stores each title's choice, and Settings holds the global default
This commit is contained in:
nvrl committed 2026-10-03 04:53:59 +02:00
1 parent 1c8df80c27
commit eee45bc8f8
3 files changed
+328 -323

No files matched your search

@@ -7,7 +7,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -24,7 +23,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -37,11 +35,8 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -72,10 +67,16 @@ import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
internal fun TitleScreen(
@@ -89,28 +90,11 @@ internal fun TitleScreen(
padding: PaddingValues,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var audioLanguage by remember(title.key) { mutableStateOf("") }
var subtitleLanguage by remember(title.key) { mutableStateOf("") }
var subtitlesEnabled by remember(title.key) { mutableStateOf(false) }
var showMatch by remember(title.key) { mutableStateOf(false) }
var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) }
var overviewOpen by remember(title.key) { mutableStateOf(false) }
BackHandler(onBack = onBack)
LaunchedEffect(title.key) {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().titleTrackPreferences(title.key)
}
}.onSuccess { preferences ->
// Absent means this title has never been given a choice; the fields
// are then empty and the global preference is what plays.
audioLanguage = preferences?.audioLanguage.orEmpty()
subtitleLanguage = preferences?.subtitleLanguage.orEmpty()
subtitlesEnabled = preferences?.subtitles ?: true
}.onFailure(onPreferenceError)
}
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
@@ -122,6 +106,17 @@ internal fun TitleScreen(
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
}
val upNext = playlist.getOrNull(nextUp)
// One season on screen at a time, opened on the one Play would land in: a
// forty-episode show as stacked sections is one long scroll to the part
// that matters.
var seasonIndex by remember(title.key) {
mutableStateOf(
title.seasons.indexOfFirst { season -> season.episodes.any { it.linkId == upNext?.linkId } }
.coerceAtLeast(0),
)
}
val season = title.seasons.getOrNull(seasonIndex)
// Live download state per episode, so a row can show progress and be paused
// without a trip to the Downloads tab.
val work by WorkManager.getInstance(context)
@@ -130,197 +125,169 @@ internal fun TitleScreen(
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
QuietButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Back to library")
item(key = "backdrop") {
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to MaterialTheme.colorScheme.background,
),
),
)
IconButton(
onClick = onBack,
modifier = Modifier
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp),
fallback = title.thumbnailSource,
)
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp))
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "%.1f/10".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "),
).joinToString(" • "),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 6.dp),
)
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) }
// Wrapping, not squeezing: a fixed row hands the last button the
// width the others left over, which is how "More on AniList" ended
// up set one letter per line on a phone.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
// What a press on the poster should play: whatever was left
// part-watched, else the first unwatched episode, else the
// first — the desktop client's `next_up`.
AccentButton(onClick = { onPlay(playlist, nextUp) }, enabled = playerReady && playlist.isNotEmpty()) {
}
item(key = "heading") {
Column(Modifier.padding(horizontal = 16.dp)) {
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
// episode of forty it meant.
AccentButton(
onClick = { onPlay(playlist, nextUp) },
enabled = playerReady && playlist.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
when {
!playerReady -> "Player loading…"
playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume"
title.watchedCount > 0uL -> "Continue"
else -> "Play"
upNext == null -> "Play"
upNext.resumeAt != null -> "Resume ${upNext.label}"
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
playlist.size == 1 -> "Play"
else -> "Play ${upNext.label}"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Only where it would do something different: an unstarted show
// already starts at the beginning.
if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) {
TonalButton(
onClick = { onPlay(playlist, 0) },
enabled = playerReady && playlist.isNotEmpty(),
) { Text("Start over") }
}
// The rest in the same flow, and all the same kind of button:
// one filled primary for the thing the page is for, tonal for
// everything else. A text link among filled buttons was what
// made this block read as three unrelated rows of controls.
TonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Download show")
}
TonalButton(onClick = { showMatch = true }) {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Change match")
}
// The provider's own page for this title: where a viewer goes to
// check that the thing the app matched is the thing they have.
title.externalUrl?.let { url ->
EdgedButton(onClick = {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}) { Text("More on ${title.metadataProvider?.displayName() ?: "the provider"}") }
}
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp))
Text("Preferred tracks", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
audioLanguage,
{ audioLanguage = it },
label = { Text("Audio language") },
modifier = Modifier.weight(1f),
singleLine = true,
)
OutlinedTextField(
subtitleLanguage,
{ subtitleLanguage = it },
label = { Text("Subtitle language") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
SettingToggle("Enable subtitles", subtitlesEnabled) { subtitlesEnabled = it }
TonalButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().setTitleTrackPreferences(
title.key,
TrackPreferencesRecord(
audioLanguage.takeIf(String::isNotBlank),
subtitleLanguage.takeIf(String::isNotBlank),
subtitlesEnabled,
),
)
// Everything else as labelled icons in one row: four actions of
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
onPlay(playlist, 0)
}
}.onFailure(onPreferenceError)
}
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist)
}
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes
// to check that the thing the app matched is what they have.
title.externalUrl?.let { url ->
TitleAction(
Icons.AutoMirrored.Filled.OpenInNew,
title.metadataProvider?.displayName() ?: "Provider",
Modifier.weight(1f),
) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}
}
}
}) { Text("Save track preferences") }
title.overview?.let { overview ->
// Three lines, then a tap for the rest: a synopsis with its
// source notes ran to a screen and a half on a phone.
Text(
overview.trim(),
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 12.dp)
.clickable { overviewOpen = !overviewOpen },
)
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
title.seasons.forEach { season ->
val isExpanded = expandedSeasons.contains(season.label)
item(key = "season/${season.label}") {
// A header, not a row with a button parked on the end of it: the
// label and its count are the only things that grow, and the one
// action is an icon of fixed width, so every season header down
// the page starts and ends in the same place.
Row(
Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable {
expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label
}
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand",
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
season.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val watched = season.episodes.count(EpisodeRecord::watched)
Text(
"${season.episodes.size} " +
(if (season.episodes.size == 1) "episode" else "episodes") +
if (watched > 0) " · $watched watched" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
item(key = "seasons") {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) {
Icon(
Icons.Default.Download,
contentDescription = "Download ${season.label}",
)
}
season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
}
}
}
if (isExpanded) {
itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode ->
EpisodeRow(
episode = episode,
numbering = episode.numbering(season.number, position),
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
}
}
season?.let { shown ->
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
EpisodeRow(
episode = episode,
numbering = episode.numbering(shown.number, position),
position = position,
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
}
}
}
@@ -334,6 +301,33 @@ internal fun TitleScreen(
}
}
/** One of the title's secondary actions: an icon with its name under it. */
@Composable
private fun TitleAction(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Column(
modifier
.clip(MaterialTheme.shapes.medium)
.clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(icon, contentDescription = null)
Text(
label,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
/**
* `S03E38`, `E38`, or the row's place in the season.
*
@@ -357,17 +351,15 @@ private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): Strin
/**
* One episode: play it, see where it was left, and drive its download.
*
* The row *is* the play button — the still, the numbering and the name are one
* The row *is* the play button — the still, the number and the name are one
* target, which is what lets the trailing controls be two icons in a fixed
* column rather than a second row of buttons under every episode. Both were
* needed while the row carried a full-width `Play`: it pushed the download and
* watched controls into whatever width was left, and forty of those down a
* season read as forty differently-arranged rows.
* column rather than a second row of buttons under every episode.
*/
@Composable
private fun EpisodeRow(
episode: EpisodeRecord,
numbering: String,
position: Int,
download: RetainedDownload?,
playerReady: Boolean,
onPlay: () -> Unit,
@@ -378,135 +370,141 @@ private fun EpisodeRow(
) {
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED
// The provider's name for the episode when there is one: "The Cave of
// Skulls" says more than the filename it was parsed out of. The filename
// stays underneath it, and is the whole answer when there is no provider.
val name = episode.providerName ?: episode.detail
val detail = episode.detail.takeIf { it != name }
Card(
val heading = episode.heading(position)
Column(
Modifier
.fillMaxWidth()
.clickable(enabled = playerReady, onClick = onPlay),
.clickable(enabled = playerReady, onClick = onPlay)
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Column(Modifier.padding(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own
// thumbnail is a frame of this very episode.
Box(
Modifier
.width(96.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small),
contentAlignment = Alignment.Center,
) {
RemoteArtwork(
episode.stillUrl,
name,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
)
// Over the still rather than beside it: the row already
// plays on tap, so this says so without spending a column.
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.padding(4.dp),
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
numbering,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
name,
fontWeight = FontWeight.SemiBold,
// Seen episodes stay legible but stop competing with the
// one the viewer has not watched yet.
color = if (episode.watched) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Watched is a judgement the viewer is allowed to overrule: a
// half-watched episode they are done with, or one the 90 % rule
// marked seen because they sat through the credits.
IconButton(onClick = { onSetWatched(!episode.watched) }) {
Icon(
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
tint = if (episode.watched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
)
}
// One slot, always the same width, whatever state the download
// is in — including "there is nothing to say", where an empty
// box keeps the rows above and below it aligned.
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
when {
episode.offline -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Saved offline",
tint = MaterialTheme.colorScheme.tertiary,
)
running -> IconButton(onClick = { onPause(download!!) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
download != null -> IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.Download, contentDescription = "Resume download")
}
else -> IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, contentDescription = "Download")
}
}
}
}
// Where the episode was left, under the row it belongs to.
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own
// thumbnail is a frame of this very episode. Where it was left sits
// on the still's lower edge, the way every streaming app shows it.
Box(
Modifier
.width(128.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small),
contentAlignment = Alignment.Center,
) {
RemoteArtwork(
episode.stillUrl,
heading,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
}
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
AccentProgress(
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
fill = solid(MaterialTheme.colorScheme.tertiary),
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
// Seen episodes stay legible but stop competing with the
// one the viewer has not watched yet.
color = if (episode.watched) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " +
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
listOfNotNull(
numbering,
episode.size?.let(::formatBytes),
episode.airDate,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
// Watched is a judgement the viewer is allowed to overrule: a
// half-watched episode they are done with, or one the 90 % rule
// marked seen because they sat through the credits.
IconButton(onClick = { onSetWatched(!episode.watched) }) {
Icon(
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
tint = if (episode.watched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
)
}
// One slot, always the same width, whatever state the download
// is in — including "there is nothing to say", where an empty
// box keeps the rows above and below it aligned.
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
when {
episode.offline -> Icon(
Icons.Default.DownloadDone,
contentDescription = "Saved offline",
tint = MaterialTheme.colorScheme.tertiary,
)
running -> IconButton(onClick = { onPause(download!!) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
download != null -> IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.Download, contentDescription = "Resume download")
}
else -> IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, contentDescription = "Download")
}
}
}
}
episode.providerOverview?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
}
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
AccentProgress(
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
fill = solid(MaterialTheme.colorScheme.tertiary),
)
Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " +
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* What an episode row is headed with: "1. Mother and Children".
*
* The provider's name for the episode, else the name the filename gave it,
* else "Episode 1". The bare filename only when none of those exist — a column
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
private fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
named != null -> named
number != null -> "Episode $number"
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
@@ -104,13 +104,20 @@ class ScreenshotTest {
}
private companion object {
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null) = EpisodeRecord(
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
// `detail` is the filename unless the filename named the episode,
// which is what the bridge sends.
val file = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt())
return episodeRecord(season, number, file, watched, progress)
}
fun episodeRecord(season: UInt, number: UInt, file: String, watched: Boolean, progress: Double?) = EpisodeRecord(
shareId = "share",
volumeId = "volume",
linkId = "s${season}e$number",
name = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt()),
name = file,
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
detail = "1.2 GiB",
detail = file,
season = season,
number = number,
size = 1_300_000_000uL,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 160 KiB