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.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
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) =
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.foundation.border
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.layout.Box
import androidx.compose.foundation.shape.CircleShape
@@ -277,13 +279,15 @@ internal fun EpisodeRecord.heading(position: Int): String {
* The desktop's `widgets::chip`.
*/
@Composable
internal fun GenreChip(text: String) {
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
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),
)
}
@@ -111,6 +111,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
@@ -118,6 +119,7 @@ import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown
import uniffi.pstr_android.facetKind
/**
* 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
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -78,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
@@ -135,6 +137,28 @@ fun ProtonStreamApp(
// into the video they had just left.
var playerMinimized by rememberSaveable { 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 playing = playingTitleKey
@@ -172,7 +196,7 @@ fun ProtonStreamApp(
Destination.entries.forEach { item ->
item(
selected = destination == item,
onClick = { destination = item; selectedTitleKey = null },
onClick = { destination = item; selectedTitleKey = null; browseKind = null },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
colors = navigation,
@@ -211,8 +235,23 @@ fun ProtonStreamApp(
// library stays in view while a title is open, so moving
// between titles is one tap rather than back and in again.
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 {
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,
model::search,
{ title, index ->
@@ -238,6 +277,9 @@ fun ProtonStreamApp(
)
}
val titlePane = @Composable { selected: TitleRecord ->
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
value = model.moreLike(selected.key)
}
TitleScreen(
selected,
playerHost != null,
@@ -257,9 +299,17 @@ fun ProtonStreamApp(
},
onOpenTitle = { selectedTitleKey = it.key },
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()) {
Box(Modifier.weight(0.45f)) { libraryPane() }
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.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord
@@ -123,6 +124,10 @@ internal fun TitleScreen(
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> 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 download = rememberDownload()
@@ -260,19 +265,6 @@ internal fun TitleScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
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
// than what the title is.
val status = statusText(title)
@@ -368,7 +360,31 @@ internal fun TitleScreen(
horizontalArrangement = 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()) {
@@ -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)
}
@@ -546,8 +583,9 @@ private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack:
private val TITLE_BAR = 56.dp
/**
* One part of the franchise: its cover, what it is and when. This title's own
* is marked rather than left out, so the row says where it sits in the story.
* One part of the franchise, or one title like this one: its cover, what it is
* 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
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,
}
/// 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`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter {
@@ -479,9 +499,6 @@ pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
.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`;
/// see `pstr_core::news::countdown`.
#[uniffi::export]
@@ -489,6 +506,16 @@ pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
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]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| {
@@ -1700,6 +1727,7 @@ impl AndroidEngine {
pstr_core::shelves::home(
&cached.library.titles,
&cached.metadata,
cached.names,
&cached.list,
&cached.first_seen,
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
/// would not parse, rather than writing an empty one over it.
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.
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 {
Appearance {
flavor: match record.flavor {
@@ -4428,6 +4474,19 @@ mod tests {
.expect("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);
}
+61 -4
View File
@@ -50,6 +50,7 @@ impl FrameTimer {
Page::Library => "library",
Page::History => "history",
Page::Title(_) => "title",
Page::Browse { .. } => "browse",
Page::Shares => "shares",
Page::Downloads => "downloads",
Page::Settings => "settings",
@@ -97,6 +98,12 @@ pub enum Page {
History,
/// One title, by [`pstr_core::library::Title::key`].
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,
Downloads,
/// How the app looks, how it plays, and what it looks up.
@@ -420,6 +427,9 @@ pub struct LibraryView {
pub badges: HashMap<String, String>,
/// The keys on My List.
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.
stale: bool,
/// Only watch state changed: the shelves and franchises still stand,
@@ -457,6 +467,7 @@ impl Default for LibraryView {
resumable: Vec::new(),
badges: HashMap::new(),
listed: std::collections::HashSet::new(),
similar: None,
stale: false,
progressed: false,
}
@@ -483,6 +494,9 @@ pub struct ViewInputs<'a> {
/// How many shelves the library shows above the grid.
const SHELVES: usize = 4;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
impl LibraryView {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
use pstr_core::library::Title;
@@ -506,6 +520,7 @@ impl LibraryView {
}
let metadata = inputs.metadata;
if self.stale {
self.similar = None;
let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata)
.into_iter()
@@ -533,6 +548,7 @@ impl LibraryView {
self.shelves = pstr_core::shelves::home(
&library.titles,
metadata,
inputs.names,
inputs.list,
inputs.first_seen,
inputs.now,
@@ -1722,7 +1738,10 @@ impl App {
}
Action::WatchToEnd => self.up_next.dismissed = true,
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.focus_search = true;
@@ -1741,8 +1760,11 @@ impl App {
self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true;
}
Action::Back => match self.page {
Action::Back => match &self.page {
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
// last thing the viewer did.
Page::Library if !self.search.is_empty() => self.search.clear(),
@@ -2131,7 +2153,10 @@ impl App {
);
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
.downloads
.iter()
@@ -2452,6 +2477,18 @@ impl eframe::App for App {
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 App {
@@ -2580,6 +2617,12 @@ impl eframe::App for App {
key,
ui::title::Context {
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()),
},
ui::title::OfflineView {
@@ -2588,6 +2631,18 @@ impl eframe::App for App {
},
&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(
ui,
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)
|| 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))
{
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;
}
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.
/// `titles` as a wall of tiles. `folding` says they are the library grid's
/// own, whose folded franchises `view` counts.
fn grid(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
view: &LibraryView,
tiles: Tiles,
folding: bool,
actions: &mut Vec<Action>,
) {
let (minimum, aspect) = tile_shape(tiles);
@@ -780,6 +827,7 @@ fn grid(
let folded = view
.matches
.get(row * grid.columns + column)
.filter(|_| folding)
.and_then(|index| view.folded.get(index))
.copied();
let response = ui::card(
+95 -26
View File
@@ -20,6 +20,8 @@ pub struct OfflineView<'a> {
pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>,
/// The keys of the titles most like it, best first.
pub similar: &'a [String],
pub on_list: bool,
}
@@ -32,7 +34,11 @@ pub fn show(
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Context { franchise, on_list } = context;
let Context {
franchise,
similar,
on_list,
} = context;
let Some(title) = library.get(key) else {
// 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.
@@ -40,7 +46,7 @@ pub fn show(
return;
};
if back_link(ui).clicked() {
if back_link(ui, "Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
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();
if members.len() > 1 {
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);
}
}
@@ -137,23 +150,35 @@ pub fn show(
episode_row(ui, art, title, season, episode, &offline, actions);
}
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
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
/// A row of covers: every title of the franchise in release order, with
/// `current` marked rather than left out, so the row says where it sits in
/// the story — or, with no `current`, the titles like this one.
fn cover_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
id: impl std::hash::Hash + std::fmt::Debug,
current: Option<&Title>,
members: &[&Title],
actions: &mut Vec<Action>,
) {
ui::shelf(ui, ("franchise", &current.key), |ui| {
ui::shelf(ui, id, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
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 found = art
.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
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
/// filled button beside the page's real actions competed with them. `to`
/// names where it goes.
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()));
if response.hovered() {
ui.painter().hline(
@@ -495,7 +521,7 @@ fn header(
if let Some(found) = &found {
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.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
let tags = found
.details
.as_ref()
///
/// Its genres, studios and director are chips that open every title in the
/// library sharing them.
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())
.unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
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
// 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);
}
// 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 {
// Four lines, then "More". A provider's synopsis runs to three
// 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"));
}
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
if let Some(season) = details.season_label() {
parts.push(season);
}
parts.extend(details.studios.iter().take(2).cloned());
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
if let Some(season) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.season_label())
{
parts.push(season);
}
if let Some(found) = found {
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
}
/// 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 chapters;
pub mod config;
pub mod discover;
pub mod error;
pub mod franchise;
pub mod language;
+18 -2
View File
@@ -135,12 +135,17 @@ pub fn pick(
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:
/// 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(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: crate::metadata::TitleNames,
list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>,
now: i64,
@@ -163,6 +168,9 @@ pub fn home(
.into_iter()
.chain(mine)
.chain(news.added)
.chain(crate::discover::because_you_watched(
titles, metadata, names, BECAUSE,
))
.chain(pick(titles, metadata, limit))
.collect()
}
@@ -299,7 +307,15 @@ mod tests {
list.add("a", 1);
list.add("b", 3);
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[0].label, "My List");
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
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
**The title page** adds the season, studios, director and next airing episode
to its line of facts, and the tags beside the genres.
**The title page** adds the season and next airing episode to its line of
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`
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.
- **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.
- **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
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
@@ -98,6 +100,11 @@ top.
its picture. Right-click an episode and choose **Mark
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.
- **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
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.
+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
/// 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?
@State private var incomingLink: String?
@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 {
GeometryReader { geometry in
@@ -175,6 +182,7 @@ struct RootView: View {
private func select(_ target: Destination) {
destination = target
selectedTitleKey = nil
browsing = nil
}
private func play(_ title: TitleRecord, _ index: Int) {
@@ -209,22 +217,45 @@ struct RootView: View {
/// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let library = LibraryView(
onResume: play,
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } },
// The form as a pasted link opens it, only with nothing in it.
onAddShare: {
incomingLink = ""
destination = .shares
},
onOpenSettings: { select(.settings) }
)
if width >= twoPaneWidth {
let twoPane = width >= twoPaneWidth
let open = { (title: TitleRecord) in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }
let library = Group {
if let browsing {
BrowseView(
facet: browsing,
titles: browsed.compactMap { key in model.titles.first { $0.key == key } },
onBack: {
self.browsing = nil
if let browseFrom { selectedTitleKey = browseFrom }
},
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) {
library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
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)
}
@@ -237,7 +268,7 @@ struct RootView: View {
.allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil)
if let selected {
titlePane(selected)
titlePane(selected, twoPane: false)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.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(
title: selected,
onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
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)
.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.
/// Android's `GenreChip`.
/// Something the title is filed under — a genre, a studio — as a small rounded
/// label, which opens every title sharing it when given an action. Android's
/// `GenreChip`.
struct GenreChip: View {
@Environment(\.scheme) private var scheme
let text: String
var action: (() -> Void)?
init(text: String, action: (() -> Void)? = nil) {
self.text = text
self.action = action
}
var body: some View {
Text(text)
let label = Text(text)
.textStyle(.labelMedium)
.foregroundStyle(scheme.onSurface)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.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 {
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.
var franchise: [TitleRecord] = []
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(\.scheme) private var scheme
@@ -38,13 +42,17 @@ struct TitleView: View {
onPlay: @escaping ([EpisodeRecord], Int) -> Void,
onBack: @escaping () -> Void,
franchise: [TitleRecord] = [],
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in }
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in },
moreLike: [TitleRecord] = [],
onBrowse: @escaping (BrowseFacet) -> Void = { _ in }
) {
self.title = title
self.onPlay = onPlay
self.onBack = onBack
self.franchise = franchise
self.onOpenTitle = onOpenTitle
self.moreLike = moreLike
self.onBrowse = onBrowse
// One season on screen at a time, opened on the one Play would land in.
let playlist = title.playlist
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
@@ -71,7 +79,7 @@ struct TitleView: View {
heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
.padding(.horizontal, side + 16)
if franchise.count > 1 {
franchiseRow(side: side)
coverRow("In this franchise", franchise, side: side)
}
seasonChips(season: season, side: side)
if let season {
@@ -91,6 +99,11 @@ struct TitleView: View {
.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)
}
@@ -223,15 +236,6 @@ struct TitleView: View {
}
Text(factLine)
.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
// what the title is.
let status = statusText(title)
@@ -284,10 +288,33 @@ struct TitleView: View {
// tag is its community describing it, not what it is filed as.
if !title.genres.isEmpty {
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)
}
// 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 {
Text(title.tags.prefix(4).joined(separator: " · "))
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
@@ -317,13 +344,15 @@ struct TitleView: View {
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) {
Text("In this franchise").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, side + 16)
ScrollView(.horizontal, showsIndicators: false) {
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)
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
}