diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index de57af2..afeb81a 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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}" } } } diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index 9cec594..ea332d8 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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, diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 894e083..e9ec364 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ