增加注解
This commit is contained in:
@@ -2,9 +2,13 @@ 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 {
|
||||
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
|
||||
/// 详情页 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<Void, Never>?
|
||||
/// 解析令牌,防止异步结果回写到过期状态。
|
||||
private var qualityResolveToken = UUID()
|
||||
|
||||
/// 加载视频详情
|
||||
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
|
||||
realtimeProgressSeconds = 0
|
||||
|
||||
if let episode = vodInfo?.currentEpisode {
|
||||
// 仅当剧集 URL 变化时重置清晰度选择。
|
||||
let shouldResetQuality = qualityBaseEpisodeURL != episode.url
|
||||
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
|
||||
playUrl = selectedPlayableURL(fallback: episode.url)
|
||||
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
|
||||
selectedQualityId = option.id
|
||||
guard isPlaying else { return }
|
||||
|
||||
// “自动”使用基础剧集地址;其他选项使用对应变体地址。
|
||||
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
|
||||
guard !targetURL.isEmpty, playUrl != targetURL else { return }
|
||||
|
||||
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func selectedPlayableURL(fallback: String) -> String {
|
||||
// 若当前清晰度存在有效 URL,则优先使用;否则回退剧集原始地址。
|
||||
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
|
||||
if let selected, !selected.isEmpty {
|
||||
return selected
|
||||
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// 重置清晰度解析与选择状态。
|
||||
private func resetQualityState() {
|
||||
qualityResolveTask?.cancel()
|
||||
qualityResolveTask = nil
|
||||
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// 切换剧集时先取消旧任务,避免异步回写错位。
|
||||
qualityResolveTask?.cancel()
|
||||
qualityResolveTask = nil
|
||||
|
||||
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
|
||||
qualityOptions = [autoOption]
|
||||
|
||||
if let cached = qualityOptionCache[trimmedEpisodeURL] {
|
||||
// 缓存命中时直接复用,避免重复网络解析。
|
||||
qualityOptions = cached
|
||||
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
|
||||
selectedQualityId = PlaybackQualityOption.autoIdentifier
|
||||
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试从 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?
|
||||
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
|
||||
let bandwidth: Int?
|
||||
}
|
||||
|
||||
/// 轻量判断 URL 是否可能是 HLS 播放列表。
|
||||
private static func looksLikeHLSURL(_ url: URL) -> Bool {
|
||||
let lowercased = url.absoluteString.lowercased()
|
||||
if lowercased.contains(".m3u8") { return true }
|
||||
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
|
||||
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 [] }
|
||||
|
||||
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
|
||||
return merged
|
||||
}
|
||||
|
||||
/// 解析 `EXT-X-STREAM-INF` 的属性串为键值字典。
|
||||
private static func parseAttributeMap(_ raw: String) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
let pairs = splitAttributes(raw)
|
||||
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
|
||||
return result
|
||||
}
|
||||
|
||||
/// 按逗号分隔属性,但保留引号内逗号。
|
||||
private static func splitAttributes(_ raw: String) -> [String] {
|
||||
var parts: [String] = []
|
||||
var buffer = ""
|
||||
|
||||
@@ -4,15 +4,24 @@ import SwiftUI
|
||||
/// 首页 ViewModel
|
||||
@MainActor
|
||||
class HomeViewModel: ObservableObject {
|
||||
/// 分类列表(包含手动注入的“推荐”分类)。
|
||||
@Published var sorts: [MovieSort.SortData] = []
|
||||
/// 当前选中的分类。
|
||||
@Published var selectedSort: MovieSort.SortData?
|
||||
/// 首页推荐内容(对应“推荐”分类)。
|
||||
@Published var homeVideos: [Movie.Video] = []
|
||||
/// 普通分类的视频列表(分页加载)。
|
||||
@Published var categoryVideos: [Movie.Video] = []
|
||||
/// 页面加载状态(分类加载与分页共用)。
|
||||
@Published var isLoading = false
|
||||
/// 当前分类的分页页码。
|
||||
@Published var currentPage = 1
|
||||
/// 是否还有下一页。
|
||||
@Published var hasMore = true
|
||||
/// 错误提示文案。
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 源数据访问服务。
|
||||
private let sourceService = SourceService.shared
|
||||
|
||||
/// 加载分类列表
|
||||
@@ -24,7 +33,7 @@ class HomeViewModel: ObservableObject {
|
||||
do {
|
||||
let result = try await sourceService.getSort(sourceBean: source)
|
||||
|
||||
// 添加"推荐"到首位
|
||||
// 插入本地“推荐”分类,保持 UI 与 Android 版本习惯一致。
|
||||
var allSorts = [MovieSort.SortData.home()]
|
||||
allSorts.append(contentsOf: result.sorts)
|
||||
|
||||
@@ -43,6 +52,7 @@ class HomeViewModel: ObservableObject {
|
||||
|
||||
/// 选择分类
|
||||
func selectSort(_ sort: MovieSort.SortData) {
|
||||
// 切分类时先重置分页状态,避免旧分类残留数据闪烁。
|
||||
selectedSort = sort
|
||||
errorMessage = nil
|
||||
categoryVideos = []
|
||||
@@ -62,6 +72,7 @@ class HomeViewModel: ObservableObject {
|
||||
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
|
||||
guard sort.id != "home" else { return }
|
||||
guard let source = ApiConfig.shared.homeSourceBean else { return }
|
||||
// 防重复并发加载,避免分页错序。
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
@@ -78,6 +89,7 @@ class HomeViewModel: ObservableObject {
|
||||
} else {
|
||||
categoryVideos.append(contentsOf: videos)
|
||||
}
|
||||
// 以“返回非空”作为是否继续分页的轻量判断。
|
||||
currentPage = page
|
||||
hasMore = !videos.isEmpty
|
||||
} catch {
|
||||
@@ -105,6 +117,7 @@ class HomeViewModel: ObservableObject {
|
||||
|
||||
/// 刷新
|
||||
func refresh() async {
|
||||
// 全量刷新时重置分页与错误态,再重新拉分类与当前分类内容。
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
categoryVideos = []
|
||||
|
||||
@@ -4,12 +4,19 @@ import SwiftUI
|
||||
/// 直播 ViewModel
|
||||
@MainActor
|
||||
class LiveViewModel: ObservableObject {
|
||||
/// 全部频道分组。
|
||||
@Published var channelGroups: [LiveChannelGroup] = []
|
||||
/// 当前选中分组索引。
|
||||
@Published var selectedGroupIndex: Int = 0
|
||||
/// 当前选中频道索引(相对于当前分组)。
|
||||
@Published var selectedChannelIndex: Int = 0
|
||||
/// 当前播放频道。
|
||||
@Published var currentChannel: LiveChannelItem?
|
||||
/// 当前频道节目单(预留)。
|
||||
@Published var epgList: [Epginfo] = []
|
||||
/// 加载状态(预留,便于后续接入远程 EPG)。
|
||||
@Published var isLoading = false
|
||||
/// 是否显示频道列表(预留给 TV 遥控交互)。
|
||||
@Published var showChannelList = false
|
||||
|
||||
/// 加载直播频道
|
||||
@@ -67,6 +74,7 @@ class LiveViewModel: ObservableObject {
|
||||
|
||||
/// 切换线路
|
||||
func switchSource() {
|
||||
// `currentChannel` 为值类型,调用 mutating 方法会触发 @Published 重新发布。
|
||||
currentChannel?.nextSource()
|
||||
}
|
||||
|
||||
@@ -78,13 +86,15 @@ class LiveViewModel: ObservableObject {
|
||||
|
||||
/// 加载 EPG 节目单
|
||||
private func loadEPG(for channel: LiveChannelItem) {
|
||||
// EPG 加载 - 简化实现
|
||||
// 预留:后续可在此按频道名/频道 ID 请求远程 EPG。
|
||||
// 当前版本先清空,避免展示过期节目单。
|
||||
epgList = []
|
||||
}
|
||||
}
|
||||
|
||||
// 安全数组下标访问
|
||||
extension Collection {
|
||||
/// 安全下标读取,越界时返回 `nil`。
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
|
||||
@@ -4,14 +4,21 @@ import SwiftUI
|
||||
/// 搜索 ViewModel
|
||||
@MainActor
|
||||
class SearchViewModel: ObservableObject {
|
||||
/// 搜索关键词输入。
|
||||
@Published var keyword: String = ""
|
||||
/// 当前结果列表。
|
||||
@Published var results: [Movie.Video] = []
|
||||
/// 搜索加载状态,用于控制进度指示器。
|
||||
@Published var isSearching = false
|
||||
/// 本地搜索历史(最近在前)。
|
||||
@Published var searchHistory: [String] = []
|
||||
/// 搜索失败或空结果提示。
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 源数据服务(负责多源并发搜索)。
|
||||
private let sourceService = SourceService.shared
|
||||
|
||||
/// 初始化时同步加载本地历史记录,确保搜索页首次渲染即可展示。
|
||||
init() {
|
||||
loadSearchHistory()
|
||||
}
|
||||
@@ -25,9 +32,10 @@ class SearchViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
results = []
|
||||
|
||||
// 保存搜索历史
|
||||
// 搜索一旦触发就先落历史,保持行为与移动端常见搜索体验一致。
|
||||
addToHistory(trimmed)
|
||||
|
||||
// 走多源并发搜索,返回聚合后的影片列表。
|
||||
let videos = await sourceService.searchAll(keyword: trimmed)
|
||||
self.results = videos
|
||||
|
||||
@@ -57,10 +65,12 @@ class SearchViewModel: ObservableObject {
|
||||
|
||||
// MARK: - 搜索历史
|
||||
|
||||
/// 从本地读取历史。
|
||||
private func loadSearchHistory() {
|
||||
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
|
||||
}
|
||||
|
||||
/// 新增历史项并去重,最多保留 20 条。
|
||||
private func addToHistory(_ keyword: String) {
|
||||
searchHistory.removeAll { $0 == keyword }
|
||||
searchHistory.insert(keyword, at: 0)
|
||||
@@ -70,11 +80,13 @@ class SearchViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||
}
|
||||
|
||||
/// 清空历史。
|
||||
func clearHistory() {
|
||||
searchHistory = []
|
||||
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
|
||||
}
|
||||
|
||||
/// 删除单条历史。
|
||||
func removeFromHistory(_ keyword: String) {
|
||||
searchHistory.removeAll { $0 == keyword }
|
||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||
|
||||
@@ -4,7 +4,9 @@ import SwiftUI
|
||||
/// 设置 ViewModel
|
||||
@MainActor
|
||||
class SettingsViewModel: ObservableObject {
|
||||
/// 当输入地址是“多仓库入口”时,先弹出候选仓库供用户确认。
|
||||
struct PendingMultiRepoSelection: Identifiable {
|
||||
/// 当前待选择的是点播仓库还是直播仓库。
|
||||
enum Target {
|
||||
case vod
|
||||
case live
|
||||
@@ -18,30 +20,54 @@ class SettingsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
/// 目标类型。
|
||||
let target: Target
|
||||
/// 用户原始输入地址(用于后续“是否联动 live 地址”判断)。
|
||||
let sourceUrl: String
|
||||
/// 可选仓库列表。
|
||||
let options: [ApiConfig.MultiRepoOption]
|
||||
}
|
||||
|
||||
/// 点播配置地址。
|
||||
@Published var vodApiUrl: String = ""
|
||||
/// 直播配置地址。
|
||||
@Published var liveApiUrl: String = ""
|
||||
/// 配置加载中状态。
|
||||
@Published var isLoadingConfig = false
|
||||
/// 配置错误提示。
|
||||
@Published var configError: String?
|
||||
/// 配置是否加载成功(供 UI 执行后续跳转/收起流程)。
|
||||
@Published var configSuccess = false
|
||||
/// 多仓库待选状态,为 nil 表示无需弹窗。
|
||||
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
|
||||
/// 最近输入过的 API 历史。
|
||||
@Published var apiHistory: [String] = []
|
||||
/// 点播播放器内核选择。
|
||||
@Published var vodPlayerEngine: PlayerEngine = .system
|
||||
/// 直播播放器内核选择。
|
||||
@Published var livePlayerEngine: PlayerEngine = .system
|
||||
/// 解码模式选择。
|
||||
@Published var decodeMode: VideoDecodeMode = .auto
|
||||
/// VLC 缓冲策略。
|
||||
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
||||
/// 快进/快退步长(秒)。
|
||||
@Published var playTimeStep: Int = 10
|
||||
/// 缓存占用展示文本。
|
||||
@Published var cacheSizeString: String = "0 KB"
|
||||
|
||||
/// 快进步长候选项。
|
||||
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
|
||||
/// 当前构建可用播放器列表。
|
||||
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
|
||||
/// 解码模式候选。
|
||||
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
|
||||
/// VLC 缓冲模式候选。
|
||||
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
||||
|
||||
/// 初始化时完成三件事:
|
||||
/// 1) 回填已保存的配置地址
|
||||
/// 2) 兼容老版本单一播放器字段到新字段
|
||||
/// 3) 回填播放/缓存相关设置
|
||||
init() {
|
||||
let defaults = UserDefaults.standard
|
||||
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
@@ -99,6 +125,7 @@ class SettingsViewModel: ObservableObject {
|
||||
do {
|
||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||
|
||||
// 若探测到多仓库入口,先中断加载并弹出候选,让用户显式选定目标仓库。
|
||||
if let pending = try await detectPendingMultiRepoSelection(
|
||||
vodUrl: trimmedVod,
|
||||
liveUrl: resolvedLive
|
||||
@@ -109,6 +136,7 @@ class SettingsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
|
||||
// 保存用户输入(live 允许空值,表示跟随点播地址)。
|
||||
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
|
||||
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
|
||||
vodApiUrl = trimmedVod
|
||||
@@ -123,6 +151,7 @@ class SettingsViewModel: ObservableObject {
|
||||
isLoadingConfig = false
|
||||
}
|
||||
|
||||
/// 处理多仓库弹窗选择结果,并继续走统一加载流程。
|
||||
func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async {
|
||||
guard let pending = pendingMultiRepoSelection else { return }
|
||||
let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl)
|
||||
@@ -130,6 +159,7 @@ class SettingsViewModel: ObservableObject {
|
||||
switch pending.target {
|
||||
case .vod:
|
||||
let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl)
|
||||
// 若 live 输入与原始 vod 相同,说明用户希望两者共用,选择后同步更新。
|
||||
let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& normalizedLive == normalizedSource
|
||||
vodApiUrl = option.url
|
||||
@@ -144,11 +174,14 @@ class SettingsViewModel: ObservableObject {
|
||||
await loadConfig()
|
||||
}
|
||||
|
||||
/// 取消多仓库选择,恢复到普通待输入状态。
|
||||
func cancelPendingMultiRepoSelection() {
|
||||
pendingMultiRepoSelection = nil
|
||||
isLoadingConfig = false
|
||||
}
|
||||
|
||||
/// 尝试识别输入地址是否为多仓库入口。
|
||||
/// - Returns: 需要弹窗选择时返回待选对象,否则返回 `nil`。
|
||||
private func detectPendingMultiRepoSelection(
|
||||
vodUrl: String,
|
||||
liveUrl: String
|
||||
@@ -186,10 +219,12 @@ class SettingsViewModel: ObservableObject {
|
||||
|
||||
// MARK: - API 历史
|
||||
|
||||
/// 读取 API 历史。
|
||||
private func loadApiHistory() {
|
||||
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
|
||||
}
|
||||
|
||||
/// 新增历史并去重,最多保留 10 条。
|
||||
private func addToApiHistory(_ url: String) {
|
||||
apiHistory.removeAll { $0 == url }
|
||||
apiHistory.insert(url, at: 0)
|
||||
@@ -199,6 +234,7 @@ class SettingsViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||
}
|
||||
|
||||
/// 删除单条 API 历史。
|
||||
func removeApiHistory(_ url: String) {
|
||||
apiHistory.removeAll { $0 == url }
|
||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||
@@ -247,12 +283,14 @@ class SettingsViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
|
||||
}
|
||||
|
||||
/// 统计并刷新缓存占用展示(网络缓存 + 图片缓存磁盘占用)。
|
||||
private func refreshCacheSize() {
|
||||
let sharedDisk = URLCache.shared.currentDiskUsage
|
||||
let imageDisk = ImageLoader.shared.cacheUsage.disk
|
||||
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
|
||||
}
|
||||
|
||||
/// 格式化字节大小。
|
||||
private static func formatSize(bytes: Int) -> String {
|
||||
let size = max(0, bytes)
|
||||
if size < 1024 * 1024 {
|
||||
|
||||
Reference in New Issue
Block a user