mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app,android,ios): status, season dropdown, watched ticks
A title page now says where the show stands as a chip beside the quality marks: Airing with the day the next episode comes out, Finished, Upcoming, On hiatus or Cancelled. The words come from TitleDetails::status_label, which the bridge hands the phones as TitleRecord.status; the airing note leaves the line of facts. A show with more than five seasons picks its season from a drop-down rather than a row of pills that ran off the page, and a watched episode's still carries a tick in its corner as well as the mark at the end of its row.
This commit is contained in:
1 parent
c026f81283
commit
cd9e8eea81
11 files changed
+452
-59
No files matched your search
@@ -8,6 +8,11 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
@@ -276,6 +281,30 @@ internal fun GenreChip(text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||||
|
* [GenreChip] with a dot in front: the accent while more is coming, muted once
|
||||||
|
* it is all out. The desktop's `widgets::status_chip`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||||
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ import uniffi.pstr_android.SeasonRecord
|
|||||||
import uniffi.pstr_android.MatchRecord
|
import uniffi.pstr_android.MatchRecord
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Replay
|
import androidx.compose.material.icons.filled.Replay
|
||||||
import androidx.compose.material.icons.filled.DownloadDone
|
import androidx.compose.material.icons.filled.DownloadDone
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
@@ -254,12 +256,11 @@ internal fun TitleScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
)
|
)
|
||||||
// Who made it, and whether more is coming — the desktop's
|
// Who made it — the desktop's line of facts, on a line of
|
||||||
// line of facts, on a line of its own so neither wraps.
|
// its own so neither wraps.
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
||||||
title.directors.firstOrNull()?.let { "dir. $it" },
|
title.directors.firstOrNull()?.let { "dir. $it" },
|
||||||
airingNote(title),
|
|
||||||
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
@@ -268,9 +269,18 @@ internal fun TitleScreen(
|
|||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// What the files are, rather than what the title is.
|
// Where the title stands, then what the files are rather
|
||||||
if (title.quality.isNotEmpty()) {
|
// than what the title is.
|
||||||
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
val status = statusText(title)
|
||||||
|
if (title.quality.isNotEmpty() || status != null) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
status?.let { (text, live) ->
|
||||||
|
StatusChip(text, live, Modifier.padding(end = 4.dp))
|
||||||
|
}
|
||||||
title.quality.forEach { SpecBadge(it) }
|
title.quality.forEach { SpecBadge(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,15 +404,21 @@ internal fun TitleScreen(
|
|||||||
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
if (title.seasons.size > SEASON_CHIPS) {
|
||||||
// By position: two seasons can share a label, and a
|
// A long-running show's row of chips is a scroll of
|
||||||
// repeated key is a crash in a lazy list.
|
// its own; past a handful it is a list to pick from.
|
||||||
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
|
||||||
FilterChip(
|
} else {
|
||||||
selected = index == seasonIndex,
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onClick = { seasonIndex = index },
|
// By position: two seasons can share a label, and a
|
||||||
label = { Text(entry.label) },
|
// repeated key is a crash in a lazy list.
|
||||||
)
|
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||||
|
FilterChip(
|
||||||
|
selected = index == seasonIndex,
|
||||||
|
onClick = { seasonIndex = index },
|
||||||
|
label = { Text(entry.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
season?.let {
|
season?.let {
|
||||||
@@ -567,17 +583,51 @@ private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
|
* What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
|
||||||
* no date yet, else nothing.
|
* and whether more is coming. The desktop's `status_text`.
|
||||||
*/
|
*/
|
||||||
private fun airingNote(title: TitleRecord): String? {
|
internal fun statusText(title: TitleRecord): Pair<String, Boolean>? {
|
||||||
val at = title.nextAiringAt
|
val at = title.nextAiringAt
|
||||||
val episode = title.nextEpisode
|
val episode = title.nextEpisode
|
||||||
if (at != null && episode != null) {
|
val next = at != null && episode != null
|
||||||
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
val label = title.status ?: (if (next || title.airing) "Airing" else return null)
|
||||||
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
|
val live = next || title.airing
|
||||||
|
if (at == null || episode == null) return label to live
|
||||||
|
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||||
|
return "$label · episode $episode " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) to live
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most seasons picked from a row of chips; past it, a list. The desktop's `SEASON_PILLS`. */
|
||||||
|
private const val SEASON_CHIPS = 5
|
||||||
|
|
||||||
|
/** The season shown, as a button that opens the list of all of them. */
|
||||||
|
@Composable
|
||||||
|
private fun SeasonList(seasons: List<SeasonRecord>, picked: Int, onPick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier) {
|
||||||
|
FilterChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { open = true },
|
||||||
|
label = { Text(seasons.getOrNull(picked)?.label.orEmpty()) },
|
||||||
|
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||||
|
)
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
seasons.forEachIndexed { index, season ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(season.label) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onPick(index)
|
||||||
|
},
|
||||||
|
trailingIcon = if (index == picked) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = "Shown") }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return if (title.airing) "airing" else null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How wide the page's text and controls run on a window wider than a phone. */
|
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||||
@@ -702,6 +752,21 @@ private fun EpisodeRow(
|
|||||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Seen, said on the picture as well as by the mark at the
|
||||||
|
// row's end: a season is scanned by its stills.
|
||||||
|
if (episode.watched) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(5.dp)
|
||||||
|
.size(18.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
class StatusTest {
|
||||||
|
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
|
||||||
|
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||||
|
episodeCount = 0uL, canonicalName = null, originalName = null, overview = null,
|
||||||
|
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||||
|
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||||
|
manualMatch = false, seasons = emptyList(),
|
||||||
|
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||||
|
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
|
||||||
|
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the status chip names the next episode while one is due`() {
|
||||||
|
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
|
||||||
|
assertTrue(text, text.startsWith("Airing · episode 8 "))
|
||||||
|
assertTrue(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished title is said plainly and is not live`() {
|
||||||
|
assertEquals("Finished" to false, statusText(title("Finished")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title with no status and nothing airing has no chip`() {
|
||||||
|
assertNull(statusText(title(null)))
|
||||||
|
assertEquals("Airing" to true, statusText(title(null, airing = true)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -620,6 +620,10 @@ pub struct TitleRecord {
|
|||||||
/// `Dual audio` — in that order. Never a release group's name.
|
/// `Dual audio` — in that order. Never a release group's name.
|
||||||
#[uniffi(default = [])]
|
#[uniffi(default = [])]
|
||||||
pub quality: Vec<String>,
|
pub quality: Vec<String>,
|
||||||
|
/// `Airing`, `Finished`, `Upcoming`, `On hiatus` or `Cancelled`: the
|
||||||
|
/// shared `TitleDetails::status_label`.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playback preferences that outlive one file, one title and one launch.
|
/// Playback preferences that outlive one file, one title and one launch.
|
||||||
@@ -3684,6 +3688,9 @@ fn title_record(
|
|||||||
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
|
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
|
||||||
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
|
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
|
||||||
airing: details.is_some_and(|details| details.is_airing()),
|
airing: details.is_some_and(|details| details.is_airing()),
|
||||||
|
status: details
|
||||||
|
.and_then(|details| details.status_label())
|
||||||
|
.map(str::to_owned),
|
||||||
next_episode: None,
|
next_episode: None,
|
||||||
next_airing_at: None,
|
next_airing_at: None,
|
||||||
franchise: Vec::new(),
|
franchise: Vec::new(),
|
||||||
|
|||||||
+112
-17
@@ -85,7 +85,27 @@ pub fn show(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, label)| (index, label.as_str()))
|
.map(|(index, label)| (index, label.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
|
// A long-running show's row of pills ran off the page;
|
||||||
|
// past a handful it is a list to pick from instead.
|
||||||
|
let chosen = if choices.len() > SEASON_PILLS {
|
||||||
|
egui::ComboBox::from_id_salt(("season-list", &title.key))
|
||||||
|
.selected_text(labels[picked].as_str())
|
||||||
|
.height(360.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
let mut chosen = None;
|
||||||
|
for &(index, label) in &choices {
|
||||||
|
if ui.selectable_label(index == picked, label).clicked() {
|
||||||
|
chosen = Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen
|
||||||
|
})
|
||||||
|
.inner
|
||||||
|
.flatten()
|
||||||
|
} else {
|
||||||
|
ui::widgets::segmented(ui, picked, &choices)
|
||||||
|
};
|
||||||
|
if let Some(index) = chosen {
|
||||||
picked = index;
|
picked = index;
|
||||||
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||||
}
|
}
|
||||||
@@ -348,11 +368,22 @@ fn header(
|
|||||||
// What the files are, rather than what the title is: set apart
|
// What the files are, rather than what the title is: set apart
|
||||||
// from the line above as small outlined marks, the way a box
|
// from the line above as small outlined marks, the way a box
|
||||||
// prints them.
|
// prints them.
|
||||||
|
//
|
||||||
|
// After where the title stands, which is the one fact here that
|
||||||
|
// changes week to week.
|
||||||
let quality = title.quality().badges();
|
let quality = title.quality().badges();
|
||||||
if !quality.is_empty() {
|
let status = found
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| status_text(details, now()));
|
||||||
|
if !quality.is_empty() || status.is_some() {
|
||||||
ui.add_space(theme::space::XS);
|
ui.add_space(theme::space::XS);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||||
|
if let Some((text, live)) = &status {
|
||||||
|
ui::widgets::status_chip(ui, text, *live);
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
}
|
||||||
for badge in quality {
|
for badge in quality {
|
||||||
ui::widgets::spec_badge(ui, badge);
|
ui::widgets::spec_badge(ui, badge);
|
||||||
}
|
}
|
||||||
@@ -678,21 +709,6 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
if let Some(director) = details.directors.first() {
|
if let Some(director) = details.directors.first() {
|
||||||
parts.push(format!("dir. {director}"));
|
parts.push(format!("dir. {director}"));
|
||||||
}
|
}
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map_or(0, |since| since.as_secs() as i64);
|
|
||||||
if let Some((episode, at)) = details.next_airing(now) {
|
|
||||||
let when = chrono::DateTime::from_timestamp(at, 0)
|
|
||||||
.map(|at| {
|
|
||||||
at.with_timezone(&chrono::Local)
|
|
||||||
.format("%a %-d %b, %H:%M")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
parts.push(format!("episode {episode} airs {when}"));
|
|
||||||
} else if details.is_airing() {
|
|
||||||
parts.push("airing".to_owned());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(found) = found {
|
if let Some(found) = found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
@@ -711,6 +727,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most seasons picked from a row of pills; past it, a list.
|
||||||
|
const SEASON_PILLS: usize = 5;
|
||||||
|
|
||||||
|
/// Unix seconds, now.
|
||||||
|
fn now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the status chip says — `Airing · episode 8 Sat 1 Nov, 14:00`,
|
||||||
|
/// `Finished` — and whether more is coming.
|
||||||
|
fn status_text(details: &pstr_core::metadata::TitleDetails, now: i64) -> Option<(String, bool)> {
|
||||||
|
let next = details.next_airing(now);
|
||||||
|
let label = details.status_label().or(next.map(|_| "Airing"))?;
|
||||||
|
let live = next.is_some() || details.is_airing();
|
||||||
|
Some(match next {
|
||||||
|
Some((episode, at)) => {
|
||||||
|
let when = chrono::DateTime::from_timestamp(at, 0)
|
||||||
|
.map(|at| {
|
||||||
|
at.with_timezone(&chrono::Local)
|
||||||
|
.format("%a %-d %b, %H:%M")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(format!("{label} · episode {episode} {when}"), live)
|
||||||
|
}
|
||||||
|
None => (label.to_owned(), live),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// How big an episode's still is drawn.
|
/// How big an episode's still is drawn.
|
||||||
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
||||||
|
|
||||||
@@ -1000,6 +1047,21 @@ pub(crate) fn still(
|
|||||||
theme::accent_fill(&painter, played, radius, 0.0);
|
theme::accent_fill(&painter, played, radius, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seen, said on the picture as well as by the mark at the row's end: a
|
||||||
|
// season is scanned by its stills, and a dimmed one alone read as one that
|
||||||
|
// had not loaded.
|
||||||
|
if watched {
|
||||||
|
let corner = rect.right_top() + egui::vec2(-14.0, 14.0);
|
||||||
|
painter.circle_filled(corner, 9.0, theme::accent());
|
||||||
|
painter.text(
|
||||||
|
corner,
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
egui_phosphor::regular::CHECK,
|
||||||
|
theme::Role::Micro.font(),
|
||||||
|
theme::on_accent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if response
|
if response
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
.on_hover_text(if episode.resume_at().is_some() {
|
.on_hover_text(if episode.resume_at().is_some() {
|
||||||
@@ -1083,3 +1145,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
|
|||||||
link_id: episode.node.link_id.clone(),
|
link_id: episode.node.link_id.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn details(status: &str, next: Option<(u32, i64)>) -> pstr_core::metadata::TitleDetails {
|
||||||
|
pstr_core::metadata::TitleDetails {
|
||||||
|
status: Some(status.to_owned()),
|
||||||
|
next_episode: next.map(|(episode, _)| episode),
|
||||||
|
next_airing_at: next.map(|(_, at)| at),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_status_chip_says_when_the_next_episode_airs_and_only_while_it_is_ahead() {
|
||||||
|
let (text, live) = status_text(&details("RELEASING", Some((8, 2_000))), 1_000).unwrap();
|
||||||
|
assert!(text.starts_with("Airing · episode 8 "), "{text}");
|
||||||
|
assert!(live);
|
||||||
|
// Fetched a while ago: the date has passed, so it says nothing of it.
|
||||||
|
let (text, live) = status_text(&details("RELEASING", Some((8, 500))), 1_000).unwrap();
|
||||||
|
assert_eq!(text, "Airing");
|
||||||
|
assert!(live);
|
||||||
|
assert_eq!(
|
||||||
|
status_text(&details("FINISHED", None), 1_000),
|
||||||
|
Some(("Finished".to_owned(), false))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_text(&pstr_core::metadata::TitleDetails::default(), 0),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -409,6 +409,33 @@ pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
|||||||
.response
|
.response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a title stands — `Airing · episode 8 Sat 1 Nov, 14:00`, `Finished` —
|
||||||
|
/// as a [`chip`] with a dot in front: the accent while more is coming, muted
|
||||||
|
/// once it is all out.
|
||||||
|
pub fn status_chip(ui: &mut egui::Ui, text: &str, live: bool) -> egui::Response {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card_hover().gamma_multiply(0.8))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||||
|
let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
|
||||||
|
ui.painter().circle_filled(
|
||||||
|
dot.center(),
|
||||||
|
3.0,
|
||||||
|
if live {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
|
|
||||||
/// A small rounded label: a genre, a year, a fact.
|
/// A small rounded label: a genre, a year, a fact.
|
||||||
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
|
|||||||
@@ -238,6 +238,20 @@ impl TitleDetails {
|
|||||||
self.status.as_deref() == Some("RELEASING")
|
self.status.as_deref() == Some("RELEASING")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`TitleDetails::status`] said for a person: `Airing`, `Finished`,
|
||||||
|
/// `Upcoming`, `On hiatus` or `Cancelled`. `None` for a status nobody
|
||||||
|
/// filled in, or one this build does not know.
|
||||||
|
pub fn status_label(&self) -> Option<&'static str> {
|
||||||
|
Some(match self.status.as_deref()? {
|
||||||
|
"RELEASING" => "Airing",
|
||||||
|
"FINISHED" => "Finished",
|
||||||
|
"NOT_YET_RELEASED" => "Upcoming",
|
||||||
|
"HIATUS" => "On hiatus",
|
||||||
|
"CANCELLED" => "Cancelled",
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The next episode and when it airs, when that is still after `now`.
|
/// The next episode and when it airs, when that is still after `now`.
|
||||||
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
|
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
|
||||||
let at = self.next_airing_at.filter(|at| *at > now)?;
|
let at = self.next_airing_at.filter(|at| *at > now)?;
|
||||||
@@ -589,6 +603,24 @@ impl Default for MetadataConfig {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
|
||||||
|
let status = |value: &str| {
|
||||||
|
TitleDetails {
|
||||||
|
status: Some(value.to_owned()),
|
||||||
|
..TitleDetails::default()
|
||||||
|
}
|
||||||
|
.status_label()
|
||||||
|
};
|
||||||
|
assert_eq!(status("RELEASING"), Some("Airing"));
|
||||||
|
assert_eq!(status("FINISHED"), Some("Finished"));
|
||||||
|
assert_eq!(status("NOT_YET_RELEASED"), Some("Upcoming"));
|
||||||
|
assert_eq!(status("HIATUS"), Some("On hiatus"));
|
||||||
|
assert_eq!(status("CANCELLED"), Some("Cancelled"));
|
||||||
|
assert_eq!(status("SOMETHING_NEW"), None);
|
||||||
|
assert_eq!(TitleDetails::default().status_label(), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn metadata() -> TitleMetadata {
|
fn metadata() -> TitleMetadata {
|
||||||
TitleMetadata {
|
TitleMetadata {
|
||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
|
|||||||
+7
-2
@@ -78,8 +78,13 @@ top.
|
|||||||
is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
|
is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
|
||||||
at all. A match you pick is never replaced.
|
at all. A match you pick is never replaced.
|
||||||
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
|
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
|
||||||
- Each season has **Mark season watched** and **Download season**.
|
- Each season has **Mark season watched** and **Download season**. A show with more than five
|
||||||
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark
|
seasons lists them in a drop-down instead of a row of buttons.
|
||||||
|
- With posters and descriptions on, a chip under the name says where the show stands: **Airing**
|
||||||
|
with the day the next episode comes out, **Finished**, **Upcoming**, **On hiatus** or
|
||||||
|
**Cancelled**.
|
||||||
|
- Each episode shows a tick when you have watched it, at the end of its row and in the corner of
|
||||||
|
its picture. Right-click an episode and choose **Mark
|
||||||
watched up to here** to tick it and every episode before it.
|
watched up to here** to tick it and every episode before it.
|
||||||
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
|
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
|
||||||
- With posters and descriptions on, a show's logo takes the place of its name at the top of the
|
- With posters and descriptions on, a show's logo takes the place of its name at the top of the
|
||||||
|
|||||||
@@ -401,6 +401,25 @@ private extension UIColor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||||
|
/// `GenreChip` with a dot in front: the accent while more is coming, muted
|
||||||
|
/// once it is all out. Android's `StatusChip`.
|
||||||
|
struct StatusChip: View {
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
let text: String
|
||||||
|
let live: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle().fill(live ? scheme.primary : scheme.onSurfaceVariant).frame(width: 6, height: 6)
|
||||||
|
Text(text).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Something the title is filed under — a genre — as a small rounded label.
|
/// Something the title is filed under — a genre — as a small rounded label.
|
||||||
/// Android's `GenreChip`.
|
/// Android's `GenreChip`.
|
||||||
struct GenreChip: View {
|
struct GenreChip: View {
|
||||||
|
|||||||
@@ -223,19 +223,21 @@ struct TitleView: View {
|
|||||||
}
|
}
|
||||||
Text(factLine)
|
Text(factLine)
|
||||||
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
|
||||||
// Who made it, and whether more is coming.
|
// Who made it.
|
||||||
let makers: [String?] = [
|
let makers: [String?] = [
|
||||||
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
|
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
|
||||||
title.directors.first.map { "dir. \($0)" },
|
title.directors.first.map { "dir. \($0)" },
|
||||||
airingNote(title),
|
|
||||||
]
|
]
|
||||||
let facts = makers.compactMap { $0 }.joined(separator: " · ")
|
let facts = makers.compactMap { $0 }.joined(separator: " · ")
|
||||||
if !facts.isEmpty {
|
if !facts.isEmpty {
|
||||||
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
|
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
|
||||||
}
|
}
|
||||||
// What the files are, rather than what the title is.
|
// Where the title stands, then what the files are rather than
|
||||||
if !title.quality.isEmpty {
|
// what the title is.
|
||||||
|
let status = statusText(title)
|
||||||
|
if !title.quality.isEmpty || status != nil {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
if let status { StatusChip(text: status.text, live: status.live).padding(.trailing, 4) }
|
||||||
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
|
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -330,11 +332,34 @@ struct TitleView: View {
|
|||||||
|
|
||||||
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
|
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
if title.seasons.count > seasonChipsMax {
|
||||||
HStack(spacing: 8) {
|
// A long-running show's row of chips is a scroll of its own;
|
||||||
// By position: two seasons can share a label.
|
// past a handful it is a list to pick from.
|
||||||
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
|
Menu {
|
||||||
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
|
Picker("Season", selection: $seasonIndex) {
|
||||||
|
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
|
||||||
|
Text(entry.label).tag(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex].label : "")
|
||||||
|
Image(systemName: "chevron.down").font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.textStyle(.labelLarge)
|
||||||
|
.foregroundStyle(scheme.onSecondaryContainer)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(scheme.secondaryContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// By position: two seasons can share a label.
|
||||||
|
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
|
||||||
|
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,15 +432,20 @@ private struct FranchiseTile: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "episode 7 on Sat 11 Oct" while one is due, "airing" while it is airing
|
/// The most seasons picked from a row of chips; past it, a list. Android's
|
||||||
/// with no date yet, else nothing.
|
/// `SEASON_CHIPS`.
|
||||||
private func airingNote(_ title: TitleRecord) -> String? {
|
private let seasonChipsMax = 5
|
||||||
if let at = title.nextAiringAt, let episode = title.nextEpisode {
|
|
||||||
let format = DateFormatter()
|
/// What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
|
||||||
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
|
/// and whether more is coming. Android's `statusText`.
|
||||||
return "episode \(episode) on " + format.string(from: Date(timeIntervalSince1970: TimeInterval(at)))
|
func statusText(_ title: TitleRecord) -> (text: String, live: Bool)? {
|
||||||
}
|
let next = title.nextAiringAt.flatMap { at in title.nextEpisode.map { (episode: $0, at: at) } }
|
||||||
return title.airing ? "airing" : nil
|
guard let label = title.status ?? (next != nil || title.airing ? "Airing" : nil) else { return nil }
|
||||||
|
let live = next != nil || title.airing
|
||||||
|
guard let next else { return (label, live) }
|
||||||
|
let format = DateFormatter()
|
||||||
|
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
|
||||||
|
return ("\(label) · episode \(next.episode) " + format.string(from: Date(timeIntervalSince1970: TimeInterval(next.at))), live)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One of the title's secondary actions: an icon with its name under it.
|
/// One of the title's secondary actions: an icon with its name under it.
|
||||||
@@ -508,6 +538,19 @@ private struct EpisodeRow: View {
|
|||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
|
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
|
||||||
}
|
}
|
||||||
|
// Seen, said on the picture as well as by the mark at the
|
||||||
|
// row's end: a season is scanned by its stills.
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if episode.watched {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(scheme.onPrimary)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.background(scheme.primary, in: Circle())
|
||||||
|
.padding(5)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
// Seen episodes stay legible but stop competing with the
|
// Seen episodes stay legible but stop competing with the
|
||||||
|
|||||||
@@ -299,3 +299,35 @@ final class ThemeTests: XCTestCase {
|
|||||||
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
|
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class StatusTextTests: XCTestCase {
|
||||||
|
private func title(status: String?, airing: Bool = false, next: (UInt32, Int64)? = nil) -> TitleRecord {
|
||||||
|
TitleRecord(
|
||||||
|
key: "k", name: "k", year: nil, kind: .series, watchedCount: 0,
|
||||||
|
episodeCount: 0, canonicalName: nil, originalName: nil, overview: nil,
|
||||||
|
metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil,
|
||||||
|
backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil,
|
||||||
|
manualMatch: false, seasons: [],
|
||||||
|
displayName: "k", wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
|
||||||
|
directors: [], tags: [], airing: airing, nextEpisode: next?.0, nextAiringAt: next?.1,
|
||||||
|
franchise: [], continueIndex: nil, status: status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheStatusChipNamesTheNextEpisodeWhileOneIsDue() throws {
|
||||||
|
let status = try XCTUnwrap(statusText(title(status: "Airing", airing: true, next: (8, 1_800_000_000))))
|
||||||
|
XCTAssertTrue(status.text.hasPrefix("Airing · episode 8 "), status.text)
|
||||||
|
XCTAssertTrue(status.live)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAFinishedTitleIsSaidPlainlyAndIsNotLive() throws {
|
||||||
|
let status = try XCTUnwrap(statusText(title(status: "Finished")))
|
||||||
|
XCTAssertEqual(status.text, "Finished")
|
||||||
|
XCTAssertFalse(status.live)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testATitleWithNoStatusAndNothingAiringHasNoChip() {
|
||||||
|
XCTAssertNil(statusText(title(status: nil)))
|
||||||
|
XCTAssertEqual(statusText(title(status: nil, airing: true))?.text, "Airing")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user