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
@@ -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