mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.
pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").
The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
804 lines
36 KiB
Swift
804 lines
36 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
|
||
/// How far the bar over the banner has filled in, from clear to solid.
|
||
@State private var reveal: CGFloat = 0
|
||
|
||
/// The page's bar: its title, shown as much as `solid` says, and its
|
||
/// actions, in `ink` or the scheme's own.
|
||
private func bar(solid: CGFloat, ink: Color?) -> some View {
|
||
HStack(spacing: 0) {
|
||
Text("Library")
|
||
.textStyle(.titleLarge)
|
||
.foregroundStyle(scheme.onSurface)
|
||
.padding(.leading, 16)
|
||
.opacity(solid)
|
||
Spacer()
|
||
IconButton("magnifyingglass", "Search library", tint: ink) { 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", tint: ink, enabled: !model.refreshing) { model.refresh() }
|
||
}
|
||
.frame(height: 56)
|
||
.padding(.trailing, 4)
|
||
}
|
||
|
||
var body: some View {
|
||
let resumable = resumable(model.titles)
|
||
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
|
||
let featured = featuredTitles(model.titles, resumable)
|
||
// With a banner, the bar lies over it, clear, and fills in as the
|
||
// banner scrolls away; without one, the large title folds into the bar
|
||
// as the grid scrolls.
|
||
let overBanner = !searching && !model.loading && browsing && !featured.isEmpty
|
||
ZStack(alignment: .top) {
|
||
VStack(spacing: 0) {
|
||
if searching {
|
||
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
|
||
searching = false
|
||
model.search("")
|
||
}
|
||
} else if !overBanner {
|
||
bar(solid: collapsed ? 1 : 0, ink: nil)
|
||
.background(collapsed ? scheme.surfaceContainer : scheme.background)
|
||
}
|
||
content(resumable, featured: overBanner ? featured : [])
|
||
}
|
||
if overBanner {
|
||
// White over the banner's own scrim, then the page's ink once
|
||
// the bar has filled in.
|
||
bar(solid: reveal, ink: reveal > 0.5 ? nil : .white)
|
||
.background(scheme.surfaceContainer.opacity(reveal).ignoresSafeArea(edges: .top))
|
||
.animation(.easeOut(duration: 0.15), value: reveal > 0.5)
|
||
}
|
||
}
|
||
.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(title.onList ? "checkmark" : "plus", title.onList ? "Remove from My List" : "Add to My List") {
|
||
model.setOnList(title, !title.onList)
|
||
}
|
||
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], featured: [TitleRecord]) -> 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, featured: featured)
|
||
}
|
||
}
|
||
|
||
private func grid(_ resumable: [Resumable], featured: [TitleRecord]) -> 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
|
||
|
||
return ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 16) {
|
||
if !featured.isEmpty {
|
||
FeaturedBanner(titles: featured, resumable: resumable, onPlay: onResume, onOpen: onTitle)
|
||
// Edge to edge, out of the grid's margin.
|
||
.padding(.horizontal, -16)
|
||
.background {
|
||
GeometryReader { geometry in
|
||
Color.clear.onChange(of: geometry.frame(in: .named("library")).minY) { _, top in
|
||
reveal = min(max(-top / 280, 0), 1)
|
||
}
|
||
}
|
||
}
|
||
// Scrolled far enough to leave the lazy stack: the bar
|
||
// is long since solid.
|
||
.onDisappear { reveal = 1 }
|
||
.onAppear { reveal = 0 }
|
||
} else 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 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
|
||
// On a shelf, an airing show says when its
|
||
// next episode is out.
|
||
PosterTile(title: title, badge: title.badge ?? title.countdown())
|
||
.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 } }
|
||
}
|
||
}
|
||
}
|
||
// No room above a banner: it starts under the status bar, and the
|
||
// bar lies over it.
|
||
.padding(EdgeInsets(top: featured.isEmpty ? 8 : 0, leading: 16, bottom: 24, trailing: 16))
|
||
}
|
||
.ignoresSafeArea(edges: featured.isEmpty ? [] : .top)
|
||
.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"
|
||
case .myList: "My List"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
}
|
||
|
||
/// How many titles the banner turns through: the desktop's `FEATURED`.
|
||
private let featuredMax = 5
|
||
|
||
/// How long each title holds the banner: the desktop's `HOLD`.
|
||
private let holdSeconds = 8.0
|
||
|
||
/// One title giving way to the next: the desktop's `TURN`.
|
||
private let turn = Animation.easeInOut(duration: 0.6)
|
||
|
||
/// The titles the banner turns through: the one watched last first, then
|
||
/// titles the provider has a backdrop for, starting from a different one each
|
||
/// day — the desktop's `featured`.
|
||
private func featuredTitles(_ titles: [TitleRecord], _ resumable: [Resumable]) -> [TitleRecord] {
|
||
var picked = resumable.first.map { [$0.title] } ?? []
|
||
let pictured = titles.filter { $0.backdropUrl != nil }
|
||
if pictured.isEmpty { return picked }
|
||
let day = Int(Date().timeIntervalSince1970 + Double(TimeZone.current.secondsFromGMT())) / 86400
|
||
for offset in pictured.indices {
|
||
if picked.count == featuredMax { break }
|
||
let title = pictured[(day + offset) % pictured.count]
|
||
if !picked.contains(where: { $0.key == title.key }) { picked.append(title) }
|
||
}
|
||
return picked
|
||
}
|
||
|
||
/// The banner at the top of the library: one title at a time, edge to edge
|
||
/// under the status bar, turning to the next every `holdSeconds` — a swipe
|
||
/// starts the count again. Each title's veil is tinted with its own picture's
|
||
/// colour.
|
||
private struct FeaturedBanner: View {
|
||
let titles: [TitleRecord]
|
||
let resumable: [Resumable]
|
||
let onPlay: (TitleRecord, Int) -> Void
|
||
let onOpen: (TitleRecord) -> Void
|
||
@Environment(\.scheme) private var scheme
|
||
@State private var shown = ""
|
||
|
||
var body: some View {
|
||
let current = titles.contains { $0.key == shown } ? shown : titles.first?.key ?? ""
|
||
TabView(selection: Binding(get: { current }, set: { shown = $0 })) {
|
||
ForEach(titles, id: \.key) { title in
|
||
FeaturedPage(
|
||
title: title,
|
||
resume: resumable.first { $0.title.key == title.key },
|
||
onPlay: { onPlay(title, $0) },
|
||
onOpen: { onOpen(title) }
|
||
)
|
||
.tag(title.key)
|
||
}
|
||
}
|
||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||
// Most of a phone's first screen, but never so much of an iPad's that
|
||
// nothing under it shows.
|
||
.containerRelativeFrame(.vertical) { height, _ in min(max(height * 0.62, 360), 620) }
|
||
.overlay(alignment: .bottom) {
|
||
if titles.count > 1 {
|
||
HStack(spacing: 6) {
|
||
ForEach(titles, id: \.key) { title in
|
||
let selected = title.key == current
|
||
Capsule()
|
||
.fill(scheme.onBackground.opacity(selected ? 1 : 0.4))
|
||
.frame(width: selected ? 18 : 6, height: 6)
|
||
.contentShape(Rectangle().inset(by: -8))
|
||
.onTapGesture { withAnimation(turn) { shown = title.key } }
|
||
.accessibilityLabel("Show \(title.displayName)")
|
||
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
|
||
}
|
||
}
|
||
.animation(.easeOut(duration: 0.15), value: current)
|
||
.padding(.bottom, 10)
|
||
}
|
||
}
|
||
.task(id: current) {
|
||
guard titles.count > 1 else { return }
|
||
try? await Task.sleep(for: .seconds(holdSeconds))
|
||
if Task.isCancelled { return }
|
||
let index = titles.firstIndex { $0.key == current } ?? 0
|
||
withAnimation(turn) { shown = titles[(index + 1) % titles.count].key }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One title on the banner: its art, its veil, and what it is, from the
|
||
/// bottom up.
|
||
private struct FeaturedPage: View {
|
||
let title: TitleRecord
|
||
let resume: Resumable?
|
||
let onPlay: (Int) -> Void
|
||
let onOpen: () -> Void
|
||
@Environment(\.scheme) private var scheme
|
||
@Environment(AppModel.self) private var model
|
||
@State private var mood: UInt32?
|
||
|
||
var body: some View {
|
||
let veil = scheme.veil(mood)
|
||
ZStack(alignment: .bottomLeading) {
|
||
Backdrop(title: title) { mood = $0 }
|
||
LinearGradient(
|
||
stops: [
|
||
// Dark under the status bar and the bar's white icons…
|
||
.init(color: .black.opacity(0.5), location: 0),
|
||
.init(color: .clear, location: 0.22),
|
||
// …and the picture's own colour under the text, ending in
|
||
// the page it hands over to.
|
||
.init(color: veil.opacity(0.35), location: 0.45),
|
||
.init(color: veil.opacity(0.92), location: 0.78),
|
||
.init(color: scheme.background, location: 1),
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// A logo is lettering made for dark art, white as often as
|
||
// not; over a light palette's veil it is gone, so the light
|
||
// palette keeps the name.
|
||
if scheme.light {
|
||
name
|
||
} else {
|
||
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 260, maxHeight: 88) { name }
|
||
.padding(.bottom, 8)
|
||
}
|
||
Text(factLine).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
|
||
if let overview = title.overview {
|
||
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
|
||
.textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8)).lineLimit(2).padding(.top, 6)
|
||
}
|
||
HStack(spacing: 8) {
|
||
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
|
||
Label(resume?.episode.resumeAt != nil ? "Resume" : "Play", systemImage: "play.fill")
|
||
}
|
||
.buttonStyle(.accent)
|
||
Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal)
|
||
// The icon alone: a third labelled button does not fit a
|
||
// phone's row.
|
||
Button { model.setOnList(title, !title.onList) } label: {
|
||
Image(systemName: title.onList ? "checkmark" : "plus")
|
||
}
|
||
.buttonStyle(.tonal)
|
||
.accessibilityLabel(title.onList ? "Remove from My List" : "Add to My List")
|
||
}
|
||
.padding(.top, 12)
|
||
if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress {
|
||
HStack(spacing: 8) {
|
||
AccentProgress(progress: progress).frame(width: 120, height: 4)
|
||
if let left = episode.timeLeft {
|
||
Text(left).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
|
||
}
|
||
}
|
||
.padding(.top, 12)
|
||
}
|
||
}
|
||
.frame(maxWidth: 560, alignment: .leading)
|
||
.padding(EdgeInsets(top: 0, leading: 16, bottom: 28, trailing: 16))
|
||
}
|
||
.animation(Motion.arrive, value: mood)
|
||
.clipped()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: onOpen)
|
||
}
|
||
|
||
private var name: some View {
|
||
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onBackground).lineLimit(2).padding(.bottom, 4)
|
||
}
|
||
|
||
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
|
||
/// `New`, `3 new`, or an airing show's countdown, at the top left.
|
||
var badge: String?
|
||
@Environment(\.scheme) private var scheme
|
||
|
||
init(title: TitleRecord, folded: Int = 0, badge: String? = nil) {
|
||
self.title = title
|
||
self.folded = folded
|
||
self.badge = badge ?? title.badge
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
.overlay(alignment: .topLeading) {
|
||
if let badge {
|
||
Text(badge)
|
||
.textStyle(.labelSmall, semibold: true)
|
||
.foregroundStyle(scheme.onPrimary)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(scheme.primary, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||
.padding(6)
|
||
}
|
||
}
|
||
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 {
|
||
/// `Ep 8 · in 2 d` for a show whose next episode is still to air.
|
||
func countdown(now: Int64 = Int64(Date().timeIntervalSince1970)) -> String? {
|
||
guard let at = nextAiringAt, at > now, let episode = nextEpisode else { return nil }
|
||
return airingCountdown(episode: episode, at: at, now: now)
|
||
}
|
||
|
||
/// "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 }
|
||
}
|