first commit

This commit is contained in:
JinJiangHuang
2026-03-01 14:04:44 +08:00
commit 61e97da938
62 changed files with 9947 additions and 0 deletions
+588
View File
@@ -0,0 +1,588 @@
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()
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
/// - Android SeriesAdapter
struct EpisodeListView: View {
let episodes: [VodInfo.Episode]
let selectedIndex: Int
let onSelect: (Int) -> Void
@State private var currentGroup = 0
private let groupSize = 50
private var groupCount: Int {
max(1, (episodes.count + groupSize - 1) / groupSize)
}
private var currentEpisodes: [VodInfo.Episode] {
let start = currentGroup * groupSize
let end = min(start + groupSize, episodes.count)
guard start < episodes.count else { return [] }
return Array(episodes[start..<end])
}
var body: some View {
VStack(spacing: 12) {
//
if groupCount > 1 {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(0..<groupCount, id: \.self) { group in
let start = group * groupSize + 1
let end = min((group + 1) * groupSize, episodes.count)
Button {
withAnimation {
currentGroup = group
}
} label: {
Text("\(start)-\(end)")
.font(.system(size: 11, weight: .medium))
.foregroundColor(currentGroup == group ? .white : .white.opacity(0.5))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
ZStack {
if currentGroup == group {
Capsule().fill(Color.white.opacity(0.15))
} else {
Capsule().fill(Color.white.opacity(0.05))
}
}
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
}
//
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [
GridItem(.fixed(44)),
GridItem(.fixed(44))
], spacing: 10) {
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
let actualIndex = currentGroup * groupSize + index
Button {
onSelect(actualIndex)
} label: {
Text(episode.name)
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
.frame(minWidth: 70)
.frame(height: 44)
.background(
ZStack {
if actualIndex == selectedIndex {
AppTheme.accentGradient
} else {
Color.white.opacity(0.05)
}
}
)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
.frame(height: 100)
}
}
}