mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
766 lines
35 KiB
Swift
766 lines
35 KiB
Swift
import SwiftUI
|
|
|
|
/// How wide the page's text and controls run on a window wider than a phone.
|
|
private let readableWidth: CGFloat = 720
|
|
|
|
/// How fast the backdrop moves against the page scrolling over it.
|
|
private let parallax: CGFloat = 0.5
|
|
|
|
/// The bar over the page — Android's `TITLE_BAR`.
|
|
private let titleBarHeight: CGFloat = 56
|
|
|
|
struct TitleView: View {
|
|
let title: TitleRecord
|
|
let onPlay: ([EpisodeRecord], Int) -> Void
|
|
let onBack: () -> Void
|
|
/// 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
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var showMatch = false
|
|
@State private var downloading: DownloadRequest?
|
|
@State private var overviewOpen = false
|
|
@State private var seasonIndex: Int
|
|
// Back by an edge swipe: the page shrinks towards the library as the
|
|
// gesture is dragged, and springs back if it is let go of — Android's
|
|
// predictive back, so the viewer sees where back goes before committing.
|
|
@State private var backProgress: CGFloat = 0
|
|
/// How far the backdrop has scrolled up, which is how solid the bar is.
|
|
@State private var scrolled: CGFloat = 0
|
|
/// The backdrop's colour, once it has arrived — see `pictureMood`.
|
|
@State private var mood: UInt32?
|
|
|
|
init(
|
|
title: TitleRecord,
|
|
onPlay: @escaping ([EpisodeRecord], Int) -> Void,
|
|
onBack: @escaping () -> Void,
|
|
franchise: [TitleRecord] = [],
|
|
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)
|
|
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
|
|
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
|
|
}
|
|
|
|
var body: some View {
|
|
// Display order across seasons: what previous/next and autoplay walk.
|
|
let playlist = title.playlist
|
|
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
|
|
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
|
|
let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil
|
|
let downloads = DownloadCoordinator.shared.records
|
|
|
|
GeometryReader { geometry in
|
|
// On a tablet the page keeps a phone's reading width, centred. The
|
|
// backdrop still spans the window, but no taller than about half.
|
|
let side = max((geometry.size.width - readableWidth) / 2, 0)
|
|
let backdropHeight = min(geometry.size.width * 9 / 16, geometry.size.height * 0.55)
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
backdrop(height: backdropHeight + geometry.safeAreaInsets.top)
|
|
heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
|
|
.padding(.horizontal, side + 16)
|
|
if franchise.count > 1 {
|
|
coverRow("In this franchise", franchise, side: side)
|
|
}
|
|
seasonChips(season: season, side: side)
|
|
if let season {
|
|
ForEach(Array(season.episodes.enumerated()), id: \.element.linkId) { position, episode in
|
|
EpisodeRow(
|
|
episode: episode,
|
|
numbering: episode.numbering(season.number, position),
|
|
position: position,
|
|
download: downloads.first { $0.shareId == episode.shareId && $0.linkId == episode.linkId },
|
|
onPlay: { onPlay(playlist, playlist.firstIndex { $0.linkId == episode.linkId } ?? 0) },
|
|
onSetWatched: { model.setWatched(episode, $0) },
|
|
onWatchedUpTo: {
|
|
let upTo = playlist.firstIndex { $0.linkId == episode.linkId } ?? 0
|
|
model.markEpisodes(Array(playlist.prefix(upTo + 1)), true, "\(title.displayName) up to \(episode.label)")
|
|
}
|
|
)
|
|
.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)
|
|
}
|
|
.ignoresSafeArea(edges: .top)
|
|
.coordinateSpace(name: "title")
|
|
.background(scheme.background)
|
|
.overlay(alignment: .top) {
|
|
titleBar(solid: min(max((scrolled - (backdropHeight - 2 * titleBarHeight)) / titleBarHeight, 0), 1))
|
|
}
|
|
}
|
|
.scaleEffect(1 - 0.1 * backProgress)
|
|
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
|
|
.overlay(alignment: .leading) {
|
|
Color.clear
|
|
.frame(width: 20)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 10)
|
|
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
|
|
.onEnded { drag in
|
|
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
|
|
onBack()
|
|
}
|
|
withAnimation(.spring) { backProgress = 0 }
|
|
}
|
|
)
|
|
}
|
|
.confirmingDownload($downloading)
|
|
.sheet(isPresented: $showMatch) {
|
|
ChangeMatchSheet(title: title) { showMatch = false }
|
|
}
|
|
.onChange(of: title.key) {
|
|
overviewOpen = false
|
|
mood = nil
|
|
let playlist = title.playlist
|
|
let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil
|
|
seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
|
|
}
|
|
}
|
|
|
|
/// Edge to edge, fading into the page: the art is the first thing the page
|
|
/// says. It moves at half the speed of the page, so the picture stays
|
|
/// behind the page rather than being part of it.
|
|
private func backdrop(height: CGFloat) -> some View {
|
|
Backdrop(title: title) { mood = $0 }
|
|
.frame(height: height)
|
|
.frame(maxWidth: .infinity)
|
|
.visualEffect { content, proxy in
|
|
content.offset(y: max(-proxy.frame(in: .named("title")).minY, 0) * parallax)
|
|
}
|
|
.clipped()
|
|
.overlay {
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .clear, location: 0.4),
|
|
// The picture's own colour on the way into the page,
|
|
// as on the library's banner.
|
|
.init(color: scheme.veil(mood).opacity(0.55), location: 0.75),
|
|
.init(color: scheme.background, location: 1),
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.animation(Motion.arrive, value: mood)
|
|
}
|
|
.background {
|
|
GeometryReader { geometry in
|
|
Color.clear.onChange(of: geometry.frame(in: .named("title")).minY) { _, minY in
|
|
scrolled = -minY
|
|
}
|
|
}
|
|
}
|
|
// Scrolled far enough to leave the lazy stack: the bar is long
|
|
// since solid.
|
|
.onDisappear { scrolled = .greatestFiniteMagnitude }
|
|
.onAppear { scrolled = 0 }
|
|
}
|
|
|
|
/// The bar over the page: only the back button while the backdrop is on
|
|
/// screen, then a solid bar with the title's name once it has scrolled
|
|
/// away, so the page still says whose episodes these are.
|
|
private func titleBar(solid: CGFloat) -> some View {
|
|
HStack(spacing: 0) {
|
|
Button(action: onBack) {
|
|
Image(systemName: "chevron.backward")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(solid > 0.5 ? scheme.onSurface : .white)
|
|
.frame(width: 40, height: 40)
|
|
// A disc behind the arrow over the picture, gone once the
|
|
// bar is there to sit on.
|
|
.background(.black.opacity(0.4 * (1 - solid)), in: Circle())
|
|
.frame(width: 48, height: 48)
|
|
}
|
|
.accessibilityLabel("Back to library")
|
|
.padding(.horizontal, 8)
|
|
Text(title.displayName)
|
|
.textStyle(.titleLarge)
|
|
.foregroundStyle(scheme.onSurface)
|
|
.lineLimit(1)
|
|
.opacity(solid)
|
|
.padding(.trailing, 16)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: titleBarHeight)
|
|
.background(scheme.surfaceContainer.opacity(solid).ignoresSafeArea(edges: .top))
|
|
.animation(.easeOut(duration: 0.15), value: solid > 0.5)
|
|
}
|
|
|
|
@ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// A light page keeps the name: a clear logo is lettering made for
|
|
// dark art, white as often as not.
|
|
if scheme.light {
|
|
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
|
|
} else {
|
|
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) {
|
|
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
|
|
}
|
|
.padding(.bottom, 4)
|
|
}
|
|
// The other names it goes by: the share's when the shown one is the
|
|
// provider's, and the original-language one.
|
|
let names: [String?] = [
|
|
title.name != title.displayName ? title.name : nil,
|
|
title.originalName.flatMap { $0 != title.displayName && $0 != title.canonicalName ? $0 : nil },
|
|
]
|
|
let aliases = names.compactMap { $0 }.joined(separator: " · ")
|
|
if !aliases.isEmpty {
|
|
Text(aliases).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
|
}
|
|
Text(factLine)
|
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
|
|
// Where the title stands, then what the files are rather than
|
|
// what the title is.
|
|
let status = statusText(title)
|
|
if !title.quality.isEmpty || status != nil {
|
|
HStack(spacing: 4) {
|
|
if let status { StatusChip(text: status.text, live: status.live).padding(.trailing, 4) }
|
|
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
// The one thing the page is for, full width and naming what it will
|
|
// play: "Resume" alone left the viewer guessing which episode.
|
|
Button { onPlay(playlist, nextUp) } label: {
|
|
Label(playLabel(playlist: playlist, upNext: upNext), systemImage: "play.fill")
|
|
}
|
|
.buttonStyle(.accentWide)
|
|
.disabled(playlist.isEmpty)
|
|
.padding(.top, 16)
|
|
HStack(spacing: 0) {
|
|
TitleAction(icon: title.onList ? "checkmark" : "plus", label: "My List") {
|
|
model.setOnList(title, !title.onList)
|
|
}
|
|
.accessibilityValue(title.onList ? "On My List" : "Not on My List")
|
|
// Only where it would do something different: an unstarted show
|
|
// already starts at the beginning.
|
|
if upNext?.resumeAt != nil || title.watchedCount > 0 {
|
|
TitleAction(icon: "arrow.counterclockwise", label: "Start over") { onPlay(playlist, 0) }
|
|
}
|
|
TitleAction(icon: "arrow.down.circle", label: "Download") { downloading = download(playlist, title.displayName) }
|
|
TitleAction(icon: "pencil", label: "Match") { showMatch = true }
|
|
// The provider's own page for this title: where a viewer goes to
|
|
// check that the thing the app matched is what they have.
|
|
if let link = title.externalUrl.flatMap(URL.init(string:)) {
|
|
TitleAction(icon: "arrow.up.right.square", label: title.metadataProvider?.displayName ?? "Provider") {
|
|
openURL(link)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 12)
|
|
if let overview = title.overview {
|
|
// Three lines, then a tap for the rest.
|
|
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
.textStyle(.bodyLarge)
|
|
.foregroundStyle(scheme.onSurface)
|
|
.lineLimit(overviewOpen ? nil : 3)
|
|
.padding(.top, 12)
|
|
.onTapGesture { overviewOpen.toggle() }
|
|
}
|
|
// Genres as chips, then the provider's tags quieter after them: a
|
|
// tag is its community describing it, not what it is filed as.
|
|
if !title.genres.isEmpty {
|
|
FlowLayout(spacing: 6) {
|
|
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)
|
|
}
|
|
// Said plainly, because it changes what a re-match will do.
|
|
if title.manualMatch {
|
|
Text("Matched by hand").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var factLine: String {
|
|
let year: String? = (title.metadataYear ?? title.year).map { String($0) }
|
|
let rating: String? = title.rating.map { String(format: "★ %.1f", $0) }
|
|
let watched: String? = title.episodeCount > 1 ? "\(title.watchedCount) of \(title.episodeCount) watched" : nil
|
|
// A series' running time is its episodes', which each row says.
|
|
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
|
|
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched]
|
|
return parts.compactMap { $0 }.joined(separator: " · ")
|
|
}
|
|
|
|
private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String {
|
|
guard let upNext else { return "Play" }
|
|
if upNext.resumeAt != nil { return "Resume \(upNext.label)" }
|
|
if title.watchedCount > 0 { return "Continue with \(upNext.label)" }
|
|
if playlist.count == 1 { return "Play" }
|
|
return "Play \(upNext.label)"
|
|
}
|
|
|
|
/// 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(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
|
.padding(.horizontal, side + 16)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
LazyHStack(alignment: .top, spacing: 12) {
|
|
ForEach(members, id: \.key) { member in
|
|
FranchiseTile(title: member, here: member.key == title.key)
|
|
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
|
|
}
|
|
}
|
|
.padding(.horizontal, side + 16)
|
|
}
|
|
}
|
|
.padding(.top, 12)
|
|
}
|
|
|
|
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
|
|
HStack(spacing: 0) {
|
|
if title.seasons.count > seasonChipsMax {
|
|
// A long-running show's row of chips is a scroll of its own;
|
|
// past a handful it is a list to pick from.
|
|
Menu {
|
|
Picker("Season", selection: $seasonIndex) {
|
|
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
|
|
Text(entry.label).tag(index)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex].label : "")
|
|
Image(systemName: "chevron.down").font(.system(size: 12, weight: .semibold))
|
|
}
|
|
.textStyle(.labelLarge)
|
|
.foregroundStyle(scheme.onSecondaryContainer)
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 32)
|
|
.background(scheme.secondaryContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
|
|
}
|
|
Spacer(minLength: 0)
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
// By position: two seasons can share a label.
|
|
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
|
|
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let season {
|
|
// For someone joining the show partway, who would otherwise
|
|
// tick every earlier episode one by one.
|
|
let seen = season.episodes.allSatisfy(\.watched)
|
|
IconButton(seen ? "checkmark.circle.badge.xmark" : "checkmark.circle", "Mark \(season.label) \(seen ? "unwatched" : "watched")") {
|
|
model.markEpisodes(season.episodes, !seen, "\(title.displayName) \(season.label)")
|
|
}
|
|
IconButton("arrow.down.circle", "Download \(season.label)") {
|
|
downloading = download(season.episodes, "\(title.displayName) \(season.label)")
|
|
}
|
|
}
|
|
}
|
|
.padding(.leading, side + 16)
|
|
.padding(.trailing, side + 4)
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
|
|
/// Material's filter chip: outlined, or filled with a tick when selected.
|
|
struct FilterChip: View {
|
|
let label: String
|
|
let selected: Bool
|
|
let action: () -> Void
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 6) {
|
|
if selected { Image(systemName: "checkmark").font(.system(size: 13, weight: .semibold)) }
|
|
Text(label).textStyle(.labelLarge)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(height: 32)
|
|
.foregroundStyle(selected ? scheme.onSecondaryContainer : scheme.onSurfaceVariant)
|
|
.background(selected ? scheme.secondaryContainer : .clear, in: RoundedRectangle(cornerRadius: Corner.standard))
|
|
.overlay {
|
|
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
|
|
}
|
|
}
|
|
.buttonStyle(.pressable)
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
private struct FranchiseTile: View {
|
|
let title: TitleRecord
|
|
let here: Bool
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
|
|
.aspectRatio(2 / 3, contentMode: .fit)
|
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
|
.overlay {
|
|
if here { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.primary, lineWidth: 2) }
|
|
}
|
|
Text(title.displayName).textStyle(.labelMedium, semibold: here).foregroundStyle(scheme.onSurface)
|
|
.lineLimit(2).padding(.top, 6)
|
|
Text([title.formatLabel ?? (title.kind == .film ? "Film" : "Series"), (title.metadataYear ?? title.year).map(String.init)]
|
|
.compactMap { $0 }.joined(separator: " · "))
|
|
.textStyle(.labelSmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
|
}
|
|
.frame(width: 96)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
/// The most seasons picked from a row of chips; past it, a list. Android's
|
|
/// `SEASON_CHIPS`.
|
|
private let seasonChipsMax = 5
|
|
|
|
/// What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
|
|
/// and whether more is coming. Android's `statusText`.
|
|
func statusText(_ title: TitleRecord) -> (text: String, live: Bool)? {
|
|
let next = title.nextAiringAt.flatMap { at in title.nextEpisode.map { (episode: $0, at: at) } }
|
|
guard let label = title.status ?? (next != nil || title.airing ? "Airing" : nil) else { return nil }
|
|
let live = next != nil || title.airing
|
|
guard let next else { return (label, live) }
|
|
let format = DateFormatter()
|
|
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
|
|
return ("\(label) · episode \(next.episode) " + format.string(from: Date(timeIntervalSince1970: TimeInterval(next.at))), live)
|
|
}
|
|
|
|
/// One of the title's secondary actions: an icon with its name under it.
|
|
private struct TitleAction: View {
|
|
let icon: String
|
|
let label: String
|
|
let action: () -> Void
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: icon).font(.system(size: 22))
|
|
Text(label).textStyle(.labelMedium).lineLimit(1)
|
|
}
|
|
.foregroundStyle(scheme.onSurface)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.pressable)
|
|
}
|
|
}
|
|
|
|
extension MetadataProvider {
|
|
var displayName: String {
|
|
switch self {
|
|
case .aniList: "AniList"
|
|
case .tmdb: "TMDB"
|
|
case .tvMaze: "TVmaze"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MatchRecord {
|
|
/// Unique across one search: the same id can come back from two providers.
|
|
var choiceId: String { "\(provider.displayName):\(remoteId)" }
|
|
}
|
|
|
|
extension EpisodeRecord {
|
|
/// `S03E38`, `E38`, or the row's place in the season.
|
|
///
|
|
/// Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
|
|
/// fallback to the season folder's number. The last case is deliberately
|
|
/// *not* printed as an episode number — `#4` says "fourth in the list"
|
|
/// where `E04` would be a claim about the show.
|
|
func numbering(_ fallbackSeason: UInt32?, _ position: Int) -> String {
|
|
switch (season ?? fallbackSeason, number) {
|
|
case let (season?, number?): String(format: "S%02dE%02d", season, number)
|
|
case let (nil, number?): String(format: "E%02d", number)
|
|
default: "#\(position + 1)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One episode: play it, see where it was left, and drive its download.
|
|
///
|
|
/// The row *is* the play button — the still, the number and the name are one
|
|
/// target, which is what lets the trailing controls be two icons in a fixed
|
|
/// column rather than a second row of buttons under every episode.
|
|
private struct EpisodeRow: View {
|
|
let episode: EpisodeRecord
|
|
let numbering: String
|
|
let position: Int
|
|
let download: RetainedDownload?
|
|
let onPlay: () -> Void
|
|
let onSetWatched: (Bool) -> Void
|
|
/// Everything up to and including this one, for someone joining a show
|
|
/// partway.
|
|
let onWatchedUpTo: () -> Void
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var holding = false
|
|
|
|
private var subline: String {
|
|
// How long it runs says more than how big it is; the size is what
|
|
// there is when no provider said.
|
|
let length: String? = episode.runtime.map(formatRuntime) ?? episode.size.map { formatBytes($0) }
|
|
let parts: [String?] = [numbering, length, episode.airDate]
|
|
return parts.compactMap { $0 }.joined(separator: " · ")
|
|
}
|
|
|
|
var body: some View {
|
|
let running = download?.status == RetainedDownload.statusRunning || download?.status == RetainedDownload.statusQueued
|
|
let heading = episode.heading(position)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
RemoteArtwork(episode.stillUrl, heading, fallback: episode.thumbnailSource, labelled: false)
|
|
.frame(width: 128)
|
|
.aspectRatio(16 / 9, contentMode: .fit)
|
|
.overlay(alignment: .bottom) {
|
|
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
|
|
}
|
|
// Seen, said on the picture as well as by the mark at the
|
|
// row's end: a season is scanned by its stills.
|
|
.overlay(alignment: .topTrailing) {
|
|
if episode.watched {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(scheme.onPrimary)
|
|
.frame(width: 18, height: 18)
|
|
.background(scheme.primary, in: Circle())
|
|
.padding(5)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// Seen episodes stay legible but stop competing with the
|
|
// one the viewer has not watched yet.
|
|
Text(heading).textStyle(.titleSmall)
|
|
.foregroundStyle(episode.watched ? scheme.onSurfaceVariant : scheme.onSurface)
|
|
.lineLimit(2)
|
|
Text(subline)
|
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 12)
|
|
// Watched is a judgement the viewer is allowed to overrule.
|
|
IconButton(
|
|
episode.watched ? "checkmark.circle.fill" : "checkmark.circle",
|
|
episode.watched ? "Mark unwatched" : "Mark watched",
|
|
tint: episode.watched ? scheme.primary : scheme.outline
|
|
) { onSetWatched(!episode.watched) }
|
|
// One slot, always the same width, whatever state the download
|
|
// is in.
|
|
Group {
|
|
if episode.offline {
|
|
Image(systemName: "checkmark.icloud")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(scheme.tertiary)
|
|
.accessibilityLabel("Saved offline")
|
|
} else if running, let download {
|
|
IconButton("pause.fill", "Pause download") { DownloadCoordinator.shared.pause(download) }
|
|
} else if let download {
|
|
IconButton("arrow.down.circle", "Resume download") { DownloadCoordinator.shared.resume(download) }
|
|
} else {
|
|
IconButton("arrow.down.circle", "Download") { DownloadCoordinator.shared.enqueue(episode) }
|
|
}
|
|
}
|
|
.frame(width: 48, height: 48)
|
|
}
|
|
if let overview = episode.providerOverview {
|
|
Text(overview).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(2).padding(.top, 6)
|
|
}
|
|
if let active = download, active.total > 0, !episode.offline {
|
|
AccentProgress(progress: Double(active.downloaded) / Double(active.total), solid: scheme.tertiary)
|
|
.padding(.top, 8)
|
|
Text(downloadStatusLine(active, waitingFor: DownloadCoordinator.shared.queueReason)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
.tapAndHold(onPlay) { holding = true }
|
|
.confirmationDialog(episode.heading(position), isPresented: $holding) {
|
|
Button("Mark watched up to here", action: onWatchedUpTo)
|
|
}
|
|
.accessibilityAction(named: "Mark watched up to here", onWatchedUpTo)
|
|
}
|
|
}
|
|
|
|
/// Search the configured provider for the right entry, choose it, or forget
|
|
/// the match altogether.
|
|
struct ChangeMatchSheet: View {
|
|
let title: TitleRecord
|
|
let onDone: () -> Void
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var term: String
|
|
@State private var searching = false
|
|
// Choosing fetches the entry's episodes too, which is a few requests.
|
|
@State private var choosing = false
|
|
@State private var options: [MatchRecord] = []
|
|
/// Said here rather than in the snackbar, which this sheet covers.
|
|
@State private var failure: String?
|
|
@State private var searched = false
|
|
@State private var forgetting = false
|
|
@State private var confirmForget = false
|
|
|
|
init(title: TitleRecord, onDone: @escaping () -> Void) {
|
|
self.title = title
|
|
self.onDone = onDone
|
|
_term = State(initialValue: title.canonicalName ?? title.name)
|
|
}
|
|
|
|
var body: some View {
|
|
DialogSheet("Change match") {
|
|
Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
|
|
Field(label: "Title", text: $term, submit: .search)
|
|
.onSubmit(search)
|
|
Button(searching ? "Searching…" : "Search", action: search)
|
|
.buttonStyle(.accent)
|
|
.disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching)
|
|
if choosing {
|
|
HStack(spacing: 12) {
|
|
ProgressView().controlSize(.small).tint(scheme.primary)
|
|
Text("Fetching its episodes…").textStyle(.bodySmall)
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
if let failure {
|
|
Text(failure).foregroundStyle(scheme.error)
|
|
}
|
|
if !searching, options.isEmpty, searched {
|
|
Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall)
|
|
}
|
|
ForEach(options, id: \.choiceId) { option in
|
|
Button { choose(option) } label: {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(option.name).textStyle(.labelLarge, semibold: true)
|
|
Text([option.year.map(String.init), option.originalName, option.provider.displayName].compactMap { $0 }.joined(separator: " · "))
|
|
.textStyle(.labelLarge)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.buttonStyle(.edgedWide)
|
|
.disabled(choosing)
|
|
}
|
|
} buttons: {
|
|
Button(forgetting ? "Forgetting…" : "Forget match") { confirmForget = true }
|
|
.buttonStyle(.quiet)
|
|
.disabled(forgetting || choosing)
|
|
Button("Close", action: onDone).buttonStyle(.tonal)
|
|
}
|
|
// Asked, because a match chosen by hand is work the next look-up does
|
|
// not redo: it decides again on its own.
|
|
.alert("Forget this match?", isPresented: $confirmForget) {
|
|
Button("Forget", role: .destructive, action: forget)
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Its poster, description and episode names go, and the next look-up matches it again on its own. A match you chose by hand is lost.")
|
|
}
|
|
}
|
|
|
|
private func forget() {
|
|
forgetting = true
|
|
failure = nil
|
|
Task {
|
|
do {
|
|
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
|
changed()
|
|
} catch {
|
|
failure = errorMessage(error)
|
|
}
|
|
forgetting = false
|
|
}
|
|
}
|
|
|
|
private func search() {
|
|
let term = term
|
|
guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return }
|
|
searching = true
|
|
failure = nil
|
|
Task {
|
|
do {
|
|
options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term)
|
|
} catch {
|
|
failure = errorMessage(error)
|
|
}
|
|
searching = false
|
|
searched = true
|
|
}
|
|
}
|
|
|
|
private func choose(_ option: MatchRecord) {
|
|
choosing = true
|
|
failure = nil
|
|
Task {
|
|
do {
|
|
try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option)
|
|
changed()
|
|
} catch {
|
|
failure = errorMessage(error)
|
|
}
|
|
choosing = false
|
|
}
|
|
}
|
|
|
|
private func changed() {
|
|
onDone()
|
|
model.reloadAfterMetadataChange()
|
|
}
|
|
}
|