tv-anarchy/Sources/PlumTV/LibraryView.swift
Natalie 65f3cb1e4e feat(plum-tv): add async poster loading for shows
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-07 22:06:27 -07:00

205 lines
8 KiB
Swift

import SwiftUI
import PlumTVCore
/// Library browser: a continue-watching rail, a searchable poster grid, and a
/// show seasons episodes drill-down. Play routes to the active player target
/// (black resolves by name; VLC by file path). Fully usable offline from the
/// cached snapshot Refresh rescans ~/media when the mount is up.
struct LibraryView: View {
@Bindable var library: LibraryController
@Bindable var player: PlayerController
private let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)]
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if !library.continueWatching.isEmpty {
continueRail
}
showGrid
}
.padding(20)
}
.navigationTitle("Library")
.searchable(text: $library.query, placement: .toolbar, prompt: "Filter shows")
.toolbar { toolbarContent }
.navigationDestination(for: CachedShow.self) { show in
ShowDetailView(show: show, library: library, player: player)
}
}
.task { await library.refresh() }
}
@ToolbarContentBuilder private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .automatic) {
HStack(spacing: 8) {
if library.refreshing { ProgressView().controlSize(.small) }
Text(statusLine).font(.caption).foregroundStyle(.secondary)
Button { Task { await library.refresh() } } label: { Image(systemName: "arrow.clockwise") }
.disabled(library.refreshing)
}
}
}
private var statusLine: String {
var parts: [String] = ["\(library.shows.count) shows"]
if !library.source.isEmpty { parts.append(library.source) }
return parts.joined(separator: " · ")
}
private var continueRail: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Continue watching").font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(library.continueWatching) { item in
Button { play(continue: item) } label: { ContinueCard(item: item) }
.buttonStyle(.plain)
}
}
}
}
}
private var showGrid: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(library.filteredShows) { show in
NavigationLink(value: show) { ShowPoster(show: show) }
.buttonStyle(.plain)
}
}
}
private func play(continue item: ContinueItem) {
guard let kind = player.activeKind,
let req = library.launchRequest(continue: item, targetKind: kind) else { return }
player.launch(req)
}
}
// MARK: - Show detail (seasons episodes)
private struct ShowDetailView: View {
let show: CachedShow
@Bindable var library: LibraryController
@Bindable var player: PlayerController
var body: some View {
List {
Section {
HStack(alignment: .top, spacing: 16) {
ShowPoster(show: show).frame(width: 120)
VStack(alignment: .leading, spacing: 8) {
Text(show.name).font(.title2).bold()
Text("\(show.episodes.count) episodes · \(show.seasons.count) season(s)")
.font(.caption).foregroundStyle(.secondary)
if let overview = show.overview, !overview.isEmpty {
Text(overview).font(.callout).foregroundStyle(.secondary).lineLimit(5)
}
Button {
play(episode: nil) // black: resume-show; VLC: first file
} label: { Label("Resume / Play", systemImage: "play.fill") }
.buttonStyle(.borderedProminent)
.disabled(player.active == nil)
}
Spacer()
}
.padding(.vertical, 4)
}
ForEach(show.seasons, id: \.self) { season in
Section("Season \(season)") {
ForEach(show.episodes(inSeason: season)) { ep in
HStack {
Text("E\(ep.episode)").monospacedDigit().foregroundStyle(.secondary).frame(width: 38, alignment: .leading)
Text(ep.label).lineLimit(1)
Spacer()
Button { play(episode: ep) } label: { Image(systemName: "play.circle") }
.buttonStyle(.borderless)
.disabled(player.active == nil)
}
}
}
}
}
.navigationTitle(show.name)
}
private func play(episode: CachedEpisode?) {
guard let kind = player.activeKind,
let req = library.launchRequest(show: show, episode: episode, targetKind: kind) else { return }
player.launch(req)
}
}
// MARK: - Cells
private struct ShowPoster: View {
let show: CachedShow
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack {
RoundedRectangle(cornerRadius: 10).fill(.quaternary)
if let poster = show.posterPath, let url = posterURL(poster) {
AsyncImage(url: url) { img in
img.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Text(initials(show.name)).font(.system(size: 34, weight: .bold))
.foregroundStyle(.secondary)
}
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
Text(initials(show.name)).font(.system(size: 34, weight: .bold))
.foregroundStyle(.secondary)
}
}
.aspectRatio(2.0 / 3.0, contentMode: .fit)
Text(show.name).font(.callout).lineLimit(2).frame(maxWidth: .infinity, alignment: .leading)
if !show.episodes.isEmpty {
Text("\(show.episodes.count) eps").font(.caption2).foregroundStyle(.secondary)
}
}
}
private func initials(_ name: String) -> String {
let words = name.split(separator: " ").prefix(2)
return words.compactMap { $0.first.map(String.init) }.joined().uppercased()
}
/// posterPath is a remote TMDB URL once enriched; treat http(s) as a URL,
/// otherwise a local file path.
private func posterURL(_ poster: String) -> URL? {
if poster.hasPrefix("http") { return URL(string: poster) }
return URL(fileURLWithPath: poster)
}
}
private struct ContinueCard: View {
let item: ContinueItem
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: 8).fill(.quaternary)
.frame(width: 200, height: 112)
Image(systemName: "play.circle.fill").font(.largeTitle)
.foregroundStyle(.white.opacity(0.85)).padding(8)
}
Text(item.show ?? item.title).font(.caption).lineLimit(1).frame(width: 200, alignment: .leading)
if let pos = item.positionSeconds, pos > 0 {
Text(timecode(pos) + (item.source == "vlc" ? " · VLC" : ""))
.font(.caption2).foregroundStyle(.secondary)
} else {
Text(item.source).font(.caption2).foregroundStyle(.secondary)
}
}
}
private func timecode(_ s: Double) -> String {
let i = Int(s.rounded())
return String(format: "%d:%02d", i / 60, i % 60)
}
}