From ee8c71dcd4dfcb0db41f20aba6bddc3d456bb32e Mon Sep 17 00:00:00 2001 From: JinJiangHuang Date: Sun, 1 Mar 2026 22:23:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tvbox/Models/LiveModels.swift | 24 ++++++++++++-- tvbox/Models/Movie.swift | 34 +++++++++++++++----- tvbox/Models/MovieSort.swift | 16 ++++++++-- tvbox/Models/PlayerEngine.swift | 18 +++++++++++ tvbox/Models/SourceBean.swift | 17 +++++++--- tvbox/Models/VodInfo.swift | 23 ++++++++++++-- tvbox/Persistence/CacheStore.swift | 26 ++++++++++++++-- tvbox/Services/NetworkManager.swift | 14 +++++++++ tvbox/ViewModels/DetailViewModel.swift | 33 ++++++++++++++++++++ tvbox/ViewModels/HomeViewModel.swift | 15 ++++++++- tvbox/ViewModels/LiveViewModel.swift | 12 ++++++- tvbox/ViewModels/SearchViewModel.swift | 14 ++++++++- tvbox/ViewModels/SettingsViewModel.swift | 38 +++++++++++++++++++++++ tvbox/Views/Common/EmptyStateView.swift | 7 +++++ tvbox/Views/ContentView.swift | 11 +++++++ tvbox/Views/DesignSystem.swift | 3 +- tvbox/Views/Detail/EpisodeListView.swift | 8 +++++ tvbox/Views/Favorites/FavoritesView.swift | 10 ++++++ tvbox/Views/History/HistoryView.swift | 11 +++++++ tvbox/Views/Home/VodCardView.swift | 4 +++ tvbox/Views/Live/LiveView.swift | 27 ++++++++++++++++ tvbox/Views/Search/SearchView.swift | 11 +++++++ tvbox/tvboxApp.swift | 22 +++++++++++++ 23 files changed, 374 insertions(+), 24 deletions(-) diff --git a/tvbox/Models/LiveModels.swift b/tvbox/Models/LiveModels.swift index 3b919ad..4fee90d 100644 --- a/tvbox/Models/LiveModels.swift +++ b/tvbox/Models/LiveModels.swift @@ -4,10 +4,15 @@ import Foundation /// 直播频道分组 struct LiveChannelGroup: Codable, Identifiable, Hashable { + /// 以分组名作为稳定标识,便于 SwiftUI 列表 diff。 var id: String { groupName } + /// 分组名称(如“央视”“卫视”)。 var groupName: String = "" + /// 分组在列表中的顺序索引。 var groupIndex: Int = 0 + /// 分组下频道列表。 var channels: [LiveChannelItem] = [] + /// 是否为加密分组(当前实现仅保留字段,未启用密码校验)。 var isPassword: Bool = false init(groupName: String = "", groupIndex: Int = 0) { @@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable { /// 直播频道 struct LiveChannelItem: Codable, Identifiable, Hashable { + /// 频道标识由名称+索引组成,规避同名频道冲突。 var id: String { "\(channelName)_\(channelIndex)" } + /// 频道名。 var channelName: String = "" + /// 频道在分组内的顺序索引。 var channelIndex: Int = 0 + /// 多线路播放地址。 var channelUrls: [String] = [] + /// 当前选中的线路索引。 var sourceIndex: Int = 0 + /// 可用线路总数。 var sourceNum: Int { channelUrls.count } + /// 台标地址(预留)。 var logo: String = "" init(channelName: String = "", channelIndex: Int = 0) { @@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable { self.channelIndex = channelIndex } + /// 当前线路对应的播放地址。 + /// 当索引越界时兜底返回第一条线路,避免直接播放失败。 var currentUrl: String? { guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first } return channelUrls[sourceIndex] } + /// 轮换到下一条线路。 mutating func nextSource() { if channelUrls.count > 0 { sourceIndex = (sourceIndex + 1) % channelUrls.count @@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable { var endTime: String = "" var index: Int = 0 + /// 根据 `HH:mm` 时间段判断节目是否正在播出。 var isLive: Bool { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" @@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable { /// EPG 日期分组 struct LiveEpgDate: Codable, Identifiable, Hashable { var id: String { datePresent } - var datePresent: String = "" // 显示日期 - var date: String = "" // 查询日期 + /// 供 UI 展示的日期文案。 + var datePresent: String = "" + /// 供接口查询的原始日期值。 + var date: String = "" + /// 在日期列表中的位置索引。 var index: Int = 0 + /// 是否被当前 UI 选中。 var isSelected: Bool = false } diff --git a/tvbox/Models/Movie.swift b/tvbox/Models/Movie.swift index f9670d4..1299db1 100644 --- a/tvbox/Models/Movie.swift +++ b/tvbox/Models/Movie.swift @@ -2,28 +2,47 @@ import Foundation /// 电影/视频数据模型 - 对应 Android 版 Movie.java struct Movie: Codable { + /// 列表数据主体。 var videoList: [Video] = [] + /// 总页数。 var pagecount: Int = 0 + /// 当前页码。 var page: Int = 0 + /// 总条数。 var total: Int = 0 + /// 每页条数。 var limit: Int = 0 /// 单个视频条目 struct Video: Codable, Identifiable, Hashable { + /// 视频唯一 ID(接口可能返回 Int 或 String,见自定义解码)。 var id: String + /// 片名。 var name: String = "" + /// 海报地址。 var pic: String = "" - var note: String = "" // 备注(如"更新至第20集") + /// 备注(如“更新至第20集”)。 + var note: String = "" + /// 年份。 var year: String = "" + /// 地区。 var area: String = "" - var type: String = "" // 类型/分类名 + /// 类型/分类名。 + var type: String = "" + /// 导演。 var director: String = "" + /// 演员。 var actor: String = "" - var des: String = "" // 简介 - var sourceKey: String = "" // 来源站点 key - var tid: String = "" // 分类 ID - var last: String = "" // 最后更新 - var dt: String = "" // 日期 + /// 简介。 + var des: String = "" + /// 来源站点 key,用于跨源隔离收藏与历史。 + var sourceKey: String = "" + /// 分类 ID。 + var tid: String = "" + /// 最后更新时间。 + var last: String = "" + /// 播放来源信息(部分接口会复用该字段)。 + var dt: String = "" init(id: String = UUID().uuidString, name: String = "", pic: String = "", note: String = "", sourceKey: String = "") { @@ -51,6 +70,7 @@ struct Movie: Codable { case sourceKey } + /// 自定义解码以兼容多源字段类型差异(如 `vod_id` / `type_id` 可能是 Int 或 String)。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) // 支持 String 或 Int 类型的 id diff --git a/tvbox/Models/MovieSort.swift b/tvbox/Models/MovieSort.swift index c3af00d..02ca393 100644 --- a/tvbox/Models/MovieSort.swift +++ b/tvbox/Models/MovieSort.swift @@ -2,13 +2,18 @@ import Foundation /// 分类排序模型 - 对应 Android 版 MovieSort.java struct MovieSort: Codable { + /// 分类列表(包含首页推荐、影视分类等)。 var sortList: [SortData] = [] /// 单个分类数据 struct SortData: Codable, Identifiable, Hashable { + /// 分类唯一标识(接口字段通常为 type_id)。 var id: String + /// 分类显示名。 var name: String = "" + /// 标记位(不同源可定义不同语义,常用于首页/推荐标识)。 var flag: String = "" + /// 分类下可选筛选项(年份、地区、类型等)。 var filters: [SortFilter] = [] init(id: String = "", name: String = "", flag: String = "") { @@ -17,6 +22,8 @@ struct MovieSort: Codable { self.flag = flag } + /// 生成首页推荐占位分类。 + /// 该分类不走常规分类接口,直接渲染首页推荐列表。 static func home() -> SortData { SortData(id: "home", name: "推荐", flag: "1") } @@ -24,13 +31,18 @@ struct MovieSort: Codable { /// 筛选条件 struct SortFilter: Codable, Hashable { + /// 接口参数键,例如 `year`、`area`。 var key: String = "" + /// UI 展示名称。 var name: String = "" + /// 可选值集合。 var values: [SortFilterValue] = [] struct SortFilterValue: Codable, Hashable { - var n: String = "" // 显示名 - var v: String = "" // 值 + /// 展示名。 + var n: String = "" + /// 真实参数值。 + var v: String = "" } } } diff --git a/tvbox/Models/PlayerEngine.swift b/tvbox/Models/PlayerEngine.swift index df9adc8..b50784b 100644 --- a/tvbox/Models/PlayerEngine.swift +++ b/tvbox/Models/PlayerEngine.swift @@ -2,11 +2,14 @@ import Foundation /// 播放器引擎类型 enum PlayerEngine: Int, CaseIterable, Identifiable { + /// 系统 AVPlayer 内核。 case system = 0 + /// VLC 内核(需编译时可导入 VLCKitSPM)。 case vlc = 10 var id: Int { rawValue } + /// UI 展示名。 var title: String { switch self { case .system: @@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable { } } + /// 当前构建产物是否包含 VLC 能力。 static var isVLCAvailable: Bool { #if canImport(VLCKitSPM) return true @@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable { #endif } + /// 实际可供用户选择的播放器列表。 + /// 当 VLC 不可用时,仅暴露系统播放器,避免无效配置。 static var availableEngines: [PlayerEngine] { var engines: [PlayerEngine] = [.system] if isVLCAvailable { @@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable { return engines } + /// 从持久化值恢复播放器选项,并自动兜底到可用引擎。 static func fromStoredValue(_ rawValue: Int) -> PlayerEngine { guard let engine = PlayerEngine(rawValue: rawValue) else { return .system @@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable { /// 视频解码模式 enum VideoDecodeMode: Int, CaseIterable, Identifiable { + /// 自动策略,优先硬解,失败时用户可切换。 case auto = 0 + /// 强制硬解。 case hardware = 1 + /// 强制软解。 case software = 2 var id: Int { rawValue } @@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable { } /// VLC 媒体选项 + /// 返回 `avcodec-hw` 对应的值。 var vlcHardwareDecodeOption: String? { switch self { case .auto: @@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable { /// VLC 缓冲策略 enum VLCBufferMode: Int, CaseIterable, Identifiable { + /// 低延迟优先,适合直播但容错较低。 case lowLatency = 0 + /// 兼顾延迟与稳定性,作为默认策略。 case balanced = 1 + /// 稳定流畅优先,允许更高缓冲。 case smooth = 2 var id: Int { rawValue } @@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable { self == .lowLatency } + /// 根据直播/点播场景输出三类缓存值(单位毫秒)。 + /// - Parameters: + /// - isLive: 是否直播场景 + /// - Returns: network/live/file 三类缓存配置 func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) { switch self { case .lowLatency: diff --git a/tvbox/Models/SourceBean.swift b/tvbox/Models/SourceBean.swift index de598cf..671b8b0 100644 --- a/tvbox/Models/SourceBean.swift +++ b/tvbox/Models/SourceBean.swift @@ -2,15 +2,24 @@ import Foundation /// 视频源站点配置 - 对应 Android 版 SourceBean.java struct SourceBean: Codable, Identifiable, Hashable { + /// 以源 key 作为稳定标识。 var id: String { key } + /// 源唯一键。 let key: String + /// 源显示名。 let name: String + /// 源接口地址。 let api: String - let searchable: Int // 0:关闭搜索 1:启用搜索 - let filterable: Int // 0:首页不可选 1:首页可选 - let playerType: Int // 0:系统 1:IJK 2:EXO - let type: Int // 0:xml 1:json 3:jar 4:remote + /// 搜索开关:0 关闭,1 开启。 + let searchable: Int + /// 是否允许出现在首页分类:0 不可选,1 可选。 + let filterable: Int + /// 源声明的播放器类型(历史字段,Swift 端目前主要走统一播放器策略)。 + let playerType: Int + /// 源协议类型:0 XML,1 JSON,3 JAR,4 Remote。 + let type: Int + /// 扩展参数(remote 源常用)。 let ext: String? init(key: String = "", name: String = "", api: String = "", diff --git a/tvbox/Models/VodInfo.swift b/tvbox/Models/VodInfo.swift index 97bbd15..6788102 100644 --- a/tvbox/Models/VodInfo.swift +++ b/tvbox/Models/VodInfo.swift @@ -2,16 +2,27 @@ import Foundation /// 视频详情模型 - 对应 Android 版 VodInfo.java struct VodInfo: Codable, Identifiable { + /// 视频唯一 ID。 var id: String + /// 标题。 var name: String = "" + /// 海报地址。 var pic: String = "" + /// 备注(更新状态等)。 var note: String = "" + /// 年份。 var year: String = "" + /// 地区。 var area: String = "" + /// 类型名。 var typeName: String = "" + /// 导演。 var director: String = "" + /// 演员。 var actor: String = "" + /// 简介。 var des: String = "" + /// 来源站点 key。 var sourceKey: String = "" /// 播放来源(线路)列表 @@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable { /// key: flag名称, value: 剧集列表 var playUrlMap: [String: [Episode]] = [:] - var playFlag: String = "" // 当前选中线路 - var playIndex: Int = 0 // 当前播放剧集索引 + /// 当前选中线路。 + var playFlag: String = "" + /// 当前播放剧集索引。 + var playIndex: Int = 0 /// 单集信息 struct Episode: Codable, Identifiable, Hashable { var id: String { name } + /// 集标题。 let name: String + /// 集播放地址。 let url: String init(name: String, url: String) { @@ -48,7 +63,7 @@ struct VodInfo: Codable, Identifiable { info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) info.sourceKey = video.sourceKey - // 解析播放列表 + // 解析播放列表: // playFrom 格式: "线路1$$$线路2$$$线路3" // playUrl 格式: "第1集$url1#第2集$url2$$$第1集$url3#第2集$url4" let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty } @@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable { return info } + /// 当前线路下的剧集。 var currentEpisodes: [Episode] { playUrlMap[playFlag] ?? [] } + /// 当前线路 + 当前索引对应的剧集对象。 var currentEpisode: Episode? { let eps = currentEpisodes guard playIndex >= 0, playIndex < eps.count else { return nil } diff --git a/tvbox/Persistence/CacheStore.swift b/tvbox/Persistence/CacheStore.swift index 4665d40..a6122ba 100644 --- a/tvbox/Persistence/CacheStore.swift +++ b/tvbox/Persistence/CacheStore.swift @@ -5,18 +5,26 @@ import SwiftData /// 单部剧的续播状态 struct VodPlaybackState: Codable { + /// 当前播放线路标识。 var flag: String + /// 剧集索引。 var episodeIndex: Int + /// 播放进度(秒)。 var progressSeconds: Double } /// 视频收藏 @Model final class VodCollect { + /// 视频 ID(与 sourceKey 组成唯一语义键)。 var vodId: String = "" + /// 片名。 var vodName: String = "" + /// 海报地址。 var vodPic: String = "" + /// 来源站点 key。 var sourceKey: String = "" + /// 最近更新时间(收藏创建/刷新时间)。 var updateTime: Date = Date() init(vodId: String, vodName: String, vodPic: String, sourceKey: String) { @@ -31,12 +39,19 @@ final class VodCollect { /// 播放历史记录 @Model final class VodRecord { + /// 视频 ID。 var vodId: String = "" + /// 片名。 var vodName: String = "" + /// 海报地址。 var vodPic: String = "" + /// 来源站点 key。 var sourceKey: String = "" - var playNote: String = "" // 如 "第5集 03:45" - var dataJson: String = "" // 续播状态 JSON(VodPlaybackState) + /// 播放标记,如“第5集 03:45”。 + var playNote: String = "" + /// 续播状态 JSON(`VodPlaybackState` 编码结果)。 + var dataJson: String = "" + /// 最近播放时间。 var updateTime: Date = Date() init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") { @@ -52,8 +67,11 @@ final class VodRecord { /// 通用缓存 @Model final class CacheItem { + /// 唯一缓存键。 @Attribute(.unique) var key: String = "" + /// 缓存值(字符串形式)。 var value: String = "" + /// 更新时间。 var updateTime: Date = Date() init(key: String, value: String) { @@ -95,6 +113,7 @@ actor CacheStore { @MainActor func removeCollect(vodId: String, sourceKey: String, context: ModelContext) { + // 收藏以 (vodId, sourceKey) 为业务唯一键,删除时也按该组合匹配。 let predicate = #Predicate { item in item.vodId == vodId && item.sourceKey == sourceKey } @@ -151,6 +170,7 @@ actor CacheStore { try? context.save() } + /// 读取续播状态(若无记录或 JSON 无法解码则返回 `nil`)。 @MainActor func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? { guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else { @@ -171,6 +191,7 @@ actor CacheStore { @MainActor private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? { + // 历史记录同样以 (vodId, sourceKey) 作为业务键。 let predicate = #Predicate { item in item.vodId == vodId && item.sourceKey == sourceKey } @@ -185,6 +206,7 @@ actor CacheStore { return String(data: data, encoding: .utf8) } + /// 从 JSON 字符串反序列化续播状态。 private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? { guard let data = json.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(VodPlaybackState.self, from: data) diff --git a/tvbox/Services/NetworkManager.swift b/tvbox/Services/NetworkManager.swift index 861f8be..ff1e7f3 100644 --- a/tvbox/Services/NetworkManager.swift +++ b/tvbox/Services/NetworkManager.swift @@ -2,8 +2,10 @@ import Foundation /// 网络请求封装 - 对应 Android 版 OkGo class NetworkManager { + /// 全局共享实例。 static let shared = NetworkManager() + /// 兜底字符集名称列表(按常见中文资源站编码优先级排序)。 private static let fallbackCharsetNames: [String] = [ "utf-8", "gb18030", @@ -17,6 +19,7 @@ class NetworkManager { "iso-8859-1" ] + /// 兜底字符串编码列表(与字符集列表互补)。 private static let fallbackStringEncodings: [String.Encoding] = [ .utf8, .utf16, @@ -29,9 +32,12 @@ class NetworkManager { .isoLatin1 ] + /// 内部会话对象,统一超时与连接上限配置。 private let session: URLSession + /// JSON 解码器。 private let decoder = JSONDecoder() + /// 私有初始化,防止外部创建多个请求管理器。 private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 @@ -60,6 +66,7 @@ class NetworkManager { throw NetworkError.httpError(httpResponse.statusCode) } + // 统一走字符集探测 + 多编码兜底,降低跨源乱码概率。 guard let str = Self.decodeString(data: data, response: httpResponse) else { throw NetworkError.decodingError("文本解码失败") } @@ -85,6 +92,10 @@ class NetworkManager { return data } + /// 文本解码策略: + /// 1) 先用响应头声明字符集; + /// 2) 再按常见字符集与编码顺序尝试; + /// 3) 最后用 UTF-8 宽容解码兜底。 private static func decodeString(data: Data, response: HTTPURLResponse) -> String? { // 优先使用服务端声明的字符集(如 gbk / gb2312 / gb18030) if let charset = response.textEncodingName, @@ -114,6 +125,7 @@ class NetworkManager { return nil } + /// IANA 字符集名转 `String.Encoding`。 private static func encoding(fromIANACharset charset: String) -> String.Encoding? { let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString) guard cfEncoding != kCFStringEncodingInvalidId else { @@ -124,12 +136,14 @@ class NetworkManager { } } +/// 网络层错误定义。 enum NetworkError: LocalizedError { case invalidURL(String) case invalidResponse case httpError(Int) case decodingError(String) + /// 面向 UI/日志的错误描述。 var errorDescription: String? { switch self { case .invalidURL(let url): return "无效的URL: \(url)" diff --git a/tvbox/ViewModels/DetailViewModel.swift b/tvbox/ViewModels/DetailViewModel.swift index 879b667..dc23976 100644 --- a/tvbox/ViewModels/DetailViewModel.swift +++ b/tvbox/ViewModels/DetailViewModel.swift @@ -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? + /// 解析令牌,防止异步结果回写到过期状态。 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 = "" diff --git a/tvbox/ViewModels/HomeViewModel.swift b/tvbox/ViewModels/HomeViewModel.swift index c277cf9..24ef988 100644 --- a/tvbox/ViewModels/HomeViewModel.swift +++ b/tvbox/ViewModels/HomeViewModel.swift @@ -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 = [] diff --git a/tvbox/ViewModels/LiveViewModel.swift b/tvbox/ViewModels/LiveViewModel.swift index 6a8288f..dce7e5e 100644 --- a/tvbox/ViewModels/LiveViewModel.swift +++ b/tvbox/ViewModels/LiveViewModel.swift @@ -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 } diff --git a/tvbox/ViewModels/SearchViewModel.swift b/tvbox/ViewModels/SearchViewModel.swift index 43e420a..c563cb4 100644 --- a/tvbox/ViewModels/SearchViewModel.swift +++ b/tvbox/ViewModels/SearchViewModel.swift @@ -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) diff --git a/tvbox/ViewModels/SettingsViewModel.swift b/tvbox/ViewModels/SettingsViewModel.swift index b89588e..191eb7a 100644 --- a/tvbox/ViewModels/SettingsViewModel.swift +++ b/tvbox/ViewModels/SettingsViewModel.swift @@ -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 { diff --git a/tvbox/Views/Common/EmptyStateView.swift b/tvbox/Views/Common/EmptyStateView.swift index cfae375..05393b4 100644 --- a/tvbox/Views/Common/EmptyStateView.swift +++ b/tvbox/Views/Common/EmptyStateView.swift @@ -1,8 +1,13 @@ import SwiftUI +/// 通用空状态组件。 +/// 在收藏、历史等页面复用,统一空页面视觉风格。 struct EmptyStateView: View { + /// SF Symbol 图标名。 let icon: String + /// 主标题。 let title: String + /// 可选说明文字,为空时不渲染副文案区域。 var message: String? = nil var body: some View { @@ -23,11 +28,13 @@ struct EmptyStateView: View { } .padding(.bottom, 8) + // 标题强调当前页面状态,例如“暂无收藏”“暂无播放记录”。 Text(title) .font(.title3.bold()) .foregroundColor(.white.opacity(0.9)) .tracking(1) + // 副文案用于提供下一步引导,不参与核心逻辑判断。 if let message = message { Text(message) .font(.callout) diff --git a/tvbox/Views/ContentView.swift b/tvbox/Views/ContentView.swift index 315d7ee..1e1f9e4 100644 --- a/tvbox/Views/ContentView.swift +++ b/tvbox/Views/ContentView.swift @@ -2,15 +2,21 @@ import SwiftUI /// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航 struct ContentView: View { + /// 首次配置页点击“最近使用”时,当前要写入的输入框目标。 private enum ApiInputTarget { case vod case live } + /// 全局状态(配置加载、分栏状态等)。 @EnvironmentObject var appState: AppState + /// 设置页 ViewModel。根视图复用它处理首次配置与多仓库选择。 @StateObject private var settingsVM = SettingsViewModel() + /// 当前主标签索引。 @State private var selectedTab = 0 + /// 预留:控制首次配置页显隐(当前逻辑由 `appState.isConfigLoaded` 驱动)。 @State private var showSetup = false + /// 首次配置页历史回填目标输入框。 @State private var setupInputTarget: ApiInputTarget = .vod var body: some View { @@ -29,6 +35,7 @@ struct ContentView: View { let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? "" let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? "" if !savedVodUrl.isEmpty { + // 启动自动恢复配置,避免每次重启都回到首次配置页。 Task { await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl) } @@ -38,6 +45,7 @@ struct ContentView: View { @ViewBuilder private var multiRepoSelectionOverlay: some View { + // 若配置地址解析出“多仓库入口”,在根层统一弹窗,避免被子页面导航遮挡。 if let pending = settingsVM.pendingMultiRepoSelection { SelectionModal( title: "选择\(pending.target.title)仓库", @@ -62,6 +70,7 @@ struct ContentView: View { // MARK: - 主界面 + /// 主体导航容器:iOS 使用 TabView,macOS 使用 NavigationSplitView。 private var mainTabView: some View { #if os(iOS) TabView(selection: $selectedTab) { @@ -130,6 +139,7 @@ struct ContentView: View { // MARK: - 首次配置页面 + /// 首次启动或未加载配置时的引导页面。 private var setupView: some View { ZStack { // 背景装饰 @@ -346,6 +356,7 @@ struct ContentView: View { #if os(iOS) UIPasteboard.general.string #else + // macOS 下通过 NSPasteboard 读取纯文本。 NSPasteboard.general.string(forType: .string) #endif } diff --git a/tvbox/Views/DesignSystem.swift b/tvbox/Views/DesignSystem.swift index a8e9a41..ac129e9 100644 --- a/tvbox/Views/DesignSystem.swift +++ b/tvbox/Views/DesignSystem.swift @@ -1 +1,2 @@ -// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets. +// 该文件已迁移至 `tvbox/Utils/Extensions.swift`。 +// 保留此占位文件仅为兼容历史引用,避免旧路径导入时报错。 diff --git a/tvbox/Views/Detail/EpisodeListView.swift b/tvbox/Views/Detail/EpisodeListView.swift index d1c33ff..4093611 100644 --- a/tvbox/Views/Detail/EpisodeListView.swift +++ b/tvbox/Views/Detail/EpisodeListView.swift @@ -2,17 +2,24 @@ import SwiftUI /// 剧集列表组件 - 对应 Android 版 SeriesAdapter struct EpisodeListView: View { + /// 当前线路下的全部剧集。 let episodes: [VodInfo.Episode] + /// 外部传入的当前选中集索引(绝对索引)。 let selectedIndex: Int + /// 点击某一集后的回调(返回绝对索引)。 let onSelect: (Int) -> Void + /// 当前分组索引(每 50 集一个分组,避免超长列表影响渲染与选择体验)。 @State private var currentGroup = 0 + /// 每个分组展示的剧集数量。 private let groupSize = 50 + /// 分组总数,至少为 1,避免空数组时出现 0 组的边界问题。 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) @@ -62,6 +69,7 @@ struct EpisodeListView: View { GridItem(.fixed(44)), GridItem(.fixed(44)) ], spacing: 10) { + // 这里使用当前组内索引 + 组偏移,换算成全局索引以便外部状态一致。 ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in let actualIndex = currentGroup * groupSize + index Button { diff --git a/tvbox/Views/Favorites/FavoritesView.swift b/tvbox/Views/Favorites/FavoritesView.swift index 2ad0115..a641e95 100644 --- a/tvbox/Views/Favorites/FavoritesView.swift +++ b/tvbox/Views/Favorites/FavoritesView.swift @@ -3,15 +3,19 @@ import SwiftData /// 收藏页 - 对应 Android 版 CollectActivity struct FavoritesView: View { + /// 按更新时间倒序展示收藏,最近收藏/更新的内容靠前。 @Query(sort: \VodCollect.updateTime, order: .reverse) private var favorites: [VodCollect] + /// SwiftData 上下文,用于删除收藏并持久化。 @Environment(\.modelContext) private var modelContext #if os(iOS) + /// iOS 下卡片尺寸更紧凑,适配手机竖屏。 private let columns = [ GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) ] #else + /// macOS 下卡片适度放大,提升桌面端可读性。 private let columns = [ GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) ] @@ -25,6 +29,7 @@ struct FavoritesView: View { } else { ScrollView { LazyVGrid(columns: columns, spacing: 16) { + // 每个收藏项都可直接跳转详情,并支持右键取消收藏。 ForEach(favorites) { item in NavigationLink(value: movieVideo(from: item)) { favoriteCard(item) @@ -50,12 +55,14 @@ struct FavoritesView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + // 通过 Movie.Video 作为路由载体,保持与首页/搜索页一致的详情入口。 .navigationDestination(for: Movie.Video.self) { video in DetailView(video: video) } } } + /// 空列表占位。 private var emptyState: some View { EmptyStateView( icon: "heart.text.square", @@ -65,6 +72,8 @@ struct FavoritesView: View { .padding(40) } + /// 收藏卡片。 + /// 只展示海报与标题,保持网格信息密度一致。 private func favoriteCard(_ item: VodCollect) -> some View { VStack(alignment: .leading, spacing: 6) { CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in @@ -83,6 +92,7 @@ struct FavoritesView: View { } } + /// 将收藏记录映射成详情页可识别的视频对象。 private func movieVideo(from item: VodCollect) -> Movie.Video { Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) } diff --git a/tvbox/Views/History/HistoryView.swift b/tvbox/Views/History/HistoryView.swift index 0718c29..5bc19ae 100644 --- a/tvbox/Views/History/HistoryView.swift +++ b/tvbox/Views/History/HistoryView.swift @@ -3,15 +3,19 @@ import SwiftData /// 历史记录页 - 对应 Android 版 HistoryActivity struct HistoryView: View { + /// 按最近播放时间倒序展示历史记录。 @Query(sort: \VodRecord.updateTime, order: .reverse) private var records: [VodRecord] + /// SwiftData 上下文,用于删除单条记录或清空历史。 @Environment(\.modelContext) private var modelContext #if os(iOS) + /// iOS 网格配置。 private let columns = [ GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) ] #else + /// macOS 网格配置。 private let columns = [ GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) ] @@ -25,6 +29,7 @@ struct HistoryView: View { } else { ScrollView { LazyVGrid(columns: columns, spacing: 16) { + // 记录卡片支持跳转详情与右键删除。 ForEach(records) { item in NavigationLink(value: movieVideo(from: item)) { recordCard(item) @@ -53,6 +58,7 @@ struct HistoryView: View { .toolbar { if !records.isEmpty { ToolbarItem(placement: .automatic) { + // 清空历史使用统一缓存服务,确保行为与其他入口一致。 Button { Task { CacheStore.shared.clearHistory(context: modelContext) @@ -64,12 +70,14 @@ struct HistoryView: View { } } } + // 与首页/搜索/收藏共用同一种详情路由模型。 .navigationDestination(for: Movie.Video.self) { video in DetailView(video: video) } } } + /// 无历史时的占位视图。 private var emptyState: some View { EmptyStateView( icon: "clock.arrow.circlepath", @@ -79,6 +87,8 @@ struct HistoryView: View { .padding(40) } + /// 历史卡片。 + /// 除海报和标题外,额外显示播放进度与更新时间,便于快速续播。 private func recordCard(_ item: VodRecord) -> some View { VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .bottomLeading) { @@ -115,6 +125,7 @@ struct HistoryView: View { } } + /// 将历史记录转换为详情页的入参模型。 private func movieVideo(from item: VodRecord) -> Movie.Video { Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) } diff --git a/tvbox/Views/Home/VodCardView.swift b/tvbox/Views/Home/VodCardView.swift index 1a0d3fc..390f113 100644 --- a/tvbox/Views/Home/VodCardView.swift +++ b/tvbox/Views/Home/VodCardView.swift @@ -2,7 +2,9 @@ import SwiftUI /// 视频卡片组件 struct VodCardView: View { + /// 卡片对应的视频数据。 let video: Movie.Video + /// 悬停状态(主要用于 macOS 悬停放大动效)。 @State private var isHovered = false var body: some View { @@ -44,6 +46,7 @@ struct VodCardView: View { .padding(8) } } + // 悬停缩放只增强视觉反馈,不影响点击命中区域。 .scaleEffect(isHovered ? 1.05 : 1.0) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered) .onHover { hovering in @@ -67,6 +70,7 @@ struct VodCardView: View { } } + /// 海报占位图,避免图片加载失败导致卡片高度塌陷。 private var placeholderImage: some View { RoundedRectangle(cornerRadius: AppTheme.cardRadius) .fill(Color.white.opacity(0.05)) diff --git a/tvbox/Views/Live/LiveView.swift b/tvbox/Views/Live/LiveView.swift index 4fdd69f..bbaa3f3 100644 --- a/tvbox/Views/Live/LiveView.swift +++ b/tvbox/Views/Live/LiveView.swift @@ -6,24 +6,43 @@ import AppKit /// 直播页 - 对应 Android 版 LivePlayActivity struct LiveView: View { + /// 直播频道与选中状态管理。 @StateObject private var viewModel = LiveViewModel() + /// 全局应用状态(用于 macOS 全屏时调整分栏布局)。 @EnvironmentObject var appState: AppState + /// 系统播放器实例(仅在选择系统内核时使用)。 @State private var avPlayer: AVPlayer? + /// 直播播放器内核配置(新字段)。 @AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1 + /// 兼容旧版本单播放器字段。 @AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue + /// 是否展示左侧频道抽屉。 @State private var showChannelDrawer = true + /// 当前窗口是否处于全屏。 @State private var isWindowFullScreen = false + /// 底部频道信息卡最大宽度。 private let currentChannelInfoMaxWidth: CGFloat = 600 + /// AVPlayer 状态观察者。 @State private var itemStatusObserver: NSKeyValueObservation? + /// 播放失败通知观察者。 @State private var playbackFailedObserver: NSObjectProtocol? + /// 播放卡顿通知观察者。 @State private var playbackStalledObserver: NSObjectProtocol? + /// 当前频道已失败的线路索引,用于自动切线去重。 @State private var failedSourceIndices: Set = [] + /// 当前跟踪的频道 ID(频道切换时重置失败状态)。 @State private var trackedChannelId: String = "" + /// 底部频道信息是否显示。 @State private var showCurrentChannelInfo = true + /// 自动隐藏频道信息的定时器。 @State private var channelInfoTimer: Timer? + /// 频道信息自动隐藏延迟(秒)。 private let channelInfoAutoHideDelay: TimeInterval = 3.0 + /// 用户交互令牌,递增后可通知 VLC 子视图重置自动隐藏逻辑。 @State private var vlcInteractionToken = 0 + /// 当前实际生效的播放器内核。 + /// 优先读取直播专用字段,再回退老字段,最后使用默认值。 private var selectedEngine: PlayerEngine { let defaults = UserDefaults.standard let rawValue: Int @@ -80,6 +99,7 @@ struct LiveView: View { .navigationBarTitleDisplayMode(.inline) #endif .onAppear { + // 首次进入时加载频道并展示频道信息卡。 viewModel.loadChannels() wakeUpCurrentChannelInfo() } @@ -92,6 +112,7 @@ struct LiveView: View { wakeUpCurrentChannelInfo() } .onChange(of: viewModel.currentChannel?.id) { _, _ in + // 切台后清空失败线路记录,避免复用上个频道的失败状态。 resetFailureTracking(for: viewModel.currentChannel) wakeUpCurrentChannelInfo() } @@ -364,6 +385,7 @@ struct LiveView: View { } private func reportUserActivity() { + // 任意交互都刷新显示时间,并触发 VLC 子层同步交互状态。 wakeUpCurrentChannelInfo() vlcInteractionToken &+= 1 } @@ -459,6 +481,7 @@ struct LiveView: View { } private func cleanupPlayer() { + // 先移除观察者再释放播放器,避免悬空回调。 if let observer = playbackFailedObserver { NotificationCenter.default.removeObserver(observer) playbackFailedObserver = nil @@ -475,6 +498,7 @@ struct LiveView: View { } private func observePlaybackFailure(for item: AVPlayerItem) { + // KVO 监听 item 状态失败。 itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in if observedItem.status == .failed { DispatchQueue.main.async { @@ -483,6 +507,7 @@ struct LiveView: View { } } + // 播放到结尾失败回调。 playbackFailedObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemFailedToPlayToEndTime, object: item, @@ -491,6 +516,7 @@ struct LiveView: View { handlePlaybackFailure(trigger: "item_failed") } + // 播放卡顿回调。 playbackStalledObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemPlaybackStalled, object: item, @@ -526,6 +552,7 @@ struct LiveView: View { private func switchToNextAvailableSource(totalSources: Int) -> Bool { guard failedSourceIndices.count < totalSources else { return false } + // 最多轮询 `totalSources` 次,找到一条尚未失败的线路即返回。 for _ in 0.. CGSize { let result = arrangement(proposal: proposal, subviews: subviews) return result.size } + /// 按计算结果放置子视图。 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews) for (index, position) in result.positions.enumerated() { @@ -197,6 +207,7 @@ struct FlowLayout: Layout { } } + /// 核心排版算法:按最大宽度逐个放置,超宽后自动换行。 private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) { let maxWidth = proposal.width ?? .infinity var positions: [CGPoint] = [] diff --git a/tvbox/tvboxApp.swift b/tvbox/tvboxApp.swift index 1aa1845..94be97b 100644 --- a/tvbox/tvboxApp.swift +++ b/tvbox/tvboxApp.swift @@ -1,10 +1,15 @@ import SwiftUI import SwiftData +/// 应用入口。 +/// 负责初始化 SwiftData 容器,并将全局状态 `AppState` 注入到根视图。 @main struct tvboxApp: App { + /// 全局运行时状态(配置加载状态、当前源、分栏布局状态等)。 @StateObject private var appState = AppState() + /// 全局共享的 SwiftData 容器。 + /// 这里显式声明 Schema,确保收藏/历史/缓存三类数据使用同一持久化存储。 var sharedModelContainer: ModelContainer = { let schema = Schema([ VodCollect.self, @@ -19,6 +24,7 @@ struct tvboxApp: App { } }() + /// 应用窗口与根视图。 var body: some Scene { WindowGroup { ContentView() @@ -31,20 +37,32 @@ struct tvboxApp: App { } } +/// 应用级状态容器。 +/// 统一管理配置加载与页面共享状态,避免在各页面重复拉取配置。 @MainActor class AppState: ObservableObject { + /// 解析后的配置单例,提供给所有页面与 ViewModel 使用。 @Published var apiConfig = ApiConfig.shared + /// 配置是否已经成功加载。控制 `ContentView` 显示主界面或首次配置页。 @Published var isConfigLoaded = false + /// 当前首页选中的视频源 key(用于跨页面同步)。 @Published var currentSourceKey: String = "" #if os(macOS) + /// macOS 三栏布局可见性(侧栏/内容/详情)。 @Published var splitViewVisibility: NavigationSplitViewVisibility = .all + /// 进入播放器全屏前的分栏状态快照,用于退出全屏后恢复。 private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility? #endif + /// 仅提供点播地址时的快捷加载入口(直播地址默认与点播一致)。 func loadConfig(url: String) async { await loadConfig(vodUrl: url, liveUrl: nil) } + /// 加载点播与直播配置。 + /// - Parameters: + /// - vodUrl: 点播配置地址 + /// - liveUrl: 直播配置地址;为空时自动回退到点播地址 func loadConfig(vodUrl: String, liveUrl: String?) async { let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -59,12 +77,15 @@ class AppState: ObservableObject { } } + /// 将“配置已加载”的统一状态写回全局。 + /// 该方法会在设置页和启动自动加载两个入口中复用。 func applyLoadedConfigState() { isConfigLoaded = true currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" } #if os(macOS) + /// 进入播放器全屏时隐藏侧栏,减少播放器可视区域干扰。 func enterPlayerFullScreen() { if splitViewVisibilityBeforePlayerFullScreen == nil { splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility @@ -72,6 +93,7 @@ class AppState: ObservableObject { splitViewVisibility = .detailOnly } + /// 退出播放器全屏时恢复之前的分栏状态。 func exitPlayerFullScreen() { guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return } splitViewVisibility = previous