Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
394 lines
16 KiB
Swift
394 lines
16 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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|