178 lines
6.3 KiB
Swift
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))
|
|
}
|
|
}
|