mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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 |
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", ¤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<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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user