// 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) } } }