feat(core,app,android,ios): more like this and browse by genre, studio, person

pstr_core::discover scores every title against another by shared
director, studio, genres, leading tags and format, leaves out its own
franchise and keeps one title per other franchise. It drives a "More
like this" row under a title's episodes and, seeded by the title most
recently watched to the end, a "Because you watched" row on the library
of titles not yet started.

Genre chips on the title page now open a page of every title with that
genre, and the studios and director move from the line of facts onto
chips of their own that do the same. The desktop has a Browse page whose
back link returns to the title; Android and iOS show it over the library
pane. The bridge exposes more_like, browse and facet_kind.

Also moves a doc comment the My List commit left on the wrong function.
This commit is contained in:
nvrl committed 2026-10-30 22:46:08 +01:00
1 parent f66faf6b75
commit fafe0c3087
23 files changed
+1139 -92

No files matched your search

@@ -28,6 +28,7 @@ import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.LibraryFilter import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
@@ -637,6 +638,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
suspend fun moreLike(key: String): List<String> =
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
/** The keys of every title [facet] stands for, in library order. */
suspend fun browse(facet: BrowseFacet): List<String> =
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
.onFailure(::reportError)
.getOrDefault(emptyList())
fun setTitleWatched(title: TitleRecord, watched: Boolean) = fun setTitleWatched(title: TitleRecord, watched: Boolean) =
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName) markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
@@ -6,6 +6,8 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.draw.clip
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.shape.CircleShape
@@ -277,13 +279,15 @@ internal fun EpisodeRecord.heading(position: Int): String {
* The desktop's `widgets::chip`. * The desktop's `widgets::chip`.
*/ */
@Composable @Composable
internal fun GenreChip(text: String) { internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
Text( Text(
text, text,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(horizontal = 10.dp, vertical = 4.dp),
) )
} }
@@ -111,6 +111,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
@@ -118,6 +119,7 @@ import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown import uniffi.pstr_android.airingCountdown
import uniffi.pstr_android.facetKind
/** /**
* How many shows the continue-watching shelf holds. * How many shows the continue-watching shelf holds.
@@ -1062,3 +1064,72 @@ internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000
val episode = nextEpisode ?: return null val episode = nextEpisode ?: return null
return airingCountdown(episode, at, now) return airingCountdown(episode, at, now)
} }
/**
* Every title sharing a genre, a studio or a director: what a chip on a title
* page opens. The desktop's `library::browse`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BrowseScreen(
facet: BrowseFacet,
titles: List<TitleRecord>,
onBack: () -> Unit,
onTitle: (TitleRecord) -> Unit,
padding: PaddingValues,
) {
BackHandler(onBack = onBack)
Column(Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
title = {
Column {
Text(
facetKind(facet),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(facet.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
colors = pageBarColors(),
)
if (titles.isEmpty()) {
// A recrawl or a new match took the last one away.
Box(Modifier.padding(padding)) {
EmptyState(facet.label, "Nothing in the library has this any more.")
}
return@Column
}
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize().padding(padding),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
) {
item(key = "count", span = { GridItemSpan(maxLineSpan) }) {
Text(
if (titles.size == 1) "1 title" else "${titles.size} titles",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(titles, key = { it.key }) { title ->
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitle(title) }, shared = false)
}
}
}
}
/** The name a [BrowseFacet] stands for: the chip's text and its page's heading. */
internal val BrowseFacet.label: String
get() = when (this) {
is BrowseFacet.Genre -> name
is BrowseFacet.Studio -> name
is BrowseFacet.Person -> name
}
@@ -62,6 +62,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -78,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
@@ -135,6 +137,28 @@ fun ProtonStreamApp(
// into the video they had just left. // into the video they had just left.
var playerMinimized by rememberSaveable { mutableStateOf(false) } var playerMinimized by rememberSaveable { mutableStateOf(false) }
var addingShare by remember { mutableStateOf(false) } var addingShare by remember { mutableStateOf(false) }
// What a genre, studio or director chip opened, over the library pane.
// As its two parts, which survive process recreation where the record
// would not.
var browseKind by rememberSaveable { mutableStateOf<String?>(null) }
var browseName by rememberSaveable { mutableStateOf("") }
// The title it was opened from, which is where back goes, as on the
// desktop.
var browseFrom by rememberSaveable { mutableStateOf<String?>(null) }
val browsing = when (browseKind) {
"genre" -> BrowseFacet.Genre(browseName)
"studio" -> BrowseFacet.Studio(browseName)
"person" -> BrowseFacet.Person(browseName)
else -> null
}
val browse = { facet: BrowseFacet ->
browseKind = when (facet) {
is BrowseFacet.Genre -> "genre"
is BrowseFacet.Studio -> "studio"
is BrowseFacet.Person -> "person"
}
browseName = facet.label
}
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey } val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
val playing = playingTitleKey val playing = playingTitleKey
@@ -172,7 +196,7 @@ fun ProtonStreamApp(
Destination.entries.forEach { item -> Destination.entries.forEach { item ->
item( item(
selected = destination == item, selected = destination == item,
onClick = { destination = item; selectedTitleKey = null }, onClick = { destination = item; selectedTitleKey = null; browseKind = null },
icon = { Icon(item.icon, contentDescription = item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }, label = { Text(item.label) },
colors = navigation, colors = navigation,
@@ -211,8 +235,23 @@ fun ProtonStreamApp(
// library stays in view while a title is open, so moving // library stays in view while a title is open, so moving
// between titles is one tap rather than back and in again. // between titles is one tap rather than back and in again.
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) { Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= TWO_PANE_WIDTH
val browsed by produceState(emptyList<String>(), browsing, state.titles) {
value = browsing?.let { model.browse(it) }.orEmpty()
}
val libraryPane = @Composable { val libraryPane = @Composable {
LibraryScreen( browsing?.let { facet ->
BrowseScreen(
facet,
browsed.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
onBack = {
browseKind = null
browseFrom?.let { selectedTitleKey = it }
},
onTitle = { selectedTitleKey = it.key },
padding = body,
)
} ?: LibraryScreen(
state, state,
model::search, model::search,
{ title, index -> { title, index ->
@@ -238,6 +277,9 @@ fun ProtonStreamApp(
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
value = model.moreLike(selected.key)
}
TitleScreen( TitleScreen(
selected, selected,
playerHost != null, playerHost != null,
@@ -257,9 +299,17 @@ fun ProtonStreamApp(
}, },
onOpenTitle = { selectedTitleKey = it.key }, onOpenTitle = { selectedTitleKey = it.key },
onSetOnList = model::setOnList, onSetOnList = model::setOnList,
moreLike = similar.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
onBrowse = { facet ->
browse(facet)
browseFrom = selected.key
// On a phone the list is under the title;
// the page it opens is the library pane's.
if (!twoPane) selectedTitleKey = null
},
) )
} }
if (maxWidth >= TWO_PANE_WIDTH) { if (twoPane) {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(0.45f)) { libraryPane() } Box(Modifier.weight(0.45f)) { libraryPane() }
VerticalDivider() VerticalDivider()
@@ -91,6 +91,7 @@ import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border import androidx.compose.foundation.border
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
@@ -123,6 +124,10 @@ internal fun TitleScreen(
franchise: List<TitleRecord> = emptyList(), franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {}, onOpenTitle: (TitleRecord) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> }, onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
/** The titles most like it, best first. */
moreLike: List<TitleRecord> = emptyList(),
/** Open every title sharing a genre, studio or director. */
onBrowse: (BrowseFacet) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val download = rememberDownload() val download = rememberDownload()
@@ -260,19 +265,6 @@ 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 — the desktop's line of facts, on a line of
// its own so neither wraps.
listOfNotNull(
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
title.directors.firstOrNull()?.let { "dir. $it" },
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
// Where the title stands, then what the files are rather // Where the title stands, then what the files are rather
// than what the title is. // than what the title is.
val status = statusText(title) val status = statusText(title)
@@ -368,7 +360,31 @@ internal fun TitleScreen(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
title.genres.forEach { GenreChip(it) } title.genres.forEach { genre -> GenreChip(genre) { onBrowse(BrowseFacet.Genre(genre)) } }
}
}
// Who made it, as chips that open everything else by them:
// the desktop's credits row.
val studios = title.studios.take(2)
val director = title.directors.take(1)
if (studios.isNotEmpty() || director.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
listOf(
Triple("Studio", studios) { name: String -> BrowseFacet.Studio(name) },
Triple("Director", director) { name: String -> BrowseFacet.Person(name) },
).filter { it.second.isNotEmpty() }.forEach { (label, names, facet) ->
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
names.forEach { name -> GenreChip(name) { onBrowse(facet(name)) } }
}
} }
} }
if (title.tags.isNotEmpty()) { if (title.tags.isNotEmpty()) {
@@ -476,6 +492,27 @@ internal fun TitleScreen(
} }
} }
} }
// Under the episodes rather than over them: where to go once this
// one is done, not what the page is for.
if (moreLike.isNotEmpty()) {
item(key = "more-like-this") {
Column(Modifier.padding(top = 16.dp)) {
Text(
"More like this",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
items(moreLike, key = { it.key }) { other ->
FranchiseTile(other, here = false) { onOpenTitle(other) }
}
}
}
}
}
} }
TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack) TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack)
} }
@@ -546,8 +583,9 @@ private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack:
private val TITLE_BAR = 56.dp private val TITLE_BAR = 56.dp
/** /**
* One part of the franchise: its cover, what it is and when. This title's own * One part of the franchise, or one title like this one: its cover, what it is
* is marked rather than left out, so the row says where it sits in the story. * and when. In the franchise row this title's own is marked rather than left
* out, so the row says where it sits in the story.
*/ */
@Composable @Composable
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) { private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 KiB

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+62 -3
View File
@@ -356,6 +356,26 @@ pub enum TitleNamesChoice {
Romaji, Romaji,
} }
/// What a genre, studio or director chip opens: the shared
/// `pstr_core::discover::Facet`.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum BrowseFacet {
Genre { name: String },
Studio { name: String },
Person { name: String },
}
impl BrowseFacet {
fn core(&self) -> pstr_core::discover::Facet {
use pstr_core::discover::Facet;
match self {
Self::Genre { name } => Facet::Genre(name.clone()),
Self::Studio { name } => Facet::Studio(name.clone()),
Self::Person { name } => Facet::Person(name.clone()),
}
}
}
/// The shared `pstr_core::browse::Filter`. /// The shared `pstr_core::browse::Filter`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter { pub enum LibraryFilter {
@@ -479,9 +499,6 @@ pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
.map(pstr_core::appearance::Rgb::argb) .map(pstr_core::appearance::Rgb::argb)
} }
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
/// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`; /// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`;
/// see `pstr_core::news::countdown`. /// see `pstr_core::news::countdown`.
#[uniffi::export] #[uniffi::export]
@@ -489,6 +506,16 @@ pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
pstr_core::news::countdown(episode, at, now) pstr_core::news::countdown(episode, at, now)
} }
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
/// `Director` — for over the heading of its page.
#[uniffi::export]
pub fn facet_kind(facet: BrowseFacet) -> String {
facet.core().kind().to_owned()
}
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
#[uniffi::export] #[uniffi::export]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 { pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| { let rgb = |argb: u32| {
@@ -1700,6 +1727,7 @@ impl AndroidEngine {
pstr_core::shelves::home( pstr_core::shelves::home(
&cached.library.titles, &cached.library.titles,
&cached.metadata, &cached.metadata,
cached.names,
&cached.list, &cached.list,
&cached.first_seen, &cached.first_seen,
now(), now(),
@@ -1727,6 +1755,21 @@ impl AndroidEngine {
}) })
} }
/// The keys of the titles most like `key`, best first: a title page's
/// "More like this" row. Worked out on asking rather than for every
/// record, since it compares the title with the whole library.
pub fn more_like(&self, key: String) -> Result<Vec<String>, BridgeError> {
self.with_library(|cached| {
pstr_core::discover::more_like(&key, &cached.library.titles, &cached.metadata, SIMILAR)
})
}
/// The keys of every title `facet` stands for, in library order.
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
let facet = facet.core();
self.with_library(|cached| facet.titles(&cached.library.titles, &cached.metadata))
}
/// Put a title on My List or take it off. Refused when the list on disk /// Put a title on My List or take it off. Refused when the list on disk
/// would not parse, rather than writing an empty one over it. /// would not parse, rather than writing an empty one over it.
pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> { pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> {
@@ -3604,6 +3647,9 @@ fn sort_choice(sort: LibrarySort) -> Sort {
/// desktop's four, because on a phone each is a screen's height. /// desktop's four, because on a phone each is a screen's height.
const SHELVES: usize = 3; const SHELVES: usize = 3;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
fn appearance_choice(record: AppearanceRecord) -> Appearance { fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance { Appearance {
flavor: match record.flavor { flavor: match record.flavor {
@@ -4428,6 +4474,19 @@ mod tests {
.expect("list"); .expect("list");
assert!(!engine.library(None).expect("library")[0].on_list); assert!(!engine.library(None).expect("library")[0].on_list);
// The film is Frieren's own franchise, so nothing else is like it.
assert!(
engine
.more_like("sousou no frieren".into())
.expect("similar")
.is_empty()
);
let genre = super::BrowseFacet::Genre {
name: "Drama".into(),
};
assert!(engine.browse(genre.clone()).expect("browse").is_empty());
assert_eq!(super::facet_kind(genre), "Genre");
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
} }
+61 -4
View File
@@ -50,6 +50,7 @@ impl FrameTimer {
Page::Library => "library", Page::Library => "library",
Page::History => "history", Page::History => "history",
Page::Title(_) => "title", Page::Title(_) => "title",
Page::Browse { .. } => "browse",
Page::Shares => "shares", Page::Shares => "shares",
Page::Downloads => "downloads", Page::Downloads => "downloads",
Page::Settings => "settings", Page::Settings => "settings",
@@ -97,6 +98,12 @@ pub enum Page {
History, History,
/// One title, by [`pstr_core::library::Title::key`]. /// One title, by [`pstr_core::library::Title::key`].
Title(String), Title(String),
/// The titles a genre, studio or person chip stands for, with the title
/// it was opened from, for the way back.
Browse {
facet: pstr_core::discover::Facet,
from: Option<String>,
},
Shares, Shares,
Downloads, Downloads,
/// How the app looks, how it plays, and what it looks up. /// How the app looks, how it plays, and what it looks up.
@@ -420,6 +427,9 @@ pub struct LibraryView {
pub badges: HashMap<String, String>, pub badges: HashMap<String, String>,
/// The keys on My List. /// The keys on My List.
pub listed: std::collections::HashSet<String>, pub listed: std::collections::HashSet<String>,
/// "More like this" for the title page open, worked out once per title:
/// its key, and the keys of the titles like it.
pub similar: Option<(String, Vec<String>)>,
/// The library or what is known about it changed under the lists. /// The library or what is known about it changed under the lists.
stale: bool, stale: bool,
/// Only watch state changed: the shelves and franchises still stand, /// Only watch state changed: the shelves and franchises still stand,
@@ -457,6 +467,7 @@ impl Default for LibraryView {
resumable: Vec::new(), resumable: Vec::new(),
badges: HashMap::new(), badges: HashMap::new(),
listed: std::collections::HashSet::new(), listed: std::collections::HashSet::new(),
similar: None,
stale: false, stale: false,
progressed: false, progressed: false,
} }
@@ -483,6 +494,9 @@ pub struct ViewInputs<'a> {
/// How many shelves the library shows above the grid. /// How many shelves the library shows above the grid.
const SHELVES: usize = 4; const SHELVES: usize = 4;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
impl LibraryView { impl LibraryView {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) { fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
use pstr_core::library::Title; use pstr_core::library::Title;
@@ -506,6 +520,7 @@ impl LibraryView {
} }
let metadata = inputs.metadata; let metadata = inputs.metadata;
if self.stale { if self.stale {
self.similar = None;
let all: Vec<&Title> = library.titles.iter().collect(); let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata) self.franchises = pstr_core::franchise::group(&all, metadata)
.into_iter() .into_iter()
@@ -533,6 +548,7 @@ impl LibraryView {
self.shelves = pstr_core::shelves::home( self.shelves = pstr_core::shelves::home(
&library.titles, &library.titles,
metadata, metadata,
inputs.names,
inputs.list, inputs.list,
inputs.first_seen, inputs.first_seen,
inputs.now, inputs.now,
@@ -1722,7 +1738,10 @@ impl App {
} }
Action::WatchToEnd => self.up_next.dismissed = true, Action::WatchToEnd => self.up_next.dismissed = true,
Action::FocusSearch => { Action::FocusSearch => {
if !matches!(self.page, Page::Library | Page::Title(_)) { if !matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
) {
self.page = Page::Library; self.page = Page::Library;
} }
self.focus_search = true; self.focus_search = true;
@@ -1741,8 +1760,11 @@ impl App {
self.my_list = self.engine.set_on_list(&key, on); self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true; self.view.stale = true;
} }
Action::Back => match self.page { Action::Back => match &self.page {
Page::Title(_) => self.page = Page::Library, Page::Title(_) => self.page = Page::Library,
Page::Browse { from, .. } => {
self.page = from.clone().map_or(Page::Library, Page::Title)
}
// Back out of a search before out of anything else: it is the // Back out of a search before out of anything else: it is the
// last thing the viewer did. // last thing the viewer did.
Page::Library if !self.search.is_empty() => self.search.clear(), Page::Library if !self.search.is_empty() => self.search.clear(),
@@ -2131,7 +2153,10 @@ impl App {
); );
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
let on_library = matches!(self.page, Page::Library | Page::Title(_)); let on_library = matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
);
let active = self let active = self
.downloads .downloads
.iter() .iter()
@@ -2452,6 +2477,18 @@ impl eframe::App for App {
now: crate::engine::unix_now(), now: crate::engine::unix_now(),
}, },
); );
// After the refresh, which forgets it when the library changed.
if let Page::Title(key) = &self.page
&& self.view.similar.as_ref().is_none_or(|(of, _)| of != key)
{
let keys = pstr_core::discover::more_like(
key,
&self.library.titles,
&self.metadata,
SIMILAR,
);
self.view.similar = Some((key.clone(), keys));
}
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress)); let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
let App { let App {
@@ -2580,6 +2617,12 @@ impl eframe::App for App {
key, key,
ui::title::Context { ui::title::Context {
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice), franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
similar: view
.similar
.as_ref()
.filter(|(of, _)| of == key)
.map(|(_, keys)| keys.as_slice())
.unwrap_or_default(),
on_list: view.listed.contains(key.as_str()), on_list: view.listed.contains(key.as_str()),
}, },
ui::title::OfflineView { ui::title::OfflineView {
@@ -2588,6 +2631,18 @@ impl eframe::App for App {
}, },
&mut actions, &mut actions,
), ),
Page::Browse { facet, from } => ui::library::browse(
ui,
&mut art,
library,
ui::library::Browsing {
facet,
from: from.as_deref(),
},
view,
engine.appearance().tiles,
&mut actions,
),
Page::Shares => ui::shares::show( Page::Shares => ui::shares::show(
ui, ui,
shares, shares,
@@ -2939,7 +2994,9 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
} }
if input.consume_key(Modifiers::ALT, Key::ArrowLeft) if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|| input.pointer.button_pressed(egui::PointerButton::Extra1) || input.pointer.button_pressed(egui::PointerButton::Extra1)
|| (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape)) || (!typing
&& matches!(page, Page::Title(_) | Page::Browse { .. })
&& input.key_pressed(Key::Escape))
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace)) || (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
{ {
actions.push(Action::Back); actions.push(Action::Back);
+49 -1
View File
@@ -123,7 +123,51 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
return; return;
} }
grid(ui, art, &matches, view, tiles, actions); grid(ui, art, &matches, view, tiles, true, actions);
});
}
/// What a chip on a title page opened.
pub struct Browsing<'a> {
pub facet: &'a pstr_core::discover::Facet,
/// The title it was opened from, which is where back goes.
pub from: Option<&'a str>,
}
/// Every title sharing a genre, a studio or a director, as the library grid.
pub fn browse(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
browsing: Browsing<'_>,
view: &LibraryView,
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let Browsing { facet, from } = browsing;
let back = from
.and_then(|key| library.get(key))
.map_or("Library", |title| art.name(title))
.to_owned();
if ui::title::back_link(ui, &back).clicked() {
actions.push(Action::Back);
}
ui.add_space(theme::space::S);
let keys = facet.titles(&library.titles, art.metadata);
let titles: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.label(ui::muted(facet.kind()));
ui.label(theme::Role::Display.rich(facet.name()).color(theme::text()));
ui.label(ui::muted(plural(titles.len(), "title")));
ui.add_space(theme::space::L);
if titles.is_empty() {
// A recrawl or a new match took the last one away.
ui.label(ui::muted("Nothing in the library has this any more."));
return;
}
grid(ui, art, &titles, view, tiles, false, actions);
}); });
} }
@@ -740,12 +784,15 @@ fn continue_row(
} }
/// Every title, wrapped to the window. /// Every title, wrapped to the window.
/// `titles` as a wall of tiles. `folding` says they are the library grid's
/// own, whose folded franchises `view` counts.
fn grid( fn grid(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
titles: &[&Title], titles: &[&Title],
view: &LibraryView, view: &LibraryView,
tiles: Tiles, tiles: Tiles,
folding: bool,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let (minimum, aspect) = tile_shape(tiles); let (minimum, aspect) = tile_shape(tiles);
@@ -780,6 +827,7 @@ fn grid(
let folded = view let folded = view
.matches .matches
.get(row * grid.columns + column) .get(row * grid.columns + column)
.filter(|_| folding)
.and_then(|index| view.folded.get(index)) .and_then(|index| view.folded.get(index))
.copied(); .copied();
let response = ui::card( let response = ui::card(
+95 -26
View File
@@ -20,6 +20,8 @@ pub struct OfflineView<'a> {
pub struct Context<'a> { pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one. /// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>, pub franchise: Option<&'a [String]>,
/// The keys of the titles most like it, best first.
pub similar: &'a [String],
pub on_list: bool, pub on_list: bool,
} }
@@ -32,7 +34,11 @@ pub fn show(
offline: OfflineView<'_>, offline: OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let Context { franchise, on_list } = context; let Context {
franchise,
similar,
on_list,
} = context;
let Some(title) = library.get(key) else { let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this // The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell. // title. Nothing to show, so go back rather than draw an empty shell.
@@ -40,7 +46,7 @@ pub fn show(
return; return;
}; };
if back_link(ui).clicked() { if back_link(ui, "Library").clicked() {
actions.push(Action::Goto(Page::Library)); actions.push(Action::Goto(Page::Library));
} }
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
@@ -55,7 +61,14 @@ pub fn show(
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect(); let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 { if members.len() > 1 {
ui::section(ui, "In this franchise"); ui::section(ui, "In this franchise");
franchise_row(ui, art, title, &members, actions); cover_row(
ui,
art,
("franchise", &title.key),
Some(title),
&members,
actions,
);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
} }
} }
@@ -137,23 +150,35 @@ pub fn show(
episode_row(ui, art, title, season, episode, &offline, actions); episode_row(ui, art, title, season, episode, &offline, actions);
} }
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
// Under the episodes rather than over them: it is where to go
// once this one is done, not what the page is for.
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
if !similar.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "More like this");
cover_row(ui, art, ("similar", &title.key), None, &similar, actions);
ui.add_space(theme::space::L);
}
}); });
} }
/// Every title of the franchise, in release order, as covers — this one /// A row of covers: every title of the franchise in release order, with
/// marked rather than left out, so the row says where it sits in the story. /// `current` marked rather than left out, so the row says where it sits in
fn franchise_row( /// the story — or, with no `current`, the titles like this one.
fn cover_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
current: &Title, id: impl std::hash::Hash + std::fmt::Debug,
current: Option<&Title>,
members: &[&Title], members: &[&Title],
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
ui::shelf(ui, ("franchise", &current.key), |ui| { ui::shelf(ui, id, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members { for member in members {
let here = member.key == current.key; let here = current.is_some_and(|current| member.key == current.key);
let picture = art.poster(member); let picture = art.poster(member);
let found = art let found = art
.metadata .metadata
@@ -217,9 +242,10 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
} }
/// "← Library", as a link rather than a button: it is navigation, and a /// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them. /// filled button beside the page's real actions competed with them. `to`
fn back_link(ui: &mut egui::Ui) -> egui::Response { /// names where it goes.
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT)); pub(crate) fn back_link(ui: &mut egui::Ui, to: &str) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} {to}", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click())); let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() { if response.hovered() {
ui.painter().hline( ui.painter().hline(
@@ -495,7 +521,7 @@ fn header(
if let Some(found) = &found { if let Some(found) = &found {
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
description(ui, found); description(ui, found, &title.key, actions);
} }
}); });
}); });
@@ -610,17 +636,31 @@ fn band(
} }
/// What the provider had to say. Only ever drawn under a match. /// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) { ///
let tags = found /// Its genres, studios and director are chips that open every title in the
.details /// library sharing them.
.as_ref() fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mut Vec<Action>) {
use pstr_core::discover::Facet;
let browse = |facet: Facet| {
Action::Goto(Page::Browse {
facet,
from: Some(key.to_owned()),
})
};
let details = found.details.as_ref();
let tags = details
.map(|details| details.tags.as_slice()) .map(|details| details.tags.as_slice())
.unwrap_or_default(); .unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() { if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0; ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres { for genre in &found.genres {
ui::widgets::chip(ui, genre); if ui::widgets::link_chip(ui, genre)
.on_hover_text(format!("Every {genre} title"))
.clicked()
{
actions.push(browse(Facet::Genre(genre.clone())));
}
} }
// After the genres and quieter than them: a tag is the // After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as. // provider's community describing it, not what it is filed as.
@@ -630,6 +670,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}); });
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
} }
// Who made it, out of the grey line and onto chips, so the names lead
// somewhere.
let studios = details
.map(|details| &details.studios[..details.studios.len().min(2)])
.unwrap_or_default();
let directors = details
.map(|details| &details.directors[..details.directors.len().min(1)])
.unwrap_or_default();
if !studios.is_empty() || !directors.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (label, names, facet) in [
("Studio", studios, Facet::Studio as fn(String) -> Facet),
("Director", directors, Facet::Person),
] {
if names.is_empty() {
continue;
}
ui.label(ui::muted(label));
for name in names {
if ui::widgets::link_chip(ui, name)
.on_hover_text(format!("Everything in the library by {name}"))
.clicked()
{
actions.push(browse(facet(name.clone())));
}
}
ui.add_space(theme::space::S);
}
});
ui.add_space(theme::space::M);
}
if let Some(overview) = &found.overview { if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three // Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes — // paragraphs on a long show, and all of them pushed the episodes —
@@ -711,14 +783,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{watched} watched")); parts.push(format!("{watched} watched"));
} }
if let Some(details) = found.and_then(|found| found.details.as_ref()) { if let Some(season) = found
if let Some(season) = details.season_label() { .and_then(|found| found.details.as_ref())
parts.push(season); .and_then(|details| details.season_label())
} {
parts.extend(details.studios.iter().take(2).cloned()); parts.push(season);
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
} }
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
+26
View File
@@ -447,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
}) })
.response .response
} }
/// A [`chip`] that opens something: lighter under the pointer, with a ring
/// when keyboard focus is on it.
pub fn link_chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
let mut prepared = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.begin(ui);
prepared
.content_ui
.label(theme::Role::Caption.rich(text).color(theme::text()));
let response = prepared
.allocate_space(ui)
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
prepared.frame.fill = if response.hovered() {
theme::card_hover()
} else {
theme::card_hover().gamma_multiply(0.8)
};
if response.has_focus() {
prepared.frame.stroke = egui::Stroke::new(1.5, theme::accent());
}
prepared.paint(ui);
response
}
+433
View File
@@ -0,0 +1,433 @@
//! Getting from one title to the next: "More like this", the "Because you
//! watched" row, and the pages a genre, studio or person chip opens.
//!
//! All of it is read off what the provider already said about each title —
//! genres, tags, studios, directors, format — so none of it costs a request,
//! and it only ever offers what the library holds.
use std::collections::{HashMap, HashSet};
use crate::library::Title;
use crate::metadata::{MetadataRecord, TitleMetadata, TitleNames};
use crate::shelves::Shelf;
/// What a chip on a title page stands for, and the page it opens.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Facet {
Genre(String),
Studio(String),
/// A director.
Person(String),
}
impl Facet {
/// The name on the chip, and the heading of its page.
pub fn name(&self) -> &str {
match self {
Self::Genre(name) | Self::Studio(name) | Self::Person(name) => name,
}
}
/// What kind of thing the name is, said over the heading.
pub fn kind(&self) -> &'static str {
match self {
Self::Genre(_) => "Genre",
Self::Studio(_) => "Studio",
Self::Person(_) => "Director",
}
}
/// Whether a title the provider described as `found` belongs on the page.
pub fn admits(&self, found: &TitleMetadata) -> bool {
let details = found.details.as_ref();
match self {
Self::Genre(name) => found.genres.iter().any(|genre| genre == name),
Self::Studio(name) => {
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
}
Self::Person(name) => {
details.is_some_and(|details| details.directors.iter().any(|person| person == name))
}
}
}
/// The keys of the titles in `titles` the page lists, in library order.
pub fn titles(
&self,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
titles
.iter()
.filter(|title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| self.admits(found))
})
.map(|title| title.key.clone())
.collect()
}
}
/// How much one shared trait says two titles are alike. A director is close
/// to a signature; a genre is one of three or four on every entry.
const DIRECTOR: u32 = 4;
const STUDIO: u32 = 3;
const GENRE: u32 = 2;
const TAG: u32 = 1;
const FORMAT: u32 = 1;
/// How many of a title's tags count. A provider ranks them, and the tail of
/// the list ("Male Protagonist", "Primarily Teen Cast") is on half of
/// everything.
const TAGS: usize = 8;
/// The least score a title needs to be offered: one director, or a studio and
/// a tag, or two genres. One genre in common is not a recommendation.
const MIN_SCORE: u32 = 4;
/// What a title is, for comparing: only the parts that score.
struct Traits<'a> {
genres: HashSet<&'a str>,
tags: HashSet<&'a str>,
studios: HashSet<&'a str>,
directors: HashSet<&'a str>,
format: Option<&'a str>,
popularity: u32,
}
impl<'a> Traits<'a> {
fn of(found: &'a TitleMetadata) -> Self {
let details = found.details.as_ref();
let strings = |list: Option<&'a Vec<String>>, take: usize| -> HashSet<&'a str> {
list.into_iter()
.flatten()
.take(take)
.map(String::as_str)
.collect()
};
Self {
genres: found.genres.iter().map(String::as_str).collect(),
tags: strings(details.map(|details| &details.tags), TAGS),
studios: strings(details.map(|details| &details.studios), usize::MAX),
directors: strings(details.map(|details| &details.directors), usize::MAX),
format: details.and_then(|details| details.format.as_deref()),
popularity: details.and_then(|details| details.popularity).unwrap_or(0),
}
}
fn score(&self, other: &Self) -> u32 {
let shared = |a: &HashSet<&str>, b: &HashSet<&str>| a.intersection(b).count() as u32;
let traits = DIRECTOR * shared(&self.directors, &other.directors)
+ STUDIO * shared(&self.studios, &other.studios)
+ GENRE * shared(&self.genres, &other.genres)
+ TAG * shared(&self.tags, &other.tags);
// Only on top of something else: a TV series is like every other one.
if traits > 0 && self.format.is_some() && self.format == other.format {
traits + FORMAT
} else {
traits
}
}
}
/// Up to `limit` keys of the titles most like `key`, best first.
///
/// Never its own franchise — that is the row above it — and one title per
/// other franchise, its best scoring: three films of one story are one
/// suggestion, not three.
pub fn more_like(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
limit: usize,
) -> Vec<String> {
ranked(key, titles, metadata, |_| true, limit)
}
/// The row seeded by the title most recently watched to the end: titles like
/// it the viewer has not started. `None` when nothing is finished, or nothing
/// is like it.
pub fn because_you_watched(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: TitleNames,
limit: usize,
) -> Option<Shelf> {
let seed = titles
.iter()
.filter(|title| {
title.episode_count() > 0
&& title.watched_count() == title.episode_count()
&& metadata
.get(&title.key)
.is_some_and(|record| record.metadata.is_some())
})
.max_by_key(|title| title.last_played())?;
let keys = ranked(
&seed.key,
titles,
metadata,
|title| title.last_played() == 0,
limit,
);
(!keys.is_empty()).then(|| Shelf {
label: format!(
"Because you watched {}",
crate::browse::display_name(seed, metadata, names)
),
keys,
})
}
fn ranked(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
admit: impl Fn(&Title) -> bool,
limit: usize,
) -> Vec<String> {
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let Some(seed) = titles
.iter()
.find(|title| title.key == key)
.and_then(found)
.map(Traits::of)
else {
return Vec::new();
};
let refs: Vec<&Title> = titles.iter().collect();
let franchise_of: Vec<usize> = {
let mut of = vec![0; titles.len()];
for (at, franchise) in crate::franchise::group(&refs, metadata)
.into_iter()
.enumerate()
{
for member in franchise.members {
of[member] = at;
}
}
of
};
let own = titles
.iter()
.position(|title| title.key == key)
.map(|at| franchise_of[at]);
// The best of each franchise: (score, popularity, index).
let mut best: HashMap<usize, (u32, u32, usize)> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
if Some(franchise_of[index]) == own || !admit(title) {
continue;
}
let Some(traits) = found(title).map(Traits::of) else {
continue;
};
let score = seed.score(&traits);
if score < MIN_SCORE {
continue;
}
let candidate = (score, traits.popularity, index);
best.entry(franchise_of[index])
.and_modify(|held| {
if (candidate.0, candidate.1) > (held.0, held.1) {
*held = candidate;
}
})
.or_insert(candidate);
}
let mut picked: Vec<(u32, u32, usize)> = best.into_values().collect();
// Best score, then most followed, then library order for a stable row.
picked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
picked
.into_iter()
.take(limit)
.map(|(_, _, index)| titles[index].key.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::{Library, TitleKind};
use crate::metadata::{ProviderId, TitleDetails};
struct Entry {
key: &'static str,
genres: &'static [&'static str],
studio: &'static str,
director: &'static str,
related: &'static [&'static str],
}
fn record(entry: &Entry) -> (String, MetadataRecord) {
(
entry.key.into(),
MetadataRecord {
title_key: entry.key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: entry.key.into(),
name: entry.key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: entry.genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
url: None,
details: Some(TitleDetails {
studios: vec![entry.studio.into()],
directors: vec![entry.director.into()],
related: entry.related.iter().map(|id| id.to_string()).collect(),
format: Some("TV".into()),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
},
)
}
fn file(key: &str) -> CatalogNode {
let name = format!("{key} - 01.mkv");
CatalogNode {
share_id: "s".into(),
link_id: key.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.clone(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&name),
}
}
const ENTRIES: [Entry; 5] = [
Entry {
key: "frieren",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "frieren film",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &["frieren"],
},
Entry {
key: "bocchi",
genres: &["Comedy", "Music"],
studio: "CloverWorks",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "mushishi",
genres: &["Adventure", "Drama", "Fantasy", "Mystery"],
studio: "Artland",
director: "Hiroshi Nagahama",
related: &[],
},
Entry {
key: "lucky star",
genres: &["Comedy"],
studio: "Kyoto Animation",
director: "Yasuhiro Takemoto",
related: &[],
},
];
fn library(watched: &[&str]) -> (Library, HashMap<String, MetadataRecord>) {
let watch: HashMap<(String, String), WatchState> = watched
.iter()
.enumerate()
.map(|(at, key)| {
(
("s".to_owned(), key.to_string()),
WatchState {
position_secs: 0.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: 100 + at as i64,
},
)
})
.collect();
let library = Library::build(
ENTRIES.iter().map(|entry| file(entry.key)).collect(),
&watch,
);
// The library's keys are the folder names, normalised; the fixture's
// already are.
(library, ENTRIES.iter().map(record).collect())
}
#[test]
fn more_like_this_leaves_out_the_franchise_and_ranks_a_shared_director_and_genres() {
let (library, metadata) = library(&[]);
let similar = more_like("frieren", &library.titles, &metadata, 10);
// Mushishi shares three genres, Bocchi only the director; both the
// format.
// Lucky Star shares nothing and its film is Frieren's own franchise.
assert_eq!(similar, ["mushishi", "bocchi"]);
}
#[test]
fn one_genre_in_common_is_not_a_recommendation() {
let (library, metadata) = library(&[]);
assert!(more_like("lucky star", &library.titles, &metadata, 10).is_empty());
}
#[test]
fn because_you_watched_follows_the_last_finished_title_and_skips_what_was_started() {
let (library, metadata) = library(&["mushishi", "frieren"]);
let row =
because_you_watched(&library.titles, &metadata, TitleNames::default(), 10).unwrap();
assert_eq!(row.label, "Because you watched frieren");
// Mushishi is like it, but already watched.
assert_eq!(row.keys, ["bocchi"]);
assert!(
because_you_watched(&library.titles, &HashMap::new(), TitleNames::default(), 10)
.is_none()
);
}
#[test]
fn a_facet_page_lists_the_titles_that_carry_it() {
let (library, metadata) = library(&[]);
let person = Facet::Person("Keiichirou Saitou".into());
assert_eq!(
person.titles(&library.titles, &metadata).len(),
3,
"both Frieren titles and Bocchi"
);
assert_eq!(person.kind(), "Director");
let genre = Facet::Genre("Comedy".into());
let mut comedy = genre.titles(&library.titles, &metadata);
comedy.sort();
assert_eq!(comedy, ["bocchi", "lucky star"]);
assert!(
Facet::Studio("MAPPA".into())
.titles(&library.titles, &metadata)
.is_empty()
);
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod browse;
pub mod catalog; pub mod catalog;
pub mod chapters; pub mod chapters;
pub mod config; pub mod config;
pub mod discover;
pub mod error; pub mod error;
pub mod franchise; pub mod franchise;
pub mod language; pub mod language;
+18 -2
View File
@@ -135,12 +135,17 @@ pub fn pick(
picked picked
} }
/// How many titles the "Because you watched" row offers.
const BECAUSE: usize = 12;
/// Every row above the library grid, in the order all three apps show them: /// Every row above the library grid, in the order all three apps show them:
/// new episodes of what is being watched, My List, what was recently added, /// new episodes of what is being watched, My List, what was recently added,
/// then up to `limit` [`pick`]ed shelves. /// what is like the last thing finished, then up to `limit` [`pick`]ed
/// shelves.
pub fn home( pub fn home(
titles: &[Title], titles: &[Title],
metadata: &HashMap<String, MetadataRecord>, metadata: &HashMap<String, MetadataRecord>,
names: crate::metadata::TitleNames,
list: &crate::mylist::MyList, list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>, first_seen: &HashMap<(String, String), i64>,
now: i64, now: i64,
@@ -163,6 +168,9 @@ pub fn home(
.into_iter() .into_iter()
.chain(mine) .chain(mine)
.chain(news.added) .chain(news.added)
.chain(crate::discover::because_you_watched(
titles, metadata, names, BECAUSE,
))
.chain(pick(titles, metadata, limit)) .chain(pick(titles, metadata, limit))
.collect() .collect()
} }
@@ -299,7 +307,15 @@ mod tests {
list.add("a", 1); list.add("a", 1);
list.add("b", 3); list.add("b", 3);
list.add("gone", 5); list.add("gone", 5);
let rows = home(&titles, &HashMap::new(), &list, &HashMap::new(), 10, 4); let rows = home(
&titles,
&HashMap::new(),
Default::default(),
&list,
&HashMap::new(),
10,
4,
);
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "My List"); assert_eq!(rows[0].label, "My List");
assert_eq!(rows[0].keys, ["b", "a"]); assert_eq!(rows[0].keys, ["b", "a"]);
+13 -2
View File
@@ -148,8 +148,19 @@ titles are one director's, and that is not a shelf — and a genre only under 40
of the library. Four at most, over the unfiltered library. Here: *Directed by of the library. Four at most, over the unfiltered library. Here: *Directed by
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*. Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
**The title page** adds the season, studios, director and next airing episode **The title page** adds the season and next airing episode to its line of
to its line of facts, and the tags beside the genres. facts, the tags beside the genres, and the studios and director as chips of
their own. Each genre, studio and director chip opens a page of every title
carrying it (`pstr_core::discover::Facet`).
**More like this.** `pstr_core::discover::more_like` scores every other title
by what it shares with this one — a director 4, a studio 3, a genre 2, one of
the first eight tags 1, the format 1 on top of anything else — and offers those
scoring 4 or more: a director, or two genres, but never one genre alone. The
title's own franchise is left out and each other franchise offers only its best
match. The same scoring, seeded by the title most recently watched to the end
and limited to titles not started, is the library's *Because you watched* row.
No request: it reads only what the match already stored.
**Android.** The same, through the bridge: `AndroidEngine::arrangement` **Android.** The same, through the bridge: `AndroidEngine::arrangement`
returns the grid's tiles and the shelves as keys into `library`'s titles, and returns the grid's tiles and the shelves as keys into `library`'s titles, and
+7
View File
@@ -26,6 +26,8 @@ show it once.
- **My List** has the titles you put aside to watch, the last one added first. - **My List** has the titles you put aside to watch, the last one added first.
- **Recently added** has the titles that arrived in the last two weeks and that you have not - **Recently added** has the titles that arrived in the last two weeks and that you have not
started. Their tiles say **New**. What was already there when you added a share is not new. started. Their tiles say **New**. What was already there when you added a share is not new.
- **Because you watched Frieren** has titles like the last one you watched to the end, that you
have not started. It needs posters and descriptions.
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when - **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
posters and descriptions are on. The app shows at most four, and only for a director, studio or posters and descriptions are on. The app shows at most four, and only for a director, studio or
genre with at least three titles. On a shelf, a show that is still airing says when its next genre with at least three titles. On a shelf, a show that is still airing says when its next
@@ -98,6 +100,11 @@ top.
its picture. Right-click an episode and choose **Mark 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.
- **More like this**, under the episodes, lists titles in your library that share the most with
this one: its director, studio, genres, tags and format. It leaves out the title's own
franchise, and shows one title from each other franchise. It needs posters and descriptions.
- The genre chips under the description open a page with every title of that genre in your
library. So do the **Studio** and **Director** chips. Back, or **Esc**, returns to the title.
- 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
page and on the library's banner, and each episode says how long it runs. A film says how long page and on the library's banner, and each episode says how long it runs. A film says how long
it runs in the line under its name. it runs in the line under its name.
+16
View File
@@ -548,6 +548,22 @@ final class AppModel {
} }
} }
/// The keys of the titles most like `key`, best first; none if the engine
/// cannot say.
func moreLike(_ key: String) async -> [String] {
(try? await run { try $0.moreLike(key: key) }) ?? []
}
/// The keys of every title `facet` stands for, in library order.
func browse(_ facet: BrowseFacet) async -> [String] {
do {
return try await run { try $0.browse(facet: facet) }
} catch {
reportError(error)
return []
}
}
// MARK: - Watch state // MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole /// Every episode of a title watched or unwatched, with an Undo: a whole
+57 -15
View File
@@ -89,6 +89,13 @@ struct RootView: View {
@SceneStorage("selectedTitle") private var selectedTitleKey: String? @SceneStorage("selectedTitle") private var selectedTitleKey: String?
@State private var incomingLink: String? @State private var incomingLink: String?
@State private var host = PlayerHost.shared @State private var host = PlayerHost.shared
/// What a genre, studio or director chip opened, over the library pane,
/// and the title it was opened from, which is where back goes.
@State private var browsing: BrowseFacet?
@State private var browseFrom: String?
@State private var browsed: [String] = []
/// "More like this" for the open title.
@State private var similar: [String] = []
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@@ -175,6 +182,7 @@ struct RootView: View {
private func select(_ target: Destination) { private func select(_ target: Destination) {
destination = target destination = target
selectedTitleKey = nil selectedTitleKey = nil
browsing = nil
} }
private func play(_ title: TitleRecord, _ index: Int) { private func play(_ title: TitleRecord, _ index: Int) {
@@ -209,22 +217,45 @@ struct RootView: View {
/// tap rather than back and in again. /// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View { @ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } } let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let library = LibraryView( let twoPane = width >= twoPaneWidth
onResume: play, let open = { (title: TitleRecord) in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }, let library = Group {
// The form as a pasted link opens it, only with nothing in it. if let browsing {
onAddShare: { BrowseView(
incomingLink = "" facet: browsing,
destination = .shares titles: browsed.compactMap { key in model.titles.first { $0.key == key } },
}, onBack: {
onOpenSettings: { select(.settings) } self.browsing = nil
) if let browseFrom { selectedTitleKey = browseFrom }
if width >= twoPaneWidth { },
onTitle: open
)
} else {
LibraryView(
onResume: play,
onTitle: open,
// The form as a pasted link opens it, only with nothing in it.
onAddShare: {
incomingLink = ""
destination = .shares
},
onOpenSettings: { select(.settings) }
)
}
}
.task(id: [browsing.map { "\($0)" } ?? "", "\(model.titles.count)"]) {
if let browsing {
browsed = await model.browse(browsing)
} else {
browsed = []
}
}
if twoPane {
HStack(spacing: 0) { HStack(spacing: 0) {
library.frame(width: width * 0.45) library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea() Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
Group { Group {
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") } if let selected { titlePane(selected, twoPane: true) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
@@ -237,7 +268,7 @@ struct RootView: View {
.allowsHitTesting(selected == nil) .allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil) .accessibilityHidden(selected != nil)
if let selected { if let selected {
titlePane(selected) titlePane(selected, twoPane: false)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9)))) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.zIndex(1) .zIndex(1)
} }
@@ -245,15 +276,26 @@ struct RootView: View {
} }
} }
private func titlePane(_ selected: TitleRecord) -> some View { private func titlePane(_ selected: TitleRecord, twoPane: Bool) -> some View {
TitleView( TitleView(
title: selected, title: selected,
onPlay: { _, index in host.play(selected, at: index) }, onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } }, onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } }, franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
onOpenTitle: { selectedTitleKey = $0.key } onOpenTitle: { selectedTitleKey = $0.key },
moreLike: similar.compactMap { key in model.titles.first { $0.key == key } },
onBrowse: { facet in
browsing = facet
browseFrom = selected.key
// On a phone the list is under the title; the page it opens
// is the library pane's.
if !twoPane { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } }
}
) )
.id(selected.key) .id(selected.key)
.task(id: [selected.key, "\(model.titles.count)"]) {
similar = await model.moreLike(selected.key)
}
} }
} }
+17 -3
View File
@@ -420,19 +420,33 @@ struct StatusChip: View {
} }
} }
/// Something the title is filed under — a genre — as a small rounded label. /// Something the title is filed under — a genre, a studio — as a small rounded
/// Android's `GenreChip`. /// label, which opens every title sharing it when given an action. Android's
/// `GenreChip`.
struct GenreChip: View { struct GenreChip: View {
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
let text: String let text: String
var action: (() -> Void)?
init(text: String, action: (() -> Void)? = nil) {
self.text = text
self.action = action
}
var body: some View { var body: some View {
Text(text) let label = Text(text)
.textStyle(.labelMedium) .textStyle(.labelMedium)
.foregroundStyle(scheme.onSurface) .foregroundStyle(scheme.onSurface)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12)) .background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
if let action {
Button(action: action) { label }
.buttonStyle(.plain)
.accessibilityHint("Browse \(text)")
} else {
label
}
} }
} }
+45
View File
@@ -801,3 +801,48 @@ extension EpisodeRecord {
extension TitleRecord: Identifiable { extension TitleRecord: Identifiable {
public var id: String { key } public var id: String { key }
} }
/// Every title sharing a genre, a studio or a director: what a chip on a title
/// page opens. Android's `BrowseScreen`, the desktop's `library::browse`.
struct BrowseView: View {
let facet: BrowseFacet
let titles: [TitleRecord]
let onBack: () -> Void
let onTitle: (TitleRecord) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
TabPage(facet.label, leading: {
IconButton("chevron.backward", "Back", tint: scheme.onSurface, action: onBack)
}) {
if titles.isEmpty {
// A recrawl or a new match took the last one away.
EmptyState(facet.label, "Nothing in the library has this any more.")
} else {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text("\(facetKind(facet: facet)) · \(titles.count == 1 ? "1 title" : "\(titles.count) titles")")
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurfaceVariant)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(titles, id: \.key) { title in
PosterTile(title: title)
.tapAndHold { onTitle(title) }
}
}
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
}
}
}
}
}
extension BrowseFacet {
/// The name it stands for: the chip's text and its page's heading.
var label: String {
switch self {
case .genre(let name), .studio(let name), .person(let name): name
}
}
}
+44 -15
View File
@@ -16,6 +16,10 @@ struct TitleView: View {
/// Every title of its franchise, in release order, this one included. /// Every title of its franchise, in release order, this one included.
var franchise: [TitleRecord] = [] var franchise: [TitleRecord] = []
var onOpenTitle: (TitleRecord) -> Void = { _ in } var onOpenTitle: (TitleRecord) -> Void = { _ in }
/// The titles most like it, best first.
var moreLike: [TitleRecord] = []
/// Open every title sharing a genre, studio or director.
var onBrowse: (BrowseFacet) -> Void = { _ in }
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
@@ -38,13 +42,17 @@ struct TitleView: View {
onPlay: @escaping ([EpisodeRecord], Int) -> Void, onPlay: @escaping ([EpisodeRecord], Int) -> Void,
onBack: @escaping () -> Void, onBack: @escaping () -> Void,
franchise: [TitleRecord] = [], franchise: [TitleRecord] = [],
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in } onOpenTitle: @escaping (TitleRecord) -> Void = { _ in },
moreLike: [TitleRecord] = [],
onBrowse: @escaping (BrowseFacet) -> Void = { _ in }
) { ) {
self.title = title self.title = title
self.onPlay = onPlay self.onPlay = onPlay
self.onBack = onBack self.onBack = onBack
self.franchise = franchise self.franchise = franchise
self.onOpenTitle = onOpenTitle self.onOpenTitle = onOpenTitle
self.moreLike = moreLike
self.onBrowse = onBrowse
// One season on screen at a time, opened on the one Play would land in. // One season on screen at a time, opened on the one Play would land in.
let playlist = title.playlist let playlist = title.playlist
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex) let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
@@ -71,7 +79,7 @@ struct TitleView: View {
heading(playlist: playlist, nextUp: nextUp, upNext: upNext) heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
.padding(.horizontal, side + 16) .padding(.horizontal, side + 16)
if franchise.count > 1 { if franchise.count > 1 {
franchiseRow(side: side) coverRow("In this franchise", franchise, side: side)
} }
seasonChips(season: season, side: side) seasonChips(season: season, side: side)
if let season { if let season {
@@ -91,6 +99,11 @@ struct TitleView: View {
.padding(.horizontal, side) .padding(.horizontal, side)
} }
} }
// Under the episodes rather than over them: where to go
// once this one is done, not what the page is for.
if !moreLike.isEmpty {
coverRow("More like this", moreLike, side: side)
}
} }
.padding(.bottom, 24) .padding(.bottom, 24)
} }
@@ -223,15 +236,6 @@ 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.
let makers: [String?] = [
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
title.directors.first.map { "dir. \($0)" },
]
let facts = makers.compactMap { $0 }.joined(separator: " · ")
if !facts.isEmpty {
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
}
// Where the title stands, then what the files are rather than // Where the title stands, then what the files are rather than
// what the title is. // what the title is.
let status = statusText(title) let status = statusText(title)
@@ -284,10 +288,33 @@ struct TitleView: View {
// tag is its community describing it, not what it is filed as. // tag is its community describing it, not what it is filed as.
if !title.genres.isEmpty { if !title.genres.isEmpty {
FlowLayout(spacing: 6) { FlowLayout(spacing: 6) {
ForEach(title.genres, id: \.self) { GenreChip(text: $0) } ForEach(title.genres, id: \.self) { genre in
GenreChip(text: genre) { onBrowse(.genre(name: genre)) }
}
} }
.padding(.top, 12) .padding(.top, 12)
} }
// Who made it, as chips that open everything else by them: the
// desktop's credits row.
let studios = Array(title.studios.prefix(2))
let director = Array(title.directors.prefix(1))
if !studios.isEmpty || !director.isEmpty {
FlowLayout(spacing: 6) {
if !studios.isEmpty {
Text("Studio").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
ForEach(studios, id: \.self) { name in
GenreChip(text: name) { onBrowse(.studio(name: name)) }
}
}
if !director.isEmpty {
Text("Director").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
ForEach(director, id: \.self) { name in
GenreChip(text: name) { onBrowse(.person(name: name)) }
}
}
}
.padding(.top, 8)
}
if !title.tags.isEmpty { if !title.tags.isEmpty {
Text(title.tags.prefix(4).joined(separator: " · ")) Text(title.tags.prefix(4).joined(separator: " · "))
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8) .textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
@@ -317,13 +344,15 @@ struct TitleView: View {
return "Play \(upNext.label)" return "Play \(upNext.label)"
} }
private func franchiseRow(side: CGFloat) -> some View { /// A row of covers: the franchise, with this title marked, or the titles
/// like it.
private func coverRow(_ heading: String, _ members: [TitleRecord], side: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("In this franchise").textStyle(.titleMedium).foregroundStyle(scheme.onSurface) Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, side + 16) .padding(.horizontal, side + 16)
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) { LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in ForEach(members, id: \.key) { member in
FranchiseTile(title: member, here: member.key == title.key) FranchiseTile(title: member, here: member.key == title.key)
.tapAndHold { if member.key != title.key { onOpenTitle(member) } } .tapAndHold { if member.key != title.key { onOpenTitle(member) } }
} }