mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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 |
Reference in new issue
Block a user