init: init proj
This commit is contained in:
289
DDYSClient/Views/Detail/DetailView.swift
Normal file
289
DDYSClient/Views/Detail/DetailView.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user