tv-anarchy/Sources/TVAnarchyiOS/LibraryView.swift
Natalie f0669f1ca8 feat(ios): TVAnarchyiOS app target + UI tests
(cherry picked from commit 7f8f4b0dd92358ba687f8230a922d8f316cb06e9)
2026-06-09 05:50:26 -07:00

178 lines
6.3 KiB
Swift

// Browse the network library: shows episodes play. The whole screen is
// driven by one bridge call (fetchShows); episodes come embedded in each show.
// Styled with the shared Lilith dark-first design tokens.
import SwiftUI
import LilithDesignTokens
struct LibraryView: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var shows: [BridgeShow] = []
@State private var loading = false
@State private var errorText: String?
@State private var showingSettings = false
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
content
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
.foregroundStyle(AppColors.textSecondary)
}
}
}
.navigationDestination(for: BridgeShow.self) { show in
EpisodesView(show: show)
}
.sheet(isPresented: $showingSettings) {
SettingsView().environmentObject(settings)
}
.task { await load(refresh: false) }
.refreshable { await load(refresh: true) }
}
.tint(AppColors.primary)
}
@ViewBuilder
private var content: some View {
if let errorText {
ContentUnavailableView {
Label("Can't reach the bridge", systemImage: "wifi.exclamationmark")
} description: {
Text(errorText)
} actions: {
Button("Settings") { showingSettings = true }
Button("Retry") { Task { await load(refresh: true) } }
}
} else if shows.isEmpty && loading {
ProgressView("Loading library…")
.tint(AppColors.primary)
.foregroundStyle(AppColors.textSecondary)
} else if shows.isEmpty {
ContentUnavailableView("No shows", systemImage: "tv")
} else {
ScrollView {
LazyVStack(spacing: AppSpacing.md) {
ForEach(shows) { show in
NavigationLink(value: show) {
ShowRow(show: show)
}
.buttonStyle(.plain)
}
}
.padding(AppSpacing.base)
}
}
}
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)
errorText = nil
} catch {
errorText = error.localizedDescription
}
}
}
private struct ShowRow: View {
let show: BridgeShow
var body: some View {
HStack(spacing: AppSpacing.md) {
RoundedRectangle(cornerRadius: 8)
.fill(AppColors.primary.opacity(0.18))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "play.tv.fill")
.foregroundStyle(AppColors.primary)
}
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.base)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 14))
}
}
struct EpisodesView: View {
@EnvironmentObject private var settings: BridgeSettings
let show: BridgeShow
var body: some View {
ZStack {
AppColors.background.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(show.episodes) { ep in
if let client = settings.client {
// Destination-based link: robust inside a pushed view
// (value-based destinations don't always register here).
NavigationLink {
PlayerScreen(
title: "\(show.name) · \(ep.code)",
url: client.streamURL(episodeId: ep.id),
networkCachingMs: settings.networkCachingMs
)
} label: {
EpisodeRow(episode: ep)
}
.buttonStyle(.plain)
}
}
}
.padding(AppSpacing.base)
}
}
.navigationTitle(show.name)
.navigationBarTitleDisplayMode(.inline)
.tint(AppColors.primary)
}
}
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: 70, alignment: .leading)
Text(episode.label)
.font(AppTypography.bodySmall())
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Image(systemName: "play.circle.fill")
.foregroundStyle(AppColors.primary)
}
.padding(.vertical, AppSpacing.md)
.padding(.horizontal, AppSpacing.base)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}