tv-anarchy/Sources/TVAnarchyiOS/LibraryView.swift
Natalie 17cf518418 feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements
(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
2026-06-09 06:38:45 -07:00

275 lines
10 KiB
Swift

// Library tab: a Continue-Watching rail (resume where you left off) over the
// full show list. Artwork is ffmpeg frame-grabs served by the bridge.
import SwiftUI
import LilithDesignTokens
/// A concrete (show, episode) pair to play one navigation destination for the
/// whole stack, so pushes from the rail and from the episode list both work.
struct PlaybackTarget: Hashable {
/// Present for library playback (enables prefetch-ahead); nil for offline-only.
let show: BridgeShow?
let episode: BridgeEpisode
}
struct LibraryView: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var shows: [BridgeShow] = []
@State private var continueItems: [ContinueItem] = []
@State private var loading = false
@State private var errorText: String?
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
content
}
.navigationTitle("Library")
.navigationDestination(for: BridgeShow.self) { EpisodesView(show: $0) }
.navigationDestination(for: PlaybackTarget.self) { PlayerScreen(show: $0.show, episode: $0.episode) }
.task { await load(refresh: false) }
.refreshable { await load(refresh: true) }
}
}
@ViewBuilder
private var content: some View {
if let errorText {
ContentUnavailableView {
Label("Can't reach the bridge", systemImage: "wifi.exclamationmark")
} description: {
Text(errorText)
} actions: {
Button("Retry") { Task { await load(refresh: true) } }
}
} else if shows.isEmpty && loading {
ProgressView("Loading library…").tint(AppColors.primary)
} else if shows.isEmpty {
ContentUnavailableView("No shows", systemImage: "tv")
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: AppSpacing.lg) {
if !continueItems.isEmpty {
continueRail
}
Text("All Shows")
.font(AppTypography.h4())
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, AppSpacing.base)
LazyVStack(spacing: AppSpacing.md) {
ForEach(shows) { show in
NavigationLink(value: show) { ShowRow(show: show, client: settings.client) }
.buttonStyle(.plain)
}
}
.padding(.horizontal, AppSpacing.base)
}
.padding(.vertical, AppSpacing.base)
}
}
}
private var continueRail: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text("Continue Watching")
.font(AppTypography.h4())
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, AppSpacing.base)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.md) {
ForEach(continueItems) { item in
if let target = playTarget(for: item) {
NavigationLink(value: target) {
ContinueCard(item: item, client: settings.client)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, AppSpacing.base)
}
}
}
/// Resolve a continue item's resume episode back to concrete (show, episode).
private func playTarget(for item: ContinueItem) -> PlaybackTarget? {
guard let resume = item.resume,
let show = shows.first(where: { $0.id == item.showId }),
let episode = show.episodes.first(where: { $0.id == resume.episodeId }) else { return nil }
return PlaybackTarget(show: show, episode: episode)
}
private func load(refresh: Bool) async {
guard let client = settings.client else {
errorText = "Set a bridge host in Settings."
return
}
loading = true
defer { loading = false }
do {
shows = try await client.fetchShows(refresh: refresh)
continueItems = (try? await client.fetchContinue()) ?? []
errorText = nil
} catch {
errorText = error.localizedDescription
}
}
}
// MARK: - Rows & cards
struct ArtworkThumb: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
if case .success(let image) = phase {
image.resizable().aspectRatio(contentMode: .fill)
} else {
ZStack {
AppColors.surfaceElevated
Image(systemName: "play.tv").foregroundStyle(AppColors.textTertiary)
}
}
}
}
}
private struct ShowRow: View {
let show: BridgeShow
let client: BridgeClient?
var body: some View {
HStack(spacing: AppSpacing.md) {
ArtworkThumb(url: show.episodes.first.flatMap { client?.artworkURL(episodeId: $0.id) })
.frame(width: 80, height: 45)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(show.name)
.font(AppTypography.body(weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
Text("\(show.episodeCount) episodes · \(show.seasons.count) season\(show.seasons.count == 1 ? "" : "s")")
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right").font(.caption).foregroundStyle(AppColors.textTertiary)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 14))
}
}
private struct ContinueCard: View {
let item: ContinueItem
let client: BridgeClient?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ZStack(alignment: .bottomLeading) {
ArtworkThumb(url: item.resume.flatMap { client?.artworkURL(episodeId: $0.episodeId) })
.frame(width: 200, height: 112)
.clipShape(RoundedRectangle(cornerRadius: 10))
if let resume = item.resume, let dur = resume.durationSeconds, dur > 0 {
GeometryReader { geo in
Rectangle()
.fill(AppColors.primary)
.frame(width: geo.size.width * min(1, resume.positionSeconds / dur), height: 3)
}
.frame(height: 3)
}
}
Text(item.show)
.font(AppTypography.bodySmall(weight: .medium))
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
Text(item.resume.map { "\($0.code)" } ?? "")
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
}
.frame(width: 200)
}
}
// MARK: - Episodes
struct EpisodesView: View {
@EnvironmentObject private var settings: BridgeSettings
@EnvironmentObject private var downloads: DownloadManager
let show: BridgeShow
var body: some View {
ZStack {
AppColors.background.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(show.episodes) { ep in
// Download control is a *sibling* of the link, not a child
// a Button nested inside a NavigationLink swallows the row tap.
ZStack(alignment: .trailing) {
NavigationLink(value: PlaybackTarget(show: show, episode: ep)) {
EpisodeRow(episode: ep)
}
.buttonStyle(.plain)
DownloadControl(episode: ep, showName: show.name, downloads: downloads, client: settings.client)
.padding(.trailing, AppSpacing.base)
}
}
}
.padding(AppSpacing.base)
}
}
.navigationTitle(show.name)
.navigationBarTitleDisplayMode(.inline)
}
}
private struct EpisodeRow: View {
let episode: BridgeEpisode
var body: some View {
HStack(spacing: AppSpacing.md) {
Text(episode.code)
.font(AppTypography.mono(size: 13))
.foregroundStyle(AppColors.secondary)
.frame(width: 64, alignment: .leading)
Text(episode.label)
.font(AppTypography.bodySmall())
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1).truncationMode(.middle)
Spacer()
Image(systemName: "play.circle.fill").foregroundStyle(AppColors.primary)
Color.clear.frame(width: 28) // reserve space for the overlaid download control
}
.padding(.vertical, AppSpacing.md)
.padding(.horizontal, AppSpacing.base)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
private struct DownloadControl: View {
let episode: BridgeEpisode
let showName: String
@ObservedObject var downloads: DownloadManager
let client: BridgeClient?
var body: some View {
if downloads.isDownloaded(episode.id) {
Image(systemName: "arrow.down.circle.fill").foregroundStyle(AppColors.Semantic.success)
} else if case .downloading(let p) = downloads.states[episode.id] {
ProgressView(value: p).progressViewStyle(.circular).tint(AppColors.primary)
.frame(width: 20, height: 20)
} else if let client {
Button {
downloads.download(DownloadRequest(
episodeId: episode.id, ext: episode.ext, show: showName, label: episode.label,
season: episode.season, episode: episode.episode, url: client.streamURL(episodeId: episode.id)
))
} label: {
Image(systemName: "arrow.down.circle").foregroundStyle(AppColors.textSecondary)
}
.buttonStyle(.plain)
}
}
}