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