205 lines
8 KiB
Swift
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)
|
|
}
|
|
}
|