Files
ddys-client/DDYSClient/Views/Detail/DetailView.swift
YANGJIANKUAN 1da412e637 fix: 修复详情页空白和海报图片错误
- DetailViewModel 添加 @MainActor 确保状态更新在主线程
- DetailView 消除空白初始状态,ProgressView 作为默认兜底
- 取消时保留加载状态避免页面闪回空白
- 使用 .task(id:) 确保切换条目时任务重新触发
- 海报优先从 JSON-LD image 字段获取,HTML fallback 改用正确选择器

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:33:22 +08:00

290 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 let detail = viewModel.detail {
ScrollView {
detailContent(detail)
}
} else if let error = viewModel.error {
errorView(error)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle(item.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task(id: item.id) {
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)
}
}