tv-anarchy/Sources/TVAnarchyiOS/LibraryView.swift

395 lines
16 KiB
Swift
Raw Permalink Normal View History

// 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 {
enum Mode: String, CaseIterable { case shows = "Shows", movies = "Movies" }
@EnvironmentObject private var settings: BridgeSettings
@State private var mode: Mode = .shows
@State private var shows: [BridgeShow] = []
@State private var movies: [BridgeMovie] = []
@State private var continueItems: [ContinueItem] = []
@State private var loading = false
@State private var offline = 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 && movies.isEmpty && loading {
ProgressView("Loading library…").tint(AppColors.primary)
} else if shows.isEmpty && movies.isEmpty {
ContentUnavailableView("No shows", systemImage: "tv")
} else {
VStack(spacing: 0) {
if offline {
Label("Offline — showing the last synced library", systemImage: "wifi.slash")
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, AppSpacing.xs)
.background(AppColors.surfaceElevated)
}
Picker("Section", selection: $mode) {
ForEach(Mode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.padding(.horizontal, AppSpacing.base)
.padding(.top, AppSpacing.sm)
switch mode {
case .shows: showsList
case .movies: MoviesList(movies: movies)
}
}
}
}
private var showsList: some View {
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)
movies = (try? await client.fetchMovies(refresh: refresh)) ?? []
continueItems = (try? await client.fetchContinue()) ?? []
errorText = nil
offline = false
LibraryCache.save(shows: shows, movies: movies)
} catch {
// Bridge unreachable: browse the last synced catalog; downloads still play.
if let cached = LibraryCache.load(), !(cached.shows.isEmpty && cached.movies.isEmpty) {
shows = cached.shows
movies = cached.movies
offline = true
errorText = nil
} else {
errorText = error.localizedDescription
}
}
}
}
// MARK: - Movies
private struct MoviesList: View {
@EnvironmentObject private var settings: BridgeSettings
@EnvironmentObject private var downloads: DownloadManager
let movies: [BridgeMovie]
/// Standalone films first, then collections alphabetically.
private var sections: [(title: String?, movies: [BridgeMovie])] {
let standalone = movies.filter { $0.collection == nil }
let grouped = Dictionary(grouping: movies.filter { $0.collection != nil }, by: { $0.collection! })
var out: [(String?, [BridgeMovie])] = []
if !standalone.isEmpty { out.append((nil, standalone)) }
out.append(contentsOf: grouped.sorted { $0.key < $1.key }.map { ($0.key, $0.value) })
return out
}
var body: some View {
if movies.isEmpty {
ContentUnavailableView("No movies", systemImage: "film")
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: AppSpacing.lg) {
ForEach(sections, id: \.title) { section in
if let title = section.title {
Text(title)
.font(AppTypography.h4())
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, AppSpacing.base)
}
LazyVStack(spacing: AppSpacing.sm) {
ForEach(section.movies) { movie in
ZStack(alignment: .trailing) {
NavigationLink(value: PlaybackTarget(show: nil, episode: movie.asEpisode)) {
MovieRow(movie: movie, client: settings.client)
}
.buttonStyle(.plain)
DownloadControl(episode: movie.asEpisode,
showName: movie.collection ?? "Movies",
downloads: downloads, client: settings.client)
.padding(.trailing, AppSpacing.base)
}
}
}
.padding(.horizontal, AppSpacing.base)
}
}
.padding(.vertical, AppSpacing.base)
}
}
}
}
private struct MovieRow: View {
let movie: BridgeMovie
let client: BridgeClient?
var body: some View {
HStack(spacing: AppSpacing.md) {
ArtworkThumb(url: client?.artworkURL(episodeId: movie.id))
.frame(width: 80, height: 45)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(movie.title)
.font(AppTypography.body(weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1).truncationMode(.middle)
Text(ByteCountFormatter.string(fromByteCount: movie.bytes, countStyle: .file))
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
}
Spacer()
Image(systemName: "play.circle.fill").foregroundStyle(AppColors.primary)
Color.clear.frame(width: 28) // reserve space for the overlaid download control
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 14))
}
}
// 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)
}
}
}