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

+16
View File
@@ -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
+57 -15
View File
@@ -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)
}
}
}
+17 -3
View File
@@ -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
}
}
}
+45
View File
@@ -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
}
}
}
+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) } }
}