init: init proj

This commit is contained in:
2026-02-26 22:15:35 +08:00
commit 7ef5348f65
43 changed files with 3085 additions and 0 deletions

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