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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.itemsIndexed
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text 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.Edit
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow 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.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.TonalButton
import io.narl.protonstream.ui.theme.solid import io.narl.protonstream.ui.theme.solid
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord 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 @Composable
internal fun TitleScreen( internal fun TitleScreen(
@@ -89,28 +90,11 @@ internal fun TitleScreen(
padding: PaddingValues, padding: PaddingValues,
) { ) {
val context = LocalContext.current 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 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) 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 // Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses. // the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
@@ -122,6 +106,17 @@ internal fun TitleScreen(
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 } ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 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 // Live download state per episode, so a row can show progress and be paused
// without a trip to the Downloads tab. // without a trip to the Downloads tab.
val work by WorkManager.getInstance(context) val work by WorkManager.getInstance(context)
@@ -130,197 +125,169 @@ internal fun TitleScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item { item(key = "backdrop") {
QuietButton(onClick = onBack) { // Edge to edge, fading into the page: the art is the first thing
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) // the page says, and a framed thumbnail with a margin round it read
Spacer(Modifier.width(8.dp)) // as one card among the many below it.
Text("Back to library") 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, item(key = "heading") {
title.canonicalName ?: title.name, Column(Modifier.padding(horizontal = 16.dp)) {
Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp), Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
fallback = title.thumbnailSource, title.originalName?.takeIf { it != title.canonicalName }?.let {
) Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp)) }
title.originalName?.takeIf { it != title.canonicalName }?.let { Text(
Text(it, style = MaterialTheme.typography.bodyMedium) listOfNotNull(
} (title.metadataYear ?: title.year)?.toString(),
Text( title.rating?.let { "★ %.1f".format(it) },
listOfNotNull( title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
(title.metadataYear ?: title.year)?.toString(), "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
title.rating?.let { "%.1f/10".format(it) }, ).joinToString(" · "),
title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "), style = MaterialTheme.typography.bodySmall,
).joinToString(" • "), color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 6.dp),
modifier = Modifier.padding(top = 6.dp), )
) // The one thing the page is for, full width and naming what it
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) } // will play: "Resume" alone left the viewer guessing which
// Wrapping, not squeezing: a fixed row hands the last button the // episode of forty it meant.
// width the others left over, which is how "More on AniList" ended AccentButton(
// up set one letter per line on a phone. onClick = { onPlay(playlist, nextUp) },
FlowRow( enabled = playerReady && playlist.isNotEmpty(),
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), ) {
modifier = Modifier.fillMaxWidth().padding(top = 12.dp), Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
) { Spacer(Modifier.width(8.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()) {
Text( Text(
when { when {
!playerReady -> "Player loading…" !playerReady -> "Player loading…"
playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume" upNext == null -> "Play"
title.watchedCount > 0uL -> "Continue" upNext.resumeAt != null -> "Resume ${upNext.label}"
else -> "Play" 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 // Everything else as labelled icons in one row: four actions of
// already starts at the beginning. // three button styles used to wrap over three lines and bury
if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) { // the episodes under them.
TonalButton( Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
onClick = { onPlay(playlist, 0) }, // Only where it would do something different: an unstarted
enabled = playerReady && playlist.isNotEmpty(), // show already starts at the beginning.
) { Text("Start over") } if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
} TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
// The rest in the same flow, and all the same kind of button: onPlay(playlist, 0)
// 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,
),
)
} }
}.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 -> item(key = "seasons") {
val isExpanded = expandedSeasons.contains(season.label) Row(
item(key = "season/${season.label}") { Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
// A header, not a row with a button parked on the end of it: the verticalAlignment = Alignment.CenterVertically,
// 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 LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// the page starts and ends in the same place. itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
Row( FilterChip(
Modifier selected = index == seasonIndex,
.fillMaxWidth() onClick = { seasonIndex = index },
.padding(top = 12.dp) label = { Text(entry.label) },
.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,
) )
} }
IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) { }
Icon( season?.let {
Icons.Default.Download, IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
contentDescription = "Download ${season.label}", Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
)
} }
} }
} }
if (isExpanded) { }
itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode -> season?.let { shown ->
EpisodeRow( itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
episode = episode, EpisodeRow(
numbering = episode.numbering(season.number, position), episode = episode,
download = downloads.firstOrNull { numbering = episode.numbering(shown.number, position),
it.shareId == episode.shareId && it.linkId == episode.linkId position = position,
}, download = downloads.firstOrNull {
playerReady = playerReady, it.shareId == episode.shareId && it.linkId == episode.linkId
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) }, },
onDownload = { DownloadCoordinator.enqueue(context, episode) }, playerReady = playerReady,
onPause = { DownloadCoordinator.pause(context, it) }, onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onResume = { DownloadCoordinator.resume(context, it) }, onDownload = { DownloadCoordinator.enqueue(context, episode) },
onSetWatched = { onSetWatched(episode, it) }, 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. * `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. * 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 * 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 * column rather than a second row of buttons under every episode.
* 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.
*/ */
@Composable @Composable
private fun EpisodeRow( private fun EpisodeRow(
episode: EpisodeRecord, episode: EpisodeRecord,
numbering: String, numbering: String,
position: Int,
download: RetainedDownload?, download: RetainedDownload?,
playerReady: Boolean, playerReady: Boolean,
onPlay: () -> Unit, onPlay: () -> Unit,
@@ -378,135 +370,141 @@ private fun EpisodeRow(
) { ) {
val running = download?.status == RetainedDownload.STATUS_RUNNING || val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED download?.status == RetainedDownload.STATUS_QUEUED
// The provider's name for the episode when there is one: "The Cave of val heading = episode.heading(position)
// Skulls" says more than the filename it was parsed out of. The filename Column(
// 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(
Modifier Modifier
.fillMaxWidth() .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) {
Row(verticalAlignment = Alignment.CenterVertically) { // With metadata off the provider has no still, and Proton's own
// 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
// thumbnail is a frame of this very episode. // on the still's lower edge, the way every streaming app shows it.
Box( Box(
Modifier Modifier
.width(96.dp) .width(128.dp)
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small), .clip(MaterialTheme.shapes.small),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
RemoteArtwork( RemoteArtwork(
episode.stillUrl, episode.stillUrl,
name, heading,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = episode.thumbnailSource, 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),
) )
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 -> Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
AccentProgress( Text(
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) }, heading,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), style = MaterialTheme.typography.titleSmall,
fill = solid(MaterialTheme.colorScheme.tertiary), // 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( Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " + listOfNotNull(
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}", numbering,
episode.size?.let(::formatBytes),
episode.airDate,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 { 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", shareId = "share",
volumeId = "volume", volumeId = "volume",
linkId = "s${season}e$number", 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()), label = "S%02dE%02d".format(season.toInt(), number.toInt()),
detail = "1.2 GiB", detail = file,
season = season, season = season,
number = number, number = number,
size = 1_300_000_000uL, size = 1_300_000_000uL,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 160 KiB