Files
ddys-client/DDYSClient/Views/Detail/DetailView.swift
2026-02-26 22:15:35 +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 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)
}
}