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

+44 -15
View File
@@ -16,6 +16,10 @@ struct TitleView: View {
/// Every title of its franchise, in release order, this one included.
var franchise: [TitleRecord] = []
var onOpenTitle: (TitleRecord) -> Void = { _ in }
/// The titles most like it, best first.
var moreLike: [TitleRecord] = []
/// Open every title sharing a genre, studio or director.
var onBrowse: (BrowseFacet) -> Void = { _ in }
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@@ -38,13 +42,17 @@ struct TitleView: View {
onPlay: @escaping ([EpisodeRecord], Int) -> Void,
onBack: @escaping () -> Void,
franchise: [TitleRecord] = [],
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in }
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in },
moreLike: [TitleRecord] = [],
onBrowse: @escaping (BrowseFacet) -> Void = { _ in }
) {
self.title = title
self.onPlay = onPlay
self.onBack = onBack
self.franchise = franchise
self.onOpenTitle = onOpenTitle
self.moreLike = moreLike
self.onBrowse = onBrowse
// One season on screen at a time, opened on the one Play would land in.
let playlist = title.playlist
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
@@ -71,7 +79,7 @@ struct TitleView: View {
heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
.padding(.horizontal, side + 16)
if franchise.count > 1 {
franchiseRow(side: side)
coverRow("In this franchise", franchise, side: side)
}
seasonChips(season: season, side: side)
if let season {
@@ -91,6 +99,11 @@ struct TitleView: View {
.padding(.horizontal, side)
}
}
// Under the episodes rather than over them: where to go
// once this one is done, not what the page is for.
if !moreLike.isEmpty {
coverRow("More like this", moreLike, side: side)
}
}
.padding(.bottom, 24)
}
@@ -223,15 +236,6 @@ struct TitleView: View {
}
Text(factLine)
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
// Who made it.
let makers: [String?] = [
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
title.directors.first.map { "dir. \($0)" },
]
let facts = makers.compactMap { $0 }.joined(separator: " · ")
if !facts.isEmpty {
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
}
// Where the title stands, then what the files are rather than
// what the title is.
let status = statusText(title)
@@ -284,10 +288,33 @@ struct TitleView: View {
// tag is its community describing it, not what it is filed as.
if !title.genres.isEmpty {
FlowLayout(spacing: 6) {
ForEach(title.genres, id: \.self) { GenreChip(text: $0) }
ForEach(title.genres, id: \.self) { genre in
GenreChip(text: genre) { onBrowse(.genre(name: genre)) }
}
}
.padding(.top, 12)
}
// Who made it, as chips that open everything else by them: the
// desktop's credits row.
let studios = Array(title.studios.prefix(2))
let director = Array(title.directors.prefix(1))
if !studios.isEmpty || !director.isEmpty {
FlowLayout(spacing: 6) {
if !studios.isEmpty {
Text("Studio").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
ForEach(studios, id: \.self) { name in
GenreChip(text: name) { onBrowse(.studio(name: name)) }
}
}
if !director.isEmpty {
Text("Director").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
ForEach(director, id: \.self) { name in
GenreChip(text: name) { onBrowse(.person(name: name)) }
}
}
}
.padding(.top, 8)
}
if !title.tags.isEmpty {
Text(title.tags.prefix(4).joined(separator: " · "))
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
@@ -317,13 +344,15 @@ struct TitleView: View {
return "Play \(upNext.label)"
}
private func franchiseRow(side: CGFloat) -> some View {
/// A row of covers: the franchise, with this title marked, or the titles
/// like it.
private func coverRow(_ heading: String, _ members: [TitleRecord], side: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("In this franchise").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, side + 16)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in
ForEach(members, id: \.key) { member in
FranchiseTile(title: member, here: member.key == title.key)
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
}