290 lines
10 KiB
Swift
290 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
struct DetailView: View {
|
||
let item: ContentItem
|
||
@State private var viewModel = DetailViewModel()
|
||
|
||
#if os(macOS)
|
||
@Environment(\.openWindow) private var openWindow
|
||
#else
|
||
@State private var showPlayer = false
|
||
#endif
|
||
|
||
var body: some View {
|
||
Group {
|
||
if viewModel.isLoading {
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if let error = viewModel.error {
|
||
errorView(error)
|
||
} else if let detail = viewModel.detail {
|
||
ScrollView {
|
||
detailContent(detail)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(item.title)
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
#endif
|
||
.task {
|
||
if viewModel.detail == nil {
|
||
await viewModel.loadDetail(path: item.detailURL)
|
||
}
|
||
}
|
||
#if os(iOS)
|
||
.sheet(isPresented: $showPlayer) {
|
||
if let episode = viewModel.currentEpisode {
|
||
VideoPlayerView(
|
||
url: episode.url,
|
||
title: item.title,
|
||
episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil,
|
||
contentId: item.id,
|
||
episodeId: episode.id
|
||
)
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private func playVideo() {
|
||
guard let episode = viewModel.currentEpisode else { return }
|
||
#if os(macOS)
|
||
let data = VideoPlayerData(
|
||
url: episode.url,
|
||
title: item.title,
|
||
episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil,
|
||
contentId: item.id,
|
||
episodeId: episode.id
|
||
)
|
||
openWindow(value: data)
|
||
#else
|
||
showPlayer = true
|
||
#endif
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func detailContent(_ detail: ContentDetail) -> some View {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
// 顶部:海报 + 元信息
|
||
HStack(alignment: .top, spacing: 16) {
|
||
posterImage(detail)
|
||
.frame(width: 160, height: 240)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.shadow(radius: 4)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(detail.item.title)
|
||
.font(.title2)
|
||
.fontWeight(.bold)
|
||
.lineLimit(3)
|
||
|
||
// 评分
|
||
if let rating = detail.item.rating, rating > 0 {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "star.fill")
|
||
.foregroundStyle(.orange)
|
||
Text(String(format: "%.1f", rating))
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
|
||
// 年份 · 地区
|
||
if detail.item.year > 0 || !detail.region.isEmpty {
|
||
let parts = [
|
||
detail.item.year > 0 ? "\(detail.item.year)" : nil,
|
||
detail.region.isEmpty ? nil : detail.region,
|
||
].compactMap { $0 }
|
||
Text(parts.joined(separator: " · "))
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
// 类型标签
|
||
if !detail.genres.isEmpty {
|
||
FlowLayout(spacing: 6) {
|
||
ForEach(detail.genres, id: \.self) { genre in
|
||
Text(genre)
|
||
.font(.caption)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(Color.secondary.opacity(0.12), in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
|
||
// 播放按钮
|
||
if !detail.sources.isEmpty {
|
||
Button {
|
||
playVideo()
|
||
} label: {
|
||
Label("立即播放", systemImage: "play.fill")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(.blue)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
|
||
// 播放源选择
|
||
if detail.sources.count > 1 {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("播放源")
|
||
.font(.headline)
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(Array(detail.sources.enumerated()), id: \.element.id) { index, source in
|
||
Button {
|
||
viewModel.selectSource(index)
|
||
} label: {
|
||
Text(source.name)
|
||
.font(.subheadline)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(
|
||
viewModel.selectedSourceIndex == index
|
||
? Color.accentColor
|
||
: Color.secondary.opacity(0.12),
|
||
in: Capsule()
|
||
)
|
||
.foregroundStyle(
|
||
viewModel.selectedSourceIndex == index ? .white : .primary
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
// 剧集选择(点击直接播放)
|
||
if viewModel.hasMultipleEpisodes, let source = viewModel.currentSource {
|
||
EpisodeListView(
|
||
episodes: source.episodes,
|
||
selectedIndex: viewModel.selectedEpisodeIndex
|
||
) { index in
|
||
viewModel.selectEpisode(index)
|
||
playVideo()
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
Divider().padding(.horizontal)
|
||
|
||
// 简介
|
||
if !detail.description.isEmpty {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("剧情简介")
|
||
.font(.headline)
|
||
Text(detail.description)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.lineSpacing(4)
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
// 导演
|
||
if !detail.directors.isEmpty {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("导演")
|
||
.font(.headline)
|
||
Text(detail.directors.joined(separator: " / "))
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
// 主演
|
||
if !detail.actors.isEmpty {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("主演")
|
||
.font(.headline)
|
||
Text(detail.actors.joined(separator: " / "))
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
Spacer(minLength: 32)
|
||
}
|
||
.padding(.vertical)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func posterImage(_ detail: ContentDetail) -> some View {
|
||
CachedAsyncImage(url: detail.item.posterURL) {
|
||
Rectangle()
|
||
.fill(.quaternary)
|
||
.overlay {
|
||
Image(systemName: "film")
|
||
.font(.largeTitle)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func errorView(_ message: String) -> some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.font(.system(size: 48))
|
||
.foregroundStyle(.secondary)
|
||
Text(message)
|
||
.foregroundStyle(.secondary)
|
||
Button("重试") {
|
||
Task { await viewModel.loadDetail(path: item.detailURL) }
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 400)
|
||
}
|
||
}
|
||
|
||
// MARK: - FlowLayout for genre tags
|
||
|
||
struct FlowLayout: Layout {
|
||
var spacing: CGFloat = 8
|
||
|
||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||
let result = arrange(proposal: proposal, subviews: subviews)
|
||
return result.size
|
||
}
|
||
|
||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||
let result = arrange(proposal: proposal, subviews: subviews)
|
||
for (index, position) in result.positions.enumerated() {
|
||
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
||
}
|
||
}
|
||
|
||
private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
||
let maxWidth = proposal.width ?? .infinity
|
||
var positions: [CGPoint] = []
|
||
var x: CGFloat = 0
|
||
var y: CGFloat = 0
|
||
var rowHeight: CGFloat = 0
|
||
var maxX: CGFloat = 0
|
||
|
||
for subview in subviews {
|
||
let size = subview.sizeThatFits(.unspecified)
|
||
if x + size.width > maxWidth && x > 0 {
|
||
x = 0
|
||
y += rowHeight + spacing
|
||
rowHeight = 0
|
||
}
|
||
positions.append(CGPoint(x: x, y: y))
|
||
rowHeight = max(rowHeight, size.height)
|
||
x += size.width + spacing
|
||
maxX = max(maxX, x)
|
||
}
|
||
|
||
return (CGSize(width: maxX, height: y + rowHeight), positions)
|
||
}
|
||
}
|