import Foundation import SwiftUI struct PlaybackQualityOption: Identifiable, Hashable { /// “自动”选项固定标识。 static let autoIdentifier = "auto" /// 选项唯一标识(这里直接使用播放地址或固定 auto id)。 let id: String /// UI 展示名(如 1080p / 720p / 自动)。 let name: String /// 对应播放地址。 let url: String var isAuto: Bool { id == Self.autoIdentifier } static func auto(url: String) -> PlaybackQualityOption { PlaybackQualityOption(id: autoIdentifier, name: "自动", url: url) } } /// 详情页 ViewModel @MainActor class DetailViewModel: ObservableObject { /// 详情信息主体。 @Published var vodInfo: VodInfo? /// 加载状态。 @Published var isLoading = false /// 错误提示。 @Published var errorMessage: String? /// 当前选中线路。 @Published var selectedFlag: String = "" /// 当前选中剧集索引。 @Published var selectedEpisodeIndex: Int = 0 /// 是否处于播放态。 @Published var isPlaying = false /// 当前实际播放地址(可能是原始地址,也可能是清晰度切换后的子流地址)。 @Published var playUrl: String? /// 续播起始位置(秒)。 @Published var resumeSeconds: Double = 0 /// 当前可选清晰度列表。 @Published var qualityOptions: [PlaybackQualityOption] = [] /// 当前选中的清晰度 id。 @Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier /// 播放器高频回调进度,不直接绑定 UI,避免高频刷新引发性能问题。 private var realtimeProgressSeconds: Double = 0 /// 数据服务与网络服务。 private let sourceService = SourceService.shared private let network = NetworkManager.shared /// 当前清晰度列表对应的基础剧集地址。 private var qualityBaseEpisodeURL: String = "" /// 清晰度解析缓存,key 为原始剧集 URL。 private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:] /// 清晰度解析任务,用于取消旧请求。 private var qualityResolveTask: Task? /// 解析令牌,防止异步结果回写到过期状态。 private var qualityResolveToken = UUID() /// 加载视频详情 func loadDetail(video: Movie.Video) async { guard let source = ApiConfig.shared.getSource(key: video.sourceKey) ?? ApiConfig.shared.homeSourceBean else { return } isLoading = true errorMessage = nil do { if let info = try await sourceService.getDetail(sourceBean: source, vodId: video.id) { self.vodInfo = info self.selectedFlag = info.playFlag self.selectedEpisodeIndex = info.playIndex self.resumeSeconds = 0 self.realtimeProgressSeconds = 0 if let episode = info.currentEpisode { updateQualityOptions(for: episode.url, resetSelection: true) } else { resetQualityState() } } } catch { errorMessage = error.localizedDescription } isLoading = false } /// 选择线路 func selectFlag(_ flag: String) { guard selectedFlag != flag else { return } let currentIndex = selectedEpisodeIndex selectedFlag = flag vodInfo?.playFlag = flag resumeSeconds = 0 realtimeProgressSeconds = 0 let episodes = vodInfo?.playUrlMap[flag] ?? [] guard !episodes.isEmpty else { selectedEpisodeIndex = 0 vodInfo?.playIndex = 0 resetQualityState() return } let targetIndex = min(max(currentIndex, 0), episodes.count - 1) selectedEpisodeIndex = targetIndex vodInfo?.playIndex = targetIndex let episodeURL = episodes[targetIndex].url updateQualityOptions(for: episodeURL, resetSelection: true) // 播放中切线路时,立即切换到新线路对应剧集 if isPlaying { playUrl = selectedPlayableURL(fallback: episodeURL) } } /// 选择剧集并播放 func selectEpisode(index: Int) { guard selectedEpisodeIndex != index || !isPlaying else { return } selectedEpisodeIndex = index vodInfo?.playIndex = index resumeSeconds = 0 realtimeProgressSeconds = 0 if let episode = vodInfo?.currentEpisode { // 仅当剧集 URL 变化时重置清晰度选择。 let shouldResetQuality = qualityBaseEpisodeURL != episode.url updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality) playUrl = selectedPlayableURL(fallback: episode.url) isPlaying = true } } /// 应用历史续播状态并自动继续播放 func applyPlaybackState(_ state: VodPlaybackState) { guard let info = vodInfo, !info.playFlags.isEmpty else { return } let fallbackFlag = info.playFlag.isEmpty ? info.playFlags[0] : info.playFlag let targetFlag = info.playFlags.contains(state.flag) ? state.flag : fallbackFlag selectedFlag = targetFlag vodInfo?.playFlag = targetFlag let episodes = vodInfo?.playUrlMap[targetFlag] ?? [] guard !episodes.isEmpty else { return } let targetIndex = min(max(state.episodeIndex, 0), episodes.count - 1) selectedEpisodeIndex = targetIndex vodInfo?.playIndex = targetIndex let progress = max(0, state.progressSeconds) resumeSeconds = progress realtimeProgressSeconds = progress let episodeURL = episodes[targetIndex].url updateQualityOptions(for: episodeURL, resetSelection: true) playUrl = selectedPlayableURL(fallback: episodeURL) isPlaying = true } /// 选择清晰度 func selectQuality(_ option: PlaybackQualityOption) { guard qualityOptions.contains(option) else { return } selectedQualityId = option.id guard isPlaying else { return } // “自动”使用基础剧集地址;其他选项使用对应变体地址。 let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url guard !targetURL.isEmpty, playUrl != targetURL else { return } let progress = max(currentPlaybackSeconds(), 0) resumeSeconds = progress realtimeProgressSeconds = progress playUrl = targetURL } /// 播放器时间回调 func updatePlaybackProgress(seconds: Double) { guard seconds.isFinite else { return } realtimeProgressSeconds = max(seconds, 0) } /// 当前实时进度(不触发 UI 高频刷新) func currentPlaybackSeconds() -> Double { max(realtimeProgressSeconds, resumeSeconds) } /// 仅在必要时同步快照到可观察状态 func commitPlaybackProgressSnapshot() { let snapshot = max(realtimeProgressSeconds, 0) if abs(snapshot - resumeSeconds) >= 1 { resumeSeconds = snapshot } } /// 播放下一集 func playNext() -> Bool { guard let info = vodInfo else { return false } let episodes = info.currentEpisodes if selectedEpisodeIndex + 1 < episodes.count { selectEpisode(index: selectedEpisodeIndex + 1) return true } return false } /// 播放上一集 func playPrevious() -> Bool { if selectedEpisodeIndex > 0 { selectEpisode(index: selectedEpisodeIndex - 1) return true } return false } /// 当前剧集列表 var currentEpisodes: [VodInfo.Episode] { vodInfo?.playUrlMap[selectedFlag] ?? [] } /// 可选线路列表 var flags: [String] { vodInfo?.playFlags ?? [] } /// 是否存在可选清晰度 var hasQualityChoices: Bool { qualityOptions.count > 1 } private func selectedPlayableURL(fallback: String) -> String { // 若当前清晰度存在有效 URL,则优先使用;否则回退剧集原始地址。 let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url if let selected, !selected.isEmpty { return selected } return fallback } /// 重置清晰度解析与选择状态。 private func resetQualityState() { qualityResolveTask?.cancel() qualityResolveTask = nil qualityBaseEpisodeURL = "" qualityOptions = [] selectedQualityId = PlaybackQualityOption.autoIdentifier qualityResolveToken = UUID() } private func updateQualityOptions(for episodeURL: String, resetSelection: Bool) { let trimmedEpisodeURL = episodeURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedEpisodeURL.isEmpty else { resetQualityState() return } // 切换剧集时先取消旧任务,避免异步回写错位。 qualityResolveTask?.cancel() qualityResolveTask = nil let autoOption = PlaybackQualityOption.auto(url: trimmedEpisodeURL) let previousSelected = selectedQualityId qualityBaseEpisodeURL = trimmedEpisodeURL if resetSelection { selectedQualityId = PlaybackQualityOption.autoIdentifier } qualityOptions = [autoOption] if let cached = qualityOptionCache[trimmedEpisodeURL] { // 缓存命中时直接复用,避免重复网络解析。 qualityOptions = cached if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) { selectedQualityId = PlaybackQualityOption.autoIdentifier } else if previousSelected != selectedQualityId && cached.contains(where: { $0.id == previousSelected }) { selectedQualityId = previousSelected } return } let token = UUID() qualityResolveToken = token qualityResolveTask = Task { [trimmedEpisodeURL, resetSelection, previousSelected] in let resolved = await resolveQualityOptions(for: trimmedEpisodeURL) guard !Task.isCancelled else { return } guard qualityResolveToken == token, qualityBaseEpisodeURL == trimmedEpisodeURL else { return } guard !resolved.isEmpty else { return } qualityOptionCache[trimmedEpisodeURL] = resolved qualityOptions = resolved if resetSelection { selectedQualityId = PlaybackQualityOption.autoIdentifier } else if resolved.contains(where: { $0.id == selectedQualityId }) { // 当前选择仍有效,保持不变 } else if resolved.contains(where: { $0.id == previousSelected }) { selectedQualityId = previousSelected } else { selectedQualityId = PlaybackQualityOption.autoIdentifier } } } /// 尝试从 HLS 主播放列表解析多清晰度选项。 private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] { guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] } guard let playlist = try? await network.getString(from: episodeURL) else { return [] } return Self.parseMasterPlaylist(playlist, masterURL: url) } /// HLS 变体流中间模型。 private struct HLSVariant { let url: String let name: String? let height: Int? let bandwidth: Int? } /// 轻量判断 URL 是否可能是 HLS 播放列表。 private static func looksLikeHLSURL(_ url: URL) -> Bool { let lowercased = url.absoluteString.lowercased() if lowercased.contains(".m3u8") { return true } let ext = url.pathExtension.lowercased() return ext == "m3u8" || ext == "m3u" } /// 解析 HLS 主播放列表并生成清晰度选项。 /// 仅当解析出 2 个及以上有效变体时才返回(否则不显示清晰度切换)。 private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] { guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] } let lines = content.components(separatedBy: .newlines) var variants: [HLSVariant] = [] var index = 0 while index < lines.count { let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) guard line.hasPrefix("#EXT-X-STREAM-INF:") else { index += 1 continue } let attributeString = String(line.dropFirst("#EXT-X-STREAM-INF:".count)) let attributes = parseAttributeMap(attributeString) var uri: String? var nextIndex = index + 1 while nextIndex < lines.count { let candidate = lines[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) if candidate.isEmpty { nextIndex += 1 continue } if candidate.hasPrefix("#") { nextIndex += 1 continue } uri = candidate break } if let uri, !uri.isEmpty { let resolvedURL = URL(string: uri, relativeTo: masterURL)?.absoluteURL.absoluteString ?? uri let name = attributes["NAME"]?.trimmingCharacters(in: .whitespacesAndNewlines) let bandwidth = attributes["BANDWIDTH"].flatMap(Int.init) let height: Int? if let resolution = attributes["RESOLUTION"] { let parts = resolution.split(separator: "x") if parts.count == 2 { height = Int(parts[1]) } else { height = nil } } else { height = nil } variants.append(HLSVariant( url: resolvedURL, name: name?.isEmpty == true ? nil : name, height: height, bandwidth: bandwidth )) } index = nextIndex + 1 } guard !variants.isEmpty else { return [] } var seenURLs = Set() let deduped = variants.filter { variant in let inserted = seenURLs.insert(variant.url).inserted return inserted } let sorted = deduped.sorted { lhs, rhs in let lhsHeight = lhs.height ?? -1 let rhsHeight = rhs.height ?? -1 if lhsHeight != rhsHeight { return lhsHeight > rhsHeight } let lhsBandwidth = lhs.bandwidth ?? -1 let rhsBandwidth = rhs.bandwidth ?? -1 if lhsBandwidth != rhsBandwidth { return lhsBandwidth > rhsBandwidth } return lhs.url < rhs.url } let masterURLString = masterURL.absoluteString var displayNameCount: [String: Int] = [:] let options = sorted.enumerated().map { offset, variant -> PlaybackQualityOption in let baseName: String if let name = variant.name, !name.isEmpty { baseName = name } else if let height = variant.height { baseName = "\(height)p" } else if let bandwidth = variant.bandwidth, bandwidth > 0 { baseName = "\(bandwidth / 1000)K" } else { baseName = "清晰度\(offset + 1)" } let newCount = (displayNameCount[baseName] ?? 0) + 1 displayNameCount[baseName] = newCount let finalName = newCount > 1 ? "\(baseName) \(newCount)" : baseName return PlaybackQualityOption(id: variant.url, name: finalName, url: variant.url) }.filter { !$0.url.isEmpty && $0.url != masterURLString } guard options.count >= 2 else { return [] } var merged = [PlaybackQualityOption.auto(url: masterURLString)] merged.append(contentsOf: options) return merged } /// 解析 `EXT-X-STREAM-INF` 的属性串为键值字典。 private static func parseAttributeMap(_ raw: String) -> [String: String] { var result: [String: String] = [:] let pairs = splitAttributes(raw) for pair in pairs { let components = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) guard components.count == 2 else { continue } let key = String(components[0]).trimmingCharacters(in: .whitespacesAndNewlines) var value = String(components[1]).trimmingCharacters(in: .whitespacesAndNewlines) if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { value.removeFirst() value.removeLast() } if !key.isEmpty { result[key] = value } } return result } /// 按逗号分隔属性,但保留引号内逗号。 private static func splitAttributes(_ raw: String) -> [String] { var parts: [String] = [] var buffer = "" var inQuotes = false for char in raw { if char == "\"" { inQuotes.toggle() buffer.append(char) continue } if char == "," && !inQuotes { let item = buffer.trimmingCharacters(in: .whitespacesAndNewlines) if !item.isEmpty { parts.append(item) } buffer.removeAll(keepingCapacity: true) continue } buffer.append(char) } let tail = buffer.trimmingCharacters(in: .whitespacesAndNewlines) if !tail.isEmpty { parts.append(tail) } return parts } }