275 lines
10 KiB
Swift
275 lines
10 KiB
Swift
// 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)
|
|
}
|
|
}
|
|
}
|