diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index c3a5b54..f2a6d8f 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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 = + 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 = + 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) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 0dca4df..6d6ad94 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -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), ) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 5c77767..b19be3e 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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, + 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 + } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 4d7a8dc..b648ab6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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(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(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(), 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(), 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() diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index dad0e1d..852b2a7 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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 = emptyList(), onOpenTitle: (TitleRecord) -> Unit = {}, onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> }, + /** The titles most like it, best first. */ + moreLike: List = 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) { diff --git a/android/app/src/test/screenshots/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index 0d09783..953317a 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 5871ea4..82e0218 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index 1f38fa3..998d9b4 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 44ab33e..dd992da 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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) -> Option { .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, 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, 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); } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 8e06ff8..61c3ddc 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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, + }, Shares, Downloads, /// How the app looks, how it plays, and what it looks up. @@ -420,6 +427,9 @@ pub struct LibraryView { pub badges: HashMap, /// The keys on My List. pub listed: std::collections::HashSet, + /// "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)>, /// 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) } 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); diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index be30464..92ead1a 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -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, +) { + 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, ) { 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( diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 86cd54d..76ecb31 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -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, ) { - 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, ) { - ui::shelf(ui, ("franchise", ¤t.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 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) { + 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 { diff --git a/crates/pstr-app/src/ui/widgets.rs b/crates/pstr-app/src/ui/widgets.rs index 759092a..95921a7 100644 --- a/crates/pstr-app/src/ui/widgets.rs +++ b/crates/pstr-app/src/ui/widgets.rs @@ -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 +} diff --git a/crates/pstr-core/src/discover.rs b/crates/pstr-core/src/discover.rs new file mode 100644 index 0000000..7c272cd --- /dev/null +++ b/crates/pstr-core/src/discover.rs @@ -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, + ) -> Vec { + 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>, 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, + limit: usize, +) -> Vec { + 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, + names: TitleNames, + limit: usize, +) -> Option { + 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, + admit: impl Fn(&Title) -> bool, + limit: usize, +) -> Vec { + 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 = { + 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 = 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) { + 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() + ); + } +} diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index f0f77ed..4d59235 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -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; diff --git a/crates/pstr-core/src/shelves.rs b/crates/pstr-core/src/shelves.rs index e2f9946..f84f661 100644 --- a/crates/pstr-core/src/shelves.rs +++ b/crates/pstr-core/src/shelves.rs @@ -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, + 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"]); diff --git a/docs/METADATA.md b/docs/METADATA.md index d062765..8b2b70f 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ed0f6f5..84874b4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index e2a8f91..041ce94 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -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 diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index 4b0a92f..7cf1233 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -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) + } } } diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 69b01f2..5648753 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -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 + } } } diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 0c35642..9a51b7e 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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 + } + } +} diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 2ebacbb..b3cf25f 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -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) } } }