Files
tvbox-Swift/tvbox/Views/Detail/DetailView.swift
T
2026-03-01 14:04:44 +08:00

589 lines
20 KiB
Swift

import SwiftUI
import SwiftData
#if os(macOS)
import AppKit
#endif
/// 详情页 - 对应 Android 版 DetailActivity
struct DetailView: View {
let video: Movie.Video
@StateObject private var viewModel = DetailViewModel()
@StateObject private var sharedVLCController = VLCPlayerController()
@EnvironmentObject var appState: AppState
@Environment(\.modelContext) private var modelContext
@State private var showFullScreen = false
@State private var lastPersistedProgress: Double = 0
@State private var isCollected = false
var body: some View {
ScrollView {
VStack(spacing: 0) {
// 播放器区域
if viewModel.isPlaying, let url = viewModel.playUrl, !showFullScreen {
PlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
onProgressChanged: handlePlaybackProgress,
onPlaybackEnded: playNextEpisodeIfNeeded,
onToggleFullScreen: {
openFullScreenPlayer()
},
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
vlcController: sharedVLCController
)
.id("\(viewModel.selectedFlag)-\(viewModel.selectedEpisodeIndex)-\(url)")
.aspectRatio(16/9, contentMode: .fit)
.background(Color.black)
.onTapGesture(count: 2) {
openFullScreenPlayer()
}
}
// 视频信息
videoInfoSection
.padding(.horizontal, 20)
.padding(.top, 16)
// 线路选择
if viewModel.flags.count > 1 {
flagSelector
.padding(.horizontal, 20)
.padding(.top, 16)
}
// 清晰度选择
if viewModel.hasQualityChoices {
qualitySelector
.padding(.horizontal, 20)
.padding(.top, 16)
}
// 剧集列表
if !viewModel.currentEpisodes.isEmpty {
episodeSection
.padding(.top, 16)
}
// 简介
if let info = viewModel.vodInfo, !info.des.isEmpty {
descriptionSection(info.des)
.padding(.horizontal, 20)
.padding(.top, 16)
}
}
.padding(.bottom, 40)
}
.background(AppTheme.primaryGradient)
.navigationTitle(video.name)
#if os(macOS)
.toolbar(showFullScreen ? .hidden : .visible, for: .windowToolbar)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task(id: "\(video.sourceKey)-\(video.id)") {
await viewModel.loadDetail(video: video)
restorePlaybackFromHistory()
refreshCollectState()
}
.onDisappear {
viewModel.commitPlaybackProgressSnapshot()
persistHistoryIfNeeded(force: true)
showFullScreen = false
sharedVLCController.stop()
#if os(macOS)
appState.exitPlayerFullScreen()
#endif
}
#if os(macOS)
.overlay {
if showFullScreen, let url = viewModel.playUrl {
FullScreenPlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
onProgressChanged: handlePlaybackProgress,
onPlaybackEnded: playNextEpisodeIfNeeded,
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
vlcController: sharedVLCController,
onCloseRequested: closeMacFullScreenOverlay
)
.ignoresSafeArea()
.transition(.opacity)
.zIndex(2)
}
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in
if showFullScreen {
showFullScreen = false
}
appState.exitPlayerFullScreen()
}
#endif
#if os(iOS)
.fullScreenCover(isPresented: $showFullScreen) {
if let url = viewModel.playUrl {
FullScreenPlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
onProgressChanged: handlePlaybackProgress,
onPlaybackEnded: playNextEpisodeIfNeeded,
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
vlcController: sharedVLCController
)
}
}
#endif
}
// MARK: - 视频信息
@ViewBuilder
private var videoInfoSection: some View {
HStack(alignment: .top, spacing: 20) {
videoPoster
videoDetails
Spacer()
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
@ViewBuilder
private var videoPoster: some View {
CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in
image.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: {
ZStack {
Color.white.opacity(0.05)
Image(systemName: "film.fill").foregroundColor(.white.opacity(0.2))
}
.aspectRatio(2/3, contentMode: .fill)
}
.frame(width: 130)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
.shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
}
@ViewBuilder
private var videoDetails: some View {
VStack(alignment: .leading, spacing: 10) {
Text(viewModel.vodInfo?.name ?? video.name)
.font(.system(size: 24, weight: .bold))
.foregroundColor(.white)
if let info = viewModel.vodInfo {
VStack(alignment: .leading, spacing: 6) {
infoRow("年份", info.year)
infoRow("地区", info.area)
infoRow("类型", info.typeName)
infoRow("导演", info.director)
infoRow("演员", info.actor)
}
}
Spacer(minLength: 10)
HStack(spacing: 10) {
playButton
collectButton
}
}
}
@ViewBuilder
private var playButton: some View {
if !viewModel.isPlaying && viewModel.vodInfo != nil {
Button {
viewModel.selectEpisode(index: 0)
saveHistoryForCurrentEpisode()
} label: {
HStack(spacing: 8) {
Image(systemName: "play.fill")
Text("立即播放")
}
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 28)
.padding(.vertical, 14)
.background(AppTheme.accentGradient)
.clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 10, x: 0, y: 5)
}
.buttonStyle(.plain)
}
}
private var collectButton: some View {
Button {
toggleCollect()
} label: {
HStack(spacing: 8) {
Image(systemName: isCollected ? "heart.fill" : "heart")
Text(isCollected ? "已收藏" : "收藏")
}
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
Group {
if isCollected {
AppTheme.accentGradient
} else {
Color.white.opacity(0.08)
}
}
)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.white.opacity(isCollected ? 0 : 0.2), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private func infoRow(_ label: String, _ value: String) -> some View {
if !value.isEmpty {
HStack(alignment: .top, spacing: 4) {
Text(label)
.font(.caption)
.foregroundColor(.gray)
.frame(width: 36, alignment: .leading)
Text(value)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.lineLimit(2)
}
}
}
// MARK: - 线路选择
@ViewBuilder
private var flagSelector: some View {
VStack(alignment: .leading, spacing: 12) {
Text("播放线路")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
flagScrollView
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
@ViewBuilder
private var flagScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(viewModel.flags, id: \.self) { flag in
flagButton(flag)
}
}
}
}
@ViewBuilder
private func flagButton(_ flag: String) -> some View {
Button {
withAnimation {
viewModel.selectFlag(flag)
}
if viewModel.isPlaying {
saveHistoryForCurrentEpisode()
}
} label: {
Text(flag)
.font(.system(size: 14, weight: viewModel.selectedFlag == flag ? .bold : .medium))
.foregroundColor(viewModel.selectedFlag == flag ? .white : .white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedFlag == flag {
AppTheme.accentGradient
} else {
Color.white.opacity(0.05)
}
}
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
// MARK: - 清晰度选择
@ViewBuilder
private var qualitySelector: some View {
VStack(alignment: .leading, spacing: 12) {
Text("视频清晰度")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(viewModel.qualityOptions) { option in
qualityButton(option)
}
}
}
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
@ViewBuilder
private func qualityButton(_ option: PlaybackQualityOption) -> some View {
Button {
withAnimation {
viewModel.selectQuality(option)
}
if viewModel.isPlaying {
saveHistoryForCurrentEpisode()
}
} label: {
Text(option.name)
.font(.system(size: 14, weight: viewModel.selectedQualityId == option.id ? .bold : .medium))
.foregroundColor(viewModel.selectedQualityId == option.id ? .white : .white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedQualityId == option.id {
AppTheme.accentGradient
} else {
Color.white.opacity(0.05)
}
}
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
// MARK: - 剧集列表
private var episodeSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("选集播放")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 20)
EpisodeListView(
episodes: viewModel.currentEpisodes,
selectedIndex: viewModel.selectedEpisodeIndex,
onSelect: { index in
withAnimation {
viewModel.selectEpisode(index: index)
}
saveHistoryForCurrentEpisode()
}
)
}
}
// MARK: - 简介
private func descriptionSection(_ des: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("影片简介")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
Text(des)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
.lineSpacing(4)
.lineLimit(nil)
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
private var canPlayNextEpisode: Bool {
viewModel.selectedEpisodeIndex + 1 < viewModel.currentEpisodes.count
}
private func saveHistoryForCurrentEpisode(progressOverride: Double? = nil) {
let episodeName = viewModel.vodInfo?.currentEpisode?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let episodeLabel = episodeName.isEmpty ? "第\(viewModel.selectedEpisodeIndex + 1)集" : episodeName
let progress = max(progressOverride ?? viewModel.currentPlaybackSeconds(), 0)
let timeLabel = progress > 0 ? Int(progress).durationString : ""
let playNote = timeLabel.isEmpty ? episodeLabel : "\(episodeLabel) \(timeLabel)"
let playbackState = VodPlaybackState(
flag: viewModel.selectedFlag,
episodeIndex: viewModel.selectedEpisodeIndex,
progressSeconds: progress
)
Task { @MainActor in
CacheStore.shared.addRecord(
video,
playNote: playNote,
playbackState: playbackState,
context: modelContext
)
}
}
private func handlePlaybackProgress(_ seconds: Double, _: Double?) {
viewModel.updatePlaybackProgress(seconds: seconds)
persistHistoryIfNeeded(force: false, currentProgress: seconds)
}
private func persistHistoryIfNeeded(force: Bool, currentProgress: Double? = nil) {
guard viewModel.isPlaying else { return }
let progress = max(currentProgress ?? viewModel.currentPlaybackSeconds(), 0)
guard progress.isFinite else { return }
if !force && abs(progress - lastPersistedProgress) < 20 {
return
}
lastPersistedProgress = progress
saveHistoryForCurrentEpisode(progressOverride: progress)
}
private func restorePlaybackFromHistory() {
guard let playbackState = CacheStore.shared.getPlaybackState(
vodId: video.id,
sourceKey: video.sourceKey,
context: modelContext
) else { return }
viewModel.applyPlaybackState(playbackState)
lastPersistedProgress = max(playbackState.progressSeconds, 0)
}
private func refreshCollectState() {
isCollected = CacheStore.shared.isCollected(
vodId: video.id,
sourceKey: video.sourceKey,
context: modelContext
)
}
private func toggleCollect() {
if isCollected {
CacheStore.shared.removeCollect(
vodId: video.id,
sourceKey: video.sourceKey,
context: modelContext
)
} else {
CacheStore.shared.addCollect(video, context: modelContext)
}
refreshCollectState()
}
private func playNextEpisodeIfNeeded() {
var moved = false
withAnimation {
moved = viewModel.playNext()
}
if moved {
saveHistoryForCurrentEpisode()
}
}
private func openFullScreenPlayer() {
#if os(iOS)
showFullScreen = true
#else
guard viewModel.playUrl != nil else { return }
showFullScreen = true
appState.enterPlayerFullScreen()
requestMacWindowFullScreen(enter: true)
#endif
}
#if os(macOS)
private func requestMacWindowFullScreen(enter: Bool) {
DispatchQueue.main.async {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
let isFullScreen = window.styleMask.contains(.fullScreen)
if enter != isFullScreen {
window.toggleFullScreen(nil)
}
}
}
private func closeMacFullScreenOverlay() {
let window = NSApp.keyWindow ?? NSApp.mainWindow
if window?.styleMask.contains(.fullScreen) == true {
requestMacWindowFullScreen(enter: false)
return
}
showFullScreen = false
appState.exitPlayerFullScreen()
}
#endif
}
/// 全屏播放器
struct FullScreenPlayerView: View {
let urlString: String
var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil
var onPlaybackEnded: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var vlcController: VLCPlayerController? = nil
var onCloseRequested: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
PlayerView(
urlString: urlString,
startPosition: startPosition,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onToggleFullScreen: {
if let onCloseRequested {
onCloseRequested()
} else {
dismiss()
}
},
canPlayNext: canPlayNext,
onPlayNext: onPlayNext,
vlcController: vlcController
)
.ignoresSafeArea()
VStack {
HStack {
Button {
if let onCloseRequested {
onCloseRequested()
} else {
dismiss()
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundColor(.white.opacity(0.8))
}
Spacer()
}
.padding()
Spacer()
}
}
}
}