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 |
Reference in new issue
Block a user