mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
ani.zip sent each series' TVDB clear logo and every episode's running time from the start, and neither was kept. Enrichment now stores the logo in title_metadata.logo_url (kept across a refresh of the same entry, like the fanart) and episode runtimes in episode_metadata.runtime, TVDB's first and AniDB's length after it. AniList's duration lands in TitleDetails::runtime; TMDB episode runtimes are kept too. Schema v13 adds the two columns and marks AniList titles un-enriched without deleting anything. TitleDetails::revision lets details written before runtime existed refetch by id while the old ones stay on screen, instead of blanking them in the migration as v10 did. Clear logos are padded to a square, so each client crops them to their visible pixels before drawing. The logo replaces the name on the library's banner and the title page (not on a light theme's title page, where lettering made for dark art can vanish). Episode rows show how long they run, in place of the file size on Android and iOS; a film's runtime joins its line of facts.
634 lines
28 KiB
Swift
634 lines
28 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
|
||
/// How many shows the Continue watching shelf holds: a shelf is a shortcut,
|
||
/// not a second library.
|
||
private let continueWatchingMax = 12
|
||
|
||
/// One row of the Continue watching shelf: the episode, and where it sits.
|
||
struct Resumable: Identifiable {
|
||
let title: TitleRecord
|
||
let episode: EpisodeRecord
|
||
let index: Int
|
||
/// When the title was last played, which orders the shelf.
|
||
let lastPlayed: Int64
|
||
|
||
var id: String { "\(episode.shareId)/\(episode.linkId)" }
|
||
}
|
||
|
||
/// One tile of the grid: the title drawn, and how many more of its franchise
|
||
/// it stands for.
|
||
private struct Tile: Identifiable {
|
||
let title: TitleRecord
|
||
let folded: Int
|
||
var id: String { title.key }
|
||
}
|
||
|
||
/// A row above the grid: titles that share a director, a studio or a genre.
|
||
private struct Shelf: Identifiable {
|
||
let label: String
|
||
let titles: [TitleRecord]
|
||
var id: String { label }
|
||
}
|
||
|
||
/// Most recently played first, one episode per show: a shelf that lists four
|
||
/// episodes of the same series is a shelf with room for nothing else. Which
|
||
/// episode is the engine's call (`continueIndex`): the part-watched one, or the
|
||
/// one after the episode finished last.
|
||
func resumable(_ titles: [TitleRecord]) -> [Resumable] {
|
||
titles.compactMap { title -> Resumable? in
|
||
guard let index = title.continueIndex.map(Int.init) else { return nil }
|
||
let playlist = title.playlist
|
||
guard playlist.indices.contains(index) else { return nil }
|
||
return Resumable(
|
||
title: title,
|
||
episode: playlist[index],
|
||
index: index,
|
||
lastPlayed: playlist.map(\.lastPlayed).max() ?? 0
|
||
)
|
||
}
|
||
.sorted { $0.lastPlayed > $1.lastPlayed }
|
||
.prefix(continueWatchingMax)
|
||
.map { $0 }
|
||
}
|
||
|
||
struct LibraryView: View {
|
||
@Environment(AppModel.self) private var model
|
||
@Environment(\.scheme) private var scheme
|
||
let onResume: (TitleRecord, Int) -> Void
|
||
let onTitle: (TitleRecord) -> Void
|
||
var onAddShare: () -> Void = {}
|
||
var onOpenSettings: () -> Void = {}
|
||
|
||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||
@State private var menuFor: TitleRecord?
|
||
@State private var menuResume: Resumable?
|
||
@State private var matching: TitleRecord?
|
||
@State private var downloading: DownloadRequest?
|
||
// Posters and synopses, offered once there is a library to put them on
|
||
// and never turned on unasked: switching them on sends title names out.
|
||
@State private var offerDismissed = SettingsStore().metadataOfferDismissed
|
||
/// The same, for a download asked about once the menu has gone.
|
||
@State private var pendingDownload: DownloadRequest?
|
||
/// "Change match" picked in the tile menu, waiting for that menu to finish
|
||
/// leaving: a sheet presented while another is still dismissing is dropped.
|
||
@State private var pendingMatch: TitleRecord?
|
||
// Search is an icon until it is wanted: a permanent field above the
|
||
// library spent a band of every visit on something used in few of them.
|
||
@SceneStorage("library.searching") private var searching = false
|
||
@State private var collapsed = false
|
||
|
||
var body: some View {
|
||
let resumable = resumable(model.titles)
|
||
VStack(spacing: 0) {
|
||
if searching {
|
||
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
|
||
searching = false
|
||
model.search("")
|
||
}
|
||
} else {
|
||
HStack(spacing: 0) {
|
||
// The large title folds into the bar as the grid scrolls.
|
||
Text("Library")
|
||
.textStyle(.titleLarge)
|
||
.foregroundStyle(scheme.onSurface)
|
||
.padding(.leading, 16)
|
||
.opacity(collapsed ? 1 : 0)
|
||
Spacer()
|
||
IconButton("magnifyingglass", "Search library") { searching = true }
|
||
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
|
||
// Pull to refresh is the gesture; the icon stays for a
|
||
// viewer who does not know it.
|
||
IconButton("arrow.clockwise", "Refresh", enabled: !model.refreshing) { model.refresh() }
|
||
}
|
||
.frame(height: 56)
|
||
.padding(.trailing, 4)
|
||
.background(collapsed ? scheme.surfaceContainer : scheme.background)
|
||
}
|
||
content(resumable)
|
||
}
|
||
.background(scheme.background)
|
||
.sheet(item: $menuFor, onDismiss: {
|
||
// After the dismissal rather than a guessed delay: on a slow iPad
|
||
// the menu was still leaving after 350 ms, the search sheet was
|
||
// dropped, and it turned up later on some unrelated redraw.
|
||
if let title = pendingMatch {
|
||
pendingMatch = nil
|
||
matching = title
|
||
}
|
||
downloading = pendingDownload
|
||
pendingDownload = nil
|
||
}) { title in
|
||
let playlist = title.playlist
|
||
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
|
||
TileMenu(heading: title.displayName, caption: title.caption) {
|
||
MenuRow("play.fill", playlist.contains { $0.resumeAt != nil } ? "Resume" : "Play") {
|
||
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
|
||
}
|
||
MenuRow("info.circle", "Open") { onTitle(title) }
|
||
MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) }
|
||
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
|
||
model.setTitleWatched(title, !allWatched)
|
||
}
|
||
// The same search the title page offers, for the tile that is
|
||
// plainly wrong — the wrong poster is what the grid shows.
|
||
MenuRow("pencil", "Change match") { pendingMatch = title }
|
||
}
|
||
}
|
||
.sheet(item: $menuResume) { entry in
|
||
TileMenu(heading: entry.title.displayName, caption: entry.episode.label) {
|
||
MenuRow("play.fill", "Resume") { onResume(entry.title, entry.index) }
|
||
MenuRow("info.circle", "Open") { onTitle(entry.title) }
|
||
MenuRow("minus.circle", "Remove from Continue watching") {
|
||
model.forgetPosition(entry.title, entry.episode)
|
||
}
|
||
}
|
||
}
|
||
.confirmingDownload($downloading)
|
||
.sheet(item: $matching) { title in
|
||
ChangeMatchSheet(title: title) { matching = nil }
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func content(_ resumable: [Resumable]) -> some View {
|
||
if model.loading {
|
||
LibrarySkeleton()
|
||
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
||
} else if model.titles.isEmpty, let activity = model.activity {
|
||
// A share just added: say it is being looked through, rather than
|
||
// offer to add the one that is already filling the page.
|
||
EmptyState("Looking through the share", "\(activity) Its titles appear here once it has been listed.")
|
||
} else if model.titles.isEmpty {
|
||
ScrollView {
|
||
EmptyState(
|
||
"Your library is empty",
|
||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||
action: ("Add a share", onAddShare)
|
||
)
|
||
.containerRelativeFrame(.vertical)
|
||
}
|
||
.refreshable { await model.crawlNow(nil) }
|
||
} else {
|
||
grid(resumable)
|
||
}
|
||
}
|
||
|
||
private func grid(_ resumable: [Resumable]) -> some View {
|
||
// The arrangement names titles by key; before it has arrived, the
|
||
// titles as they came are the grid.
|
||
let byKey = Dictionary(model.titles.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
|
||
let tiles = model.arrangement?.tiles.compactMap { tile in byKey[tile.key].map { Tile(title: $0, folded: Int(tile.folded)) } }
|
||
?? model.titles.map { Tile(title: $0, folded: 0) }
|
||
let shelves = (model.arrangement?.shelves ?? [])
|
||
.map { Shelf(label: $0.label, titles: $0.keys.compactMap { byKey[$0] }) }
|
||
.filter { !$0.titles.isEmpty }
|
||
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
|
||
let featured = browsing ? featuredTitle(model.titles, resumable) : nil
|
||
|
||
return ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 16) {
|
||
if !searching {
|
||
Text("Library")
|
||
.textStyle(.headlineMedium)
|
||
.foregroundStyle(scheme.onSurface)
|
||
.background {
|
||
GeometryReader { geometry in
|
||
Color.clear.onChange(of: geometry.frame(in: .named("library")).maxY < 0) { _, folded in
|
||
collapsed = folded
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if browsing, !model.metadataSettings.enabled, !offerDismissed {
|
||
MetadataOfferCard(onOpenSettings: onOpenSettings) {
|
||
offerDismissed = true
|
||
SettingsStore().metadataOfferDismissed = true
|
||
}
|
||
}
|
||
if let featured {
|
||
FeaturedBanner(
|
||
title: featured,
|
||
resume: resumable.first { $0.title.key == featured.key },
|
||
onPlay: { onResume(featured, $0) },
|
||
onOpen: { onTitle(featured) }
|
||
)
|
||
}
|
||
if browsing && !resumable.isEmpty {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Continue watching").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
LazyHStack(alignment: .top, spacing: 12) {
|
||
ForEach(resumable) { entry in
|
||
ContinueCard(entry: entry)
|
||
.tapAndHold { onResume(entry.title, entry.index) } onHold: {
|
||
longPressed { menuResume = entry }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Only over the whole library: under a filter, a shelf of
|
||
// titles the grid below is hiding reads as the filter not
|
||
// working.
|
||
if browsing && model.filter == .all {
|
||
ForEach(shelves) { shelf in
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(shelf.label).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
LazyHStack(alignment: .top, spacing: 12) {
|
||
ForEach(shelf.titles, id: \.key) { title in
|
||
PosterTile(title: title)
|
||
.frame(width: 112)
|
||
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !resumable.isEmpty || !shelves.isEmpty {
|
||
Text("All titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
|
||
}
|
||
}
|
||
if browsing {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(SettingsStore.filters, id: \.self) { filter in
|
||
FilterChip(label: filter.label, selected: filter == model.filter) { model.setFilter(filter) }
|
||
}
|
||
}
|
||
}
|
||
if tiles.isEmpty && model.filter != .all {
|
||
Text("Nothing in the library fits that filter.")
|
||
.textStyle(.bodyMedium)
|
||
.foregroundStyle(scheme.onSurfaceVariant)
|
||
}
|
||
}
|
||
// Posters, three across on a phone.
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
|
||
ForEach(tiles) { tile in
|
||
PosterTile(title: tile.title, folded: tile.folded)
|
||
.tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
|
||
}
|
||
}
|
||
}
|
||
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
|
||
}
|
||
.coordinateSpace(name: "library")
|
||
.refreshable { await model.crawlNow(nil) }
|
||
.scrollDismissesKeyboard(.immediately)
|
||
}
|
||
|
||
private func longPressed(_ open: () -> Void) {
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
open()
|
||
}
|
||
}
|
||
|
||
/// The grid's order, and whether a franchise is one tile or one per title.
|
||
private struct ArrangeMenu: View {
|
||
let sort: LibrarySort
|
||
let grouped: Bool
|
||
let onArrange: (LibrarySort, Bool) -> Void
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
Menu {
|
||
ForEach([LibrarySort.name, .recent, .added, .release, .rating, .popularity], id: \.self) { option in
|
||
Button {
|
||
onArrange(option, grouped)
|
||
} label: {
|
||
if option == sort { Label(option.label, systemImage: "checkmark") } else { Text(option.label) }
|
||
}
|
||
}
|
||
Divider()
|
||
Button {
|
||
onArrange(sort, !grouped)
|
||
} label: {
|
||
if grouped { Label("One tile per franchise", systemImage: "checkmark") } else { Text("One tile per franchise") }
|
||
}
|
||
} label: {
|
||
Image(systemName: "arrow.up.arrow.down")
|
||
.font(.system(size: 20))
|
||
.frame(width: 48, height: 48)
|
||
.foregroundStyle(scheme.onSurfaceVariant)
|
||
}
|
||
.accessibilityLabel("Sort library")
|
||
}
|
||
}
|
||
|
||
extension LibrarySort {
|
||
/// What each order is called. The desktop says the same.
|
||
var label: String {
|
||
switch self {
|
||
case .name: "A – Z"
|
||
case .recent: "Recently watched"
|
||
case .added: "Recently added"
|
||
case .release: "Newest"
|
||
case .rating: "Highest rated"
|
||
case .popularity: "Most popular"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The bar while searching: back closes the search and clears it, and the
|
||
/// field takes the keyboard as soon as it opens.
|
||
private struct SearchBar: View {
|
||
@Binding var query: String
|
||
let onClose: () -> Void
|
||
@FocusState private var focused: Bool
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
IconButton("chevron.backward", "Close search", tint: scheme.onSurface, action: onClose)
|
||
HStack {
|
||
TextField("", text: $query, prompt: Text("Search library").foregroundStyle(scheme.onSurfaceVariant))
|
||
.textStyle(.bodyLarge)
|
||
.foregroundStyle(scheme.onSurface)
|
||
.focused($focused)
|
||
.submitLabel(.search)
|
||
.autocorrectionDisabled()
|
||
if !query.isEmpty {
|
||
Button { query = "" } label: {
|
||
Image(systemName: "xmark").foregroundStyle(scheme.onSurfaceVariant)
|
||
}
|
||
.accessibilityLabel("Clear search")
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.frame(height: 44)
|
||
.background(scheme.surfaceContainerHighest, in: Capsule())
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(height: 56)
|
||
.background(scheme.background)
|
||
.onAppear { focused = true }
|
||
}
|
||
}
|
||
|
||
/// A long press's menu, from the bottom of the screen where the thumb already
|
||
/// is. Each row closes it before doing its thing.
|
||
struct TileMenu<Rows: View>: View {
|
||
let heading: String
|
||
let caption: String
|
||
@ViewBuilder var rows: Rows
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface).lineLimit(1).padding(.horizontal, 24)
|
||
Text(caption).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||
.padding(.horizontal, 24).padding(.bottom, 8)
|
||
rows
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.top, 28)
|
||
.presentationDetents([.height(CGFloat(110 + 56 * 5)), .large])
|
||
.presentationDragIndicator(.visible)
|
||
.presentationBackground(scheme.surfaceContainerLow)
|
||
.presentationCornerRadius(Corner.sheet)
|
||
}
|
||
}
|
||
|
||
struct MenuRow: View {
|
||
let icon: String
|
||
let label: String
|
||
let action: () -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
init(_ icon: String, _ label: String, action: @escaping () -> Void) {
|
||
self.icon = icon
|
||
self.label = label
|
||
self.action = action
|
||
}
|
||
|
||
var body: some View {
|
||
Button {
|
||
dismiss()
|
||
action()
|
||
} label: {
|
||
ListRow(icon: icon, headline: label)
|
||
}
|
||
.buttonStyle(.pressable)
|
||
.padding(.horizontal, 8)
|
||
}
|
||
}
|
||
|
||
/// The grid's shape before the catalog has answered: posters and their two
|
||
/// lines of text as blocks, so the page does not jump when the titles land.
|
||
private struct LibrarySkeleton: View {
|
||
var body: some View {
|
||
ScrollView {
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12)], spacing: 16) {
|
||
ForEach(0 ..< 18, id: \.self) { _ in
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Skeleton().aspectRatio(2 / 3, contentMode: .fit).clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
Skeleton().frame(height: 12).clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
.padding(.trailing, 20).padding(.top, 8)
|
||
Skeleton().frame(height: 10).clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
.padding(.trailing, 50).padding(.top, 6)
|
||
}
|
||
}
|
||
}
|
||
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
|
||
}
|
||
.scrollDisabled(true)
|
||
}
|
||
}
|
||
|
||
extension LibraryFilter {
|
||
/// The desktop's names for the filters, from `browse::Filter::label`.
|
||
var label: String {
|
||
switch self {
|
||
case .all: "All"
|
||
case .series: "Series"
|
||
case .films: "Films"
|
||
case .watching: "Watching"
|
||
case .unwatched: "Not started"
|
||
case .downloaded: "Downloaded"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The offer of posters and synopses, and its two answers.
|
||
private struct MetadataOfferCard: View {
|
||
let onOpenSettings: () -> Void
|
||
let onDismiss: () -> Void
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Add posters and synopses?").textStyle(.titleSmall).foregroundStyle(scheme.onSurface)
|
||
Text("Turning it on in Settings sends the titles in your library — not your files, and nothing about what you have watched — to AniList or TMDB.")
|
||
.textStyle(.bodySmall)
|
||
.foregroundStyle(scheme.onSurfaceVariant)
|
||
HStack {
|
||
Spacer()
|
||
Button("Not now", action: onDismiss).buttonStyle(.quiet)
|
||
Button("Open settings", action: onOpenSettings).buttonStyle(.quiet)
|
||
}
|
||
}
|
||
.padding(EdgeInsets(top: 12, leading: 16, bottom: 4, trailing: 8))
|
||
.background(scheme.surfaceContainer, in: RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
}
|
||
|
||
/// The title the banner shows: the one watched last, or with nothing part
|
||
/// watched, one of the titles the provider has a backdrop for, turning over
|
||
/// once a day — the desktop's `featured`.
|
||
private func featuredTitle(_ titles: [TitleRecord], _ resumable: [Resumable]) -> TitleRecord? {
|
||
if let first = resumable.first { return first.title }
|
||
let pictured = titles.filter { $0.backdropUrl != nil }
|
||
if pictured.isEmpty { return nil }
|
||
let day = Int(Date().timeIntervalSince1970 + Double(TimeZone.current.secondsFromGMT())) / 86400
|
||
return pictured[day % pictured.count]
|
||
}
|
||
|
||
/// Large art behind the name, rating, genres and three lines of synopsis.
|
||
private struct FeaturedBanner: View {
|
||
let title: TitleRecord
|
||
let resume: Resumable?
|
||
let onPlay: (Int) -> Void
|
||
let onOpen: () -> Void
|
||
|
||
var body: some View {
|
||
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
|
||
.aspectRatio(4 / 3, contentMode: .fit)
|
||
.overlay {
|
||
LinearGradient(
|
||
stops: [.init(color: .clear, location: 0.35), .init(color: .black.opacity(0.85), location: 1)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
}
|
||
.overlay(alignment: .bottomLeading) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Always over the dark scrim above, so a logo made for dark
|
||
// art reads here whatever the theme.
|
||
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 240, maxHeight: 72) {
|
||
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
|
||
}
|
||
.padding(.bottom, 6)
|
||
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
|
||
if let overview = title.overview {
|
||
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
|
||
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
|
||
}
|
||
HStack(spacing: 8) {
|
||
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
|
||
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
|
||
}
|
||
.buttonStyle(.accent)
|
||
Button("More info", action: onOpen).buttonStyle(.tonal)
|
||
}
|
||
.padding(.top, 12)
|
||
}
|
||
.padding(16)
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
}
|
||
|
||
private var factLine: String {
|
||
let parts = [
|
||
title.rating.map { String(format: "★ %.1f", $0) },
|
||
title.genres.isEmpty ? nil : title.genres.prefix(3).joined(separator: ", "),
|
||
].compactMap { $0 }
|
||
return parts.isEmpty ? title.caption : parts.joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
/// A part-watched episode: its still, where it was left, and what is left of it.
|
||
private struct ContinueCard: View {
|
||
let entry: Resumable
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Named by the text under it; read as well, the name came twice.
|
||
RemoteArtwork(entry.episode.stillUrl ?? entry.title.backdropUrl, entry.title.displayName, fallback: entry.episode.thumbnailSource)
|
||
.accessibilityHidden(true)
|
||
.aspectRatio(16 / 9, contentMode: .fit)
|
||
.overlay(alignment: .bottom) {
|
||
if let progress = entry.episode.progress { AccentProgress(progress: progress) }
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||
.lineLimit(1).padding(.top, 8)
|
||
Text(entry.episode.resumeAt == nil
|
||
? "Next: \(entry.episode.label)"
|
||
: [entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · "))
|
||
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||
}
|
||
.frame(width: 220)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
/// One title: its poster, and its name and size under it rather than in a card.
|
||
///
|
||
/// `folded` is how many more titles of its franchise the tile stands for, said
|
||
/// in the corner.
|
||
struct PosterTile: View {
|
||
let title: TitleRecord
|
||
var folded = 0
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
|
||
.accessibilityHidden(true)
|
||
.aspectRatio(2 / 3, contentMode: .fit)
|
||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||
.overlay(alignment: .topTrailing) {
|
||
if folded > 0 {
|
||
Text("+\(folded)")
|
||
.textStyle(.labelSmall)
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: Corner.standard))
|
||
.padding(6)
|
||
.accessibilityLabel("and \(folded) more")
|
||
}
|
||
}
|
||
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
|
||
.lineLimit(2).padding(.top, 6)
|
||
// Two lines, not one: at a large text size "2023 · 7 episodes"
|
||
// does not fit a poster's width.
|
||
Text(title.caption).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(2)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
extension TitleRecord {
|
||
/// "2023 · 11 episodes", or "1988 · Film" — never "1 episodes".
|
||
var caption: String {
|
||
let count: String
|
||
if (metadataKind ?? kind) == .film && episodeCount == 1 {
|
||
count = "Film"
|
||
} else if episodeCount == 1 {
|
||
count = "1 episode"
|
||
} else {
|
||
count = "\(episodeCount) episodes"
|
||
}
|
||
return [(metadataYear ?? year).map(String.init), count].compactMap { $0 }.joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
extension EpisodeRecord {
|
||
/// "12 min left", from where the episode was left and how far through that
|
||
/// is. Nil when either is unknown — a guess would be worse than no figure.
|
||
var timeLeft: String? {
|
||
guard let at = resumeAt, let fraction = progress, fraction > 0, fraction < 1 else { return nil }
|
||
let minutes = Int((at / fraction - at) / 60)
|
||
return minutes < 1 ? "under a minute left" : "\(minutes) min left"
|
||
}
|
||
}
|
||
|
||
extension TitleRecord: Identifiable {
|
||
public var id: String { key }
|
||
}
|