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) } }