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

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

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

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

No files matched your search

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

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 KiB

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB