mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
AniList's fragment now asks for the first ten characters, main roles first, with their Japanese voice actor, and for the trailer's site and id. Both are stored in the existing details JSON as TitleDetails.cast and .trailer with serde defaults, so no migration is needed and details stored before them still read. The details REVISION goes to 2, so a stored match is refetched by its id on the next metadata run and the cast fills in without anyone matching the title again. The title page shows a Cast row of round portraits with the character's name over the actor's. Clicking a tile opens the Person browse page for that actor, which now also admits voice actors, and search counts them under People and studios. A Trailer button opens the YouTube or Dailymotion page in the browser; the app itself makes no request to either, and an unknown site or an id with odd characters gives no link.
810 lines
38 KiB
Swift
810 lines
38 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)
|
|
}
|
|
}
|
|
if !title.cast.isEmpty {
|
|
castRow(side: 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() }
|
|
}
|
|
// In the browser: the app itself never requests it.
|
|
if let trailer = title.trailerUrl.flatMap(URL.init(string:)) {
|
|
Button { openURL(trailer) } label: {
|
|
Label("Trailer", systemImage: "film")
|
|
}
|
|
.buttonStyle(.quiet)
|
|
.padding(.top, 4)
|
|
}
|
|
// 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)"
|
|
}
|
|
|
|
/// The characters, each a round portrait over their name and who voices
|
|
/// them. A voice actor opens every title in the library they are in.
|
|
private func castRow(side: CGFloat) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Cast").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
|
.padding(.horizontal, side + 16)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
LazyHStack(alignment: .top, spacing: 16) {
|
|
// By position: two characters can share a name.
|
|
ForEach(Array(title.cast.enumerated()), id: \.offset) { _, member in
|
|
VStack(spacing: 2) {
|
|
RemoteArtwork(member.characterImage ?? member.actorImage, member.character, labelled: false)
|
|
.frame(width: 76, height: 76)
|
|
.clipShape(Circle())
|
|
Text(member.character).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
|
.lineLimit(1).padding(.top, 4)
|
|
if let actor = member.actor {
|
|
Text(actor).textStyle(.labelSmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
|
}
|
|
}
|
|
.frame(width: 88)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { if let actor = member.actor { onBrowse(.person(name: actor)) } }
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityHint(member.actor.map { "Browse \($0)" } ?? "")
|
|
}
|
|
}
|
|
.padding(.horizontal, side + 16)
|
|
}
|
|
}
|
|
.padding(.top, 16)
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|