Compare commits
4
Commits
Release1.1
...
260302
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc1a010e05 | ||
|
|
e58322339f | ||
|
|
ee8c71dcd4 | ||
|
|
99a8cc799b |
@@ -214,3 +214,31 @@ struct AppConfigData: Codable {
|
|||||||
var script: [String]?
|
var script: [String]?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AppConfigData {
|
||||||
|
/// 是否包含可直接加载的核心内容
|
||||||
|
/// 说明:AppConfigData 字段几乎全是可选,任意 JSON 都可能“解码成功”,
|
||||||
|
/// 因此需要额外判定,避免把多仓库索引误当成正常配置。
|
||||||
|
var hasUsableContent: Bool {
|
||||||
|
let hasSites = !(sites?.isEmpty ?? true)
|
||||||
|
let hasLives = !(lives?.isEmpty ?? true)
|
||||||
|
let hasParses = !(parses?.isEmpty ?? true)
|
||||||
|
return hasSites || hasLives || hasParses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 多仓库合并索引配置(如 tvboxmulti.json)
|
||||||
|
struct MultiRepoConfigData: Codable {
|
||||||
|
var urls: [Entry]?
|
||||||
|
|
||||||
|
struct Entry: Codable {
|
||||||
|
var name: String?
|
||||||
|
var url: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateUrls: [String] {
|
||||||
|
(urls ?? [])
|
||||||
|
.compactMap { $0.url?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import Foundation
|
|||||||
|
|
||||||
/// 直播频道分组
|
/// 直播频道分组
|
||||||
struct LiveChannelGroup: Codable, Identifiable, Hashable {
|
struct LiveChannelGroup: Codable, Identifiable, Hashable {
|
||||||
|
/// 以分组名作为稳定标识,便于 SwiftUI 列表 diff。
|
||||||
var id: String { groupName }
|
var id: String { groupName }
|
||||||
|
/// 分组名称(如“央视”“卫视”)。
|
||||||
var groupName: String = ""
|
var groupName: String = ""
|
||||||
|
/// 分组在列表中的顺序索引。
|
||||||
var groupIndex: Int = 0
|
var groupIndex: Int = 0
|
||||||
|
/// 分组下频道列表。
|
||||||
var channels: [LiveChannelItem] = []
|
var channels: [LiveChannelItem] = []
|
||||||
|
/// 是否为加密分组(当前实现仅保留字段,未启用密码校验)。
|
||||||
var isPassword: Bool = false
|
var isPassword: Bool = false
|
||||||
|
|
||||||
init(groupName: String = "", groupIndex: Int = 0) {
|
init(groupName: String = "", groupIndex: Int = 0) {
|
||||||
@@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
/// 直播频道
|
/// 直播频道
|
||||||
struct LiveChannelItem: Codable, Identifiable, Hashable {
|
struct LiveChannelItem: Codable, Identifiable, Hashable {
|
||||||
|
/// 频道标识由名称+索引组成,规避同名频道冲突。
|
||||||
var id: String { "\(channelName)_\(channelIndex)" }
|
var id: String { "\(channelName)_\(channelIndex)" }
|
||||||
|
/// 频道名。
|
||||||
var channelName: String = ""
|
var channelName: String = ""
|
||||||
|
/// 频道在分组内的顺序索引。
|
||||||
var channelIndex: Int = 0
|
var channelIndex: Int = 0
|
||||||
|
/// 多线路播放地址。
|
||||||
var channelUrls: [String] = []
|
var channelUrls: [String] = []
|
||||||
|
/// 当前选中的线路索引。
|
||||||
var sourceIndex: Int = 0
|
var sourceIndex: Int = 0
|
||||||
|
/// 可用线路总数。
|
||||||
var sourceNum: Int { channelUrls.count }
|
var sourceNum: Int { channelUrls.count }
|
||||||
|
/// 台标地址(预留)。
|
||||||
var logo: String = ""
|
var logo: String = ""
|
||||||
|
|
||||||
init(channelName: String = "", channelIndex: Int = 0) {
|
init(channelName: String = "", channelIndex: Int = 0) {
|
||||||
@@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable {
|
|||||||
self.channelIndex = channelIndex
|
self.channelIndex = channelIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前线路对应的播放地址。
|
||||||
|
/// 当索引越界时兜底返回第一条线路,避免直接播放失败。
|
||||||
var currentUrl: String? {
|
var currentUrl: String? {
|
||||||
guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first }
|
guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first }
|
||||||
return channelUrls[sourceIndex]
|
return channelUrls[sourceIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 轮换到下一条线路。
|
||||||
mutating func nextSource() {
|
mutating func nextSource() {
|
||||||
if channelUrls.count > 0 {
|
if channelUrls.count > 0 {
|
||||||
sourceIndex = (sourceIndex + 1) % channelUrls.count
|
sourceIndex = (sourceIndex + 1) % channelUrls.count
|
||||||
@@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable {
|
|||||||
var endTime: String = ""
|
var endTime: String = ""
|
||||||
var index: Int = 0
|
var index: Int = 0
|
||||||
|
|
||||||
|
/// 根据 `HH:mm` 时间段判断节目是否正在播出。
|
||||||
var isLive: Bool {
|
var isLive: Bool {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "HH:mm"
|
formatter.dateFormat = "HH:mm"
|
||||||
@@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable {
|
|||||||
/// EPG 日期分组
|
/// EPG 日期分组
|
||||||
struct LiveEpgDate: Codable, Identifiable, Hashable {
|
struct LiveEpgDate: Codable, Identifiable, Hashable {
|
||||||
var id: String { datePresent }
|
var id: String { datePresent }
|
||||||
var datePresent: String = "" // 显示日期
|
/// 供 UI 展示的日期文案。
|
||||||
var date: String = "" // 查询日期
|
var datePresent: String = ""
|
||||||
|
/// 供接口查询的原始日期值。
|
||||||
|
var date: String = ""
|
||||||
|
/// 在日期列表中的位置索引。
|
||||||
var index: Int = 0
|
var index: Int = 0
|
||||||
|
/// 是否被当前 UI 选中。
|
||||||
var isSelected: Bool = false
|
var isSelected: Bool = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,47 @@ import Foundation
|
|||||||
|
|
||||||
/// 电影/视频数据模型 - 对应 Android 版 Movie.java
|
/// 电影/视频数据模型 - 对应 Android 版 Movie.java
|
||||||
struct Movie: Codable {
|
struct Movie: Codable {
|
||||||
|
/// 列表数据主体。
|
||||||
var videoList: [Video] = []
|
var videoList: [Video] = []
|
||||||
|
/// 总页数。
|
||||||
var pagecount: Int = 0
|
var pagecount: Int = 0
|
||||||
|
/// 当前页码。
|
||||||
var page: Int = 0
|
var page: Int = 0
|
||||||
|
/// 总条数。
|
||||||
var total: Int = 0
|
var total: Int = 0
|
||||||
|
/// 每页条数。
|
||||||
var limit: Int = 0
|
var limit: Int = 0
|
||||||
|
|
||||||
/// 单个视频条目
|
/// 单个视频条目
|
||||||
struct Video: Codable, Identifiable, Hashable {
|
struct Video: Codable, Identifiable, Hashable {
|
||||||
|
/// 视频唯一 ID(接口可能返回 Int 或 String,见自定义解码)。
|
||||||
var id: String
|
var id: String
|
||||||
|
/// 片名。
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
|
/// 海报地址。
|
||||||
var pic: String = ""
|
var pic: String = ""
|
||||||
var note: String = "" // 备注(如"更新至第20集")
|
/// 备注(如“更新至第20集”)。
|
||||||
|
var note: String = ""
|
||||||
|
/// 年份。
|
||||||
var year: String = ""
|
var year: String = ""
|
||||||
|
/// 地区。
|
||||||
var area: String = ""
|
var area: String = ""
|
||||||
var type: String = "" // 类型/分类名
|
/// 类型/分类名。
|
||||||
|
var type: String = ""
|
||||||
|
/// 导演。
|
||||||
var director: String = ""
|
var director: String = ""
|
||||||
|
/// 演员。
|
||||||
var actor: String = ""
|
var actor: String = ""
|
||||||
var des: String = "" // 简介
|
/// 简介。
|
||||||
var sourceKey: String = "" // 来源站点 key
|
var des: String = ""
|
||||||
var tid: String = "" // 分类 ID
|
/// 来源站点 key,用于跨源隔离收藏与历史。
|
||||||
var last: String = "" // 最后更新
|
var sourceKey: String = ""
|
||||||
var dt: String = "" // 日期
|
/// 分类 ID。
|
||||||
|
var tid: String = ""
|
||||||
|
/// 最后更新时间。
|
||||||
|
var last: String = ""
|
||||||
|
/// 播放来源信息(部分接口会复用该字段)。
|
||||||
|
var dt: String = ""
|
||||||
|
|
||||||
init(id: String = UUID().uuidString, name: String = "", pic: String = "",
|
init(id: String = UUID().uuidString, name: String = "", pic: String = "",
|
||||||
note: String = "", sourceKey: String = "") {
|
note: String = "", sourceKey: String = "") {
|
||||||
@@ -51,6 +70,7 @@ struct Movie: Codable {
|
|||||||
case sourceKey
|
case sourceKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 自定义解码以兼容多源字段类型差异(如 `vod_id` / `type_id` 可能是 Int 或 String)。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
// 支持 String 或 Int 类型的 id
|
// 支持 String 或 Int 类型的 id
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import Foundation
|
|||||||
|
|
||||||
/// 分类排序模型 - 对应 Android 版 MovieSort.java
|
/// 分类排序模型 - 对应 Android 版 MovieSort.java
|
||||||
struct MovieSort: Codable {
|
struct MovieSort: Codable {
|
||||||
|
/// 分类列表(包含首页推荐、影视分类等)。
|
||||||
var sortList: [SortData] = []
|
var sortList: [SortData] = []
|
||||||
|
|
||||||
/// 单个分类数据
|
/// 单个分类数据
|
||||||
struct SortData: Codable, Identifiable, Hashable {
|
struct SortData: Codable, Identifiable, Hashable {
|
||||||
|
/// 分类唯一标识(接口字段通常为 type_id)。
|
||||||
var id: String
|
var id: String
|
||||||
|
/// 分类显示名。
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
|
/// 标记位(不同源可定义不同语义,常用于首页/推荐标识)。
|
||||||
var flag: String = ""
|
var flag: String = ""
|
||||||
|
/// 分类下可选筛选项(年份、地区、类型等)。
|
||||||
var filters: [SortFilter] = []
|
var filters: [SortFilter] = []
|
||||||
|
|
||||||
init(id: String = "", name: String = "", flag: String = "") {
|
init(id: String = "", name: String = "", flag: String = "") {
|
||||||
@@ -17,6 +22,8 @@ struct MovieSort: Codable {
|
|||||||
self.flag = flag
|
self.flag = flag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成首页推荐占位分类。
|
||||||
|
/// 该分类不走常规分类接口,直接渲染首页推荐列表。
|
||||||
static func home() -> SortData {
|
static func home() -> SortData {
|
||||||
SortData(id: "home", name: "推荐", flag: "1")
|
SortData(id: "home", name: "推荐", flag: "1")
|
||||||
}
|
}
|
||||||
@@ -24,13 +31,18 @@ struct MovieSort: Codable {
|
|||||||
|
|
||||||
/// 筛选条件
|
/// 筛选条件
|
||||||
struct SortFilter: Codable, Hashable {
|
struct SortFilter: Codable, Hashable {
|
||||||
|
/// 接口参数键,例如 `year`、`area`。
|
||||||
var key: String = ""
|
var key: String = ""
|
||||||
|
/// UI 展示名称。
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
|
/// 可选值集合。
|
||||||
var values: [SortFilterValue] = []
|
var values: [SortFilterValue] = []
|
||||||
|
|
||||||
struct SortFilterValue: Codable, Hashable {
|
struct SortFilterValue: Codable, Hashable {
|
||||||
var n: String = "" // 显示名
|
/// 展示名。
|
||||||
var v: String = "" // 值
|
var n: String = ""
|
||||||
|
/// 真实参数值。
|
||||||
|
var v: String = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
/// 播放器引擎类型
|
/// 播放器引擎类型
|
||||||
enum PlayerEngine: Int, CaseIterable, Identifiable {
|
enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||||
|
/// 系统 AVPlayer 内核。
|
||||||
case system = 0
|
case system = 0
|
||||||
|
/// VLC 内核(需编译时可导入 VLCKitSPM)。
|
||||||
case vlc = 10
|
case vlc = 10
|
||||||
|
|
||||||
var id: Int { rawValue }
|
var id: Int { rawValue }
|
||||||
|
|
||||||
|
/// UI 展示名。
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .system:
|
case .system:
|
||||||
@@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前构建产物是否包含 VLC 能力。
|
||||||
static var isVLCAvailable: Bool {
|
static var isVLCAvailable: Bool {
|
||||||
#if canImport(VLCKitSPM)
|
#if canImport(VLCKitSPM)
|
||||||
return true
|
return true
|
||||||
@@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 实际可供用户选择的播放器列表。
|
||||||
|
/// 当 VLC 不可用时,仅暴露系统播放器,避免无效配置。
|
||||||
static var availableEngines: [PlayerEngine] {
|
static var availableEngines: [PlayerEngine] {
|
||||||
var engines: [PlayerEngine] = [.system]
|
var engines: [PlayerEngine] = [.system]
|
||||||
if isVLCAvailable {
|
if isVLCAvailable {
|
||||||
@@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
|||||||
return engines
|
return engines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从持久化值恢复播放器选项,并自动兜底到可用引擎。
|
||||||
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
|
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
|
||||||
guard let engine = PlayerEngine(rawValue: rawValue) else {
|
guard let engine = PlayerEngine(rawValue: rawValue) else {
|
||||||
return .system
|
return .system
|
||||||
@@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
/// 视频解码模式
|
/// 视频解码模式
|
||||||
enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||||
|
/// 自动策略,优先硬解,失败时用户可切换。
|
||||||
case auto = 0
|
case auto = 0
|
||||||
|
/// 强制硬解。
|
||||||
case hardware = 1
|
case hardware = 1
|
||||||
|
/// 强制软解。
|
||||||
case software = 2
|
case software = 2
|
||||||
|
|
||||||
var id: Int { rawValue }
|
var id: Int { rawValue }
|
||||||
@@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// VLC 媒体选项
|
/// VLC 媒体选项
|
||||||
|
/// 返回 `avcodec-hw` 对应的值。
|
||||||
var vlcHardwareDecodeOption: String? {
|
var vlcHardwareDecodeOption: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .auto:
|
case .auto:
|
||||||
@@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
/// VLC 缓冲策略
|
/// VLC 缓冲策略
|
||||||
enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
||||||
|
/// 低延迟优先,适合直播但容错较低。
|
||||||
case lowLatency = 0
|
case lowLatency = 0
|
||||||
|
/// 兼顾延迟与稳定性,作为默认策略。
|
||||||
case balanced = 1
|
case balanced = 1
|
||||||
|
/// 稳定流畅优先,允许更高缓冲。
|
||||||
case smooth = 2
|
case smooth = 2
|
||||||
|
|
||||||
var id: Int { rawValue }
|
var id: Int { rawValue }
|
||||||
@@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
|||||||
self == .lowLatency
|
self == .lowLatency
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据直播/点播场景输出三类缓存值(单位毫秒)。
|
||||||
|
/// - Parameters:
|
||||||
|
/// - isLive: 是否直播场景
|
||||||
|
/// - Returns: network/live/file 三类缓存配置
|
||||||
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
|
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
|
||||||
switch self {
|
switch self {
|
||||||
case .lowLatency:
|
case .lowLatency:
|
||||||
|
|||||||
@@ -2,25 +2,37 @@ import Foundation
|
|||||||
|
|
||||||
/// 视频源站点配置 - 对应 Android 版 SourceBean.java
|
/// 视频源站点配置 - 对应 Android 版 SourceBean.java
|
||||||
struct SourceBean: Codable, Identifiable, Hashable {
|
struct SourceBean: Codable, Identifiable, Hashable {
|
||||||
|
/// 以源 key 作为稳定标识。
|
||||||
var id: String { key }
|
var id: String { key }
|
||||||
|
|
||||||
|
/// 源唯一键。
|
||||||
let key: String
|
let key: String
|
||||||
|
/// 源显示名。
|
||||||
let name: String
|
let name: String
|
||||||
|
/// 源接口地址。
|
||||||
let api: String
|
let api: String
|
||||||
let searchable: Int // 0:关闭搜索 1:启用搜索
|
/// 搜索开关:0 关闭,1 开启。
|
||||||
let filterable: Int // 0:首页不可选 1:首页可选
|
let searchable: Int
|
||||||
let playerType: Int // 0:系统 1:IJK 2:EXO
|
/// 是否允许出现在首页分类:0 不可选,1 可选。
|
||||||
let type: Int // 0:xml 1:json 3:jar 4:remote
|
let filterable: Int
|
||||||
|
/// 快速搜索开关:0 关闭,1 开启(主要用于 remote 源 quick 参数)。
|
||||||
|
let quickSearch: Int
|
||||||
|
/// 源声明的播放器类型(历史字段,Swift 端目前主要走统一播放器策略)。
|
||||||
|
let playerType: Int
|
||||||
|
/// 源协议类型:0 XML,1 JSON,3 JAR,4 Remote。
|
||||||
|
let type: Int
|
||||||
|
/// 扩展参数(remote 源常用)。
|
||||||
let ext: String?
|
let ext: String?
|
||||||
|
|
||||||
init(key: String = "", name: String = "", api: String = "",
|
init(key: String = "", name: String = "", api: String = "",
|
||||||
searchable: Int = 1, filterable: Int = 1,
|
searchable: Int = 1, filterable: Int = 1, quickSearch: Int = 0,
|
||||||
playerType: Int = 0, type: Int = 1, ext: String? = nil) {
|
playerType: Int = 0, type: Int = 1, ext: String? = nil) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.name = name
|
self.name = name
|
||||||
self.api = api
|
self.api = api
|
||||||
self.searchable = searchable
|
self.searchable = searchable
|
||||||
self.filterable = filterable
|
self.filterable = filterable
|
||||||
|
self.quickSearch = quickSearch
|
||||||
self.playerType = playerType
|
self.playerType = playerType
|
||||||
self.type = type
|
self.type = type
|
||||||
self.ext = ext
|
self.ext = ext
|
||||||
@@ -28,6 +40,7 @@ struct SourceBean: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
var isSearchable: Bool { searchable == 1 }
|
var isSearchable: Bool { searchable == 1 }
|
||||||
var isFilterable: Bool { filterable == 1 }
|
var isFilterable: Bool { filterable == 1 }
|
||||||
|
var isQuickSearchEnabled: Bool { quickSearch == 1 }
|
||||||
|
|
||||||
/// 是否在 Swift 版中受支持(type=3 为 JAR/Spider,需要 Java 运行时,暂不支持)
|
/// 是否在 Swift 版中受支持(type=3 为 JAR/Spider,需要 Java 运行时,暂不支持)
|
||||||
var isSupportedInSwift: Bool {
|
var isSupportedInSwift: Bool {
|
||||||
|
|||||||
@@ -2,16 +2,27 @@ import Foundation
|
|||||||
|
|
||||||
/// 视频详情模型 - 对应 Android 版 VodInfo.java
|
/// 视频详情模型 - 对应 Android 版 VodInfo.java
|
||||||
struct VodInfo: Codable, Identifiable {
|
struct VodInfo: Codable, Identifiable {
|
||||||
|
/// 视频唯一 ID。
|
||||||
var id: String
|
var id: String
|
||||||
|
/// 标题。
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
|
/// 海报地址。
|
||||||
var pic: String = ""
|
var pic: String = ""
|
||||||
|
/// 备注(更新状态等)。
|
||||||
var note: String = ""
|
var note: String = ""
|
||||||
|
/// 年份。
|
||||||
var year: String = ""
|
var year: String = ""
|
||||||
|
/// 地区。
|
||||||
var area: String = ""
|
var area: String = ""
|
||||||
|
/// 类型名。
|
||||||
var typeName: String = ""
|
var typeName: String = ""
|
||||||
|
/// 导演。
|
||||||
var director: String = ""
|
var director: String = ""
|
||||||
|
/// 演员。
|
||||||
var actor: String = ""
|
var actor: String = ""
|
||||||
|
/// 简介。
|
||||||
var des: String = ""
|
var des: String = ""
|
||||||
|
/// 来源站点 key。
|
||||||
var sourceKey: String = ""
|
var sourceKey: String = ""
|
||||||
|
|
||||||
/// 播放来源(线路)列表
|
/// 播放来源(线路)列表
|
||||||
@@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable {
|
|||||||
/// key: flag名称, value: 剧集列表
|
/// key: flag名称, value: 剧集列表
|
||||||
var playUrlMap: [String: [Episode]] = [:]
|
var playUrlMap: [String: [Episode]] = [:]
|
||||||
|
|
||||||
var playFlag: String = "" // 当前选中线路
|
/// 当前选中线路。
|
||||||
var playIndex: Int = 0 // 当前播放剧集索引
|
var playFlag: String = ""
|
||||||
|
/// 当前播放剧集索引。
|
||||||
|
var playIndex: Int = 0
|
||||||
|
|
||||||
/// 单集信息
|
/// 单集信息
|
||||||
struct Episode: Codable, Identifiable, Hashable {
|
struct Episode: Codable, Identifiable, Hashable {
|
||||||
var id: String { name }
|
var id: String { name }
|
||||||
|
/// 集标题。
|
||||||
let name: String
|
let name: String
|
||||||
|
/// 集播放地址。
|
||||||
let url: String
|
let url: String
|
||||||
|
|
||||||
init(name: String, 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.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||||
info.sourceKey = video.sourceKey
|
info.sourceKey = video.sourceKey
|
||||||
|
|
||||||
// 解析播放列表
|
// 解析播放列表:
|
||||||
// playFrom 格式: "线路1$$$线路2$$$线路3"
|
// playFrom 格式: "线路1$$$线路2$$$线路3"
|
||||||
// playUrl 格式: "第1集$url1#第2集$url2$$$第1集$url3#第2集$url4"
|
// playUrl 格式: "第1集$url1#第2集$url2$$$第1集$url3#第2集$url4"
|
||||||
let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty }
|
let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty }
|
||||||
@@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前线路下的剧集。
|
||||||
var currentEpisodes: [Episode] {
|
var currentEpisodes: [Episode] {
|
||||||
playUrlMap[playFlag] ?? []
|
playUrlMap[playFlag] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前线路 + 当前索引对应的剧集对象。
|
||||||
var currentEpisode: Episode? {
|
var currentEpisode: Episode? {
|
||||||
let eps = currentEpisodes
|
let eps = currentEpisodes
|
||||||
guard playIndex >= 0, playIndex < eps.count else { return nil }
|
guard playIndex >= 0, playIndex < eps.count else { return nil }
|
||||||
|
|||||||
@@ -5,18 +5,26 @@ import SwiftData
|
|||||||
|
|
||||||
/// 单部剧的续播状态
|
/// 单部剧的续播状态
|
||||||
struct VodPlaybackState: Codable {
|
struct VodPlaybackState: Codable {
|
||||||
|
/// 当前播放线路标识。
|
||||||
var flag: String
|
var flag: String
|
||||||
|
/// 剧集索引。
|
||||||
var episodeIndex: Int
|
var episodeIndex: Int
|
||||||
|
/// 播放进度(秒)。
|
||||||
var progressSeconds: Double
|
var progressSeconds: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 视频收藏
|
/// 视频收藏
|
||||||
@Model
|
@Model
|
||||||
final class VodCollect {
|
final class VodCollect {
|
||||||
|
/// 视频 ID(与 sourceKey 组成唯一语义键)。
|
||||||
var vodId: String = ""
|
var vodId: String = ""
|
||||||
|
/// 片名。
|
||||||
var vodName: String = ""
|
var vodName: String = ""
|
||||||
|
/// 海报地址。
|
||||||
var vodPic: String = ""
|
var vodPic: String = ""
|
||||||
|
/// 来源站点 key。
|
||||||
var sourceKey: String = ""
|
var sourceKey: String = ""
|
||||||
|
/// 最近更新时间(收藏创建/刷新时间)。
|
||||||
var updateTime: Date = Date()
|
var updateTime: Date = Date()
|
||||||
|
|
||||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
|
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
|
||||||
@@ -31,12 +39,19 @@ final class VodCollect {
|
|||||||
/// 播放历史记录
|
/// 播放历史记录
|
||||||
@Model
|
@Model
|
||||||
final class VodRecord {
|
final class VodRecord {
|
||||||
|
/// 视频 ID。
|
||||||
var vodId: String = ""
|
var vodId: String = ""
|
||||||
|
/// 片名。
|
||||||
var vodName: String = ""
|
var vodName: String = ""
|
||||||
|
/// 海报地址。
|
||||||
var vodPic: String = ""
|
var vodPic: String = ""
|
||||||
|
/// 来源站点 key。
|
||||||
var sourceKey: String = ""
|
var sourceKey: String = ""
|
||||||
var playNote: String = "" // 如 "第5集 03:45"
|
/// 播放标记,如“第5集 03:45”。
|
||||||
var dataJson: String = "" // 续播状态 JSON(VodPlaybackState)
|
var playNote: String = ""
|
||||||
|
/// 续播状态 JSON(`VodPlaybackState` 编码结果)。
|
||||||
|
var dataJson: String = ""
|
||||||
|
/// 最近播放时间。
|
||||||
var updateTime: Date = Date()
|
var updateTime: Date = Date()
|
||||||
|
|
||||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
|
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
|
||||||
@@ -52,8 +67,11 @@ final class VodRecord {
|
|||||||
/// 通用缓存
|
/// 通用缓存
|
||||||
@Model
|
@Model
|
||||||
final class CacheItem {
|
final class CacheItem {
|
||||||
|
/// 唯一缓存键。
|
||||||
@Attribute(.unique) var key: String = ""
|
@Attribute(.unique) var key: String = ""
|
||||||
|
/// 缓存值(字符串形式)。
|
||||||
var value: String = ""
|
var value: String = ""
|
||||||
|
/// 更新时间。
|
||||||
var updateTime: Date = Date()
|
var updateTime: Date = Date()
|
||||||
|
|
||||||
init(key: String, value: String) {
|
init(key: String, value: String) {
|
||||||
@@ -95,6 +113,7 @@ actor CacheStore {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
|
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
|
||||||
|
// 收藏以 (vodId, sourceKey) 为业务唯一键,删除时也按该组合匹配。
|
||||||
let predicate = #Predicate<VodCollect> { item in
|
let predicate = #Predicate<VodCollect> { item in
|
||||||
item.vodId == vodId && item.sourceKey == sourceKey
|
item.vodId == vodId && item.sourceKey == sourceKey
|
||||||
}
|
}
|
||||||
@@ -151,6 +170,7 @@ actor CacheStore {
|
|||||||
try? context.save()
|
try? context.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取续播状态(若无记录或 JSON 无法解码则返回 `nil`)。
|
||||||
@MainActor
|
@MainActor
|
||||||
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
|
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
|
||||||
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else {
|
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else {
|
||||||
@@ -171,6 +191,7 @@ actor CacheStore {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
|
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
|
||||||
|
// 历史记录同样以 (vodId, sourceKey) 作为业务键。
|
||||||
let predicate = #Predicate<VodRecord> { item in
|
let predicate = #Predicate<VodRecord> { item in
|
||||||
item.vodId == vodId && item.sourceKey == sourceKey
|
item.vodId == vodId && item.sourceKey == sourceKey
|
||||||
}
|
}
|
||||||
@@ -185,6 +206,7 @@ actor CacheStore {
|
|||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 JSON 字符串反序列化续播状态。
|
||||||
private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? {
|
private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? {
|
||||||
guard let data = json.data(using: .utf8) else { return nil }
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
return try? JSONDecoder().decode(VodPlaybackState.self, from: data)
|
return try? JSONDecoder().decode(VodPlaybackState.self, from: data)
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
class ApiConfig: ObservableObject {
|
class ApiConfig: ObservableObject {
|
||||||
static let shared = ApiConfig()
|
static let shared = ApiConfig()
|
||||||
|
private static let maxConfigResolveDepth = 6
|
||||||
|
private static let maxRedirectCandidates = 20
|
||||||
|
|
||||||
|
struct MultiRepoOption: Identifiable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let url: String
|
||||||
|
|
||||||
|
var id: String { url.lowercased() }
|
||||||
|
}
|
||||||
|
|
||||||
@Published var sourceBeanList: [SourceBean] = []
|
@Published var sourceBeanList: [SourceBean] = []
|
||||||
@Published var homeSourceBean: SourceBean?
|
@Published var homeSourceBean: SourceBean?
|
||||||
@@ -38,21 +47,65 @@ class ApiConfig: ObservableObject {
|
|||||||
self.liveConfigUrl = resolvedLive
|
self.liveConfigUrl = resolvedLive
|
||||||
|
|
||||||
if trimmedVod == resolvedLive {
|
if trimmedVod == resolvedLive {
|
||||||
let config = try await fetchConfig(from: trimmedVod)
|
let configResult = try await fetchConfig(from: trimmedVod)
|
||||||
parseConfig(config, apiUrl: trimmedVod, includeSources: true, includeLive: true)
|
parseConfig(
|
||||||
|
configResult.config,
|
||||||
|
apiUrl: configResult.loadedFrom,
|
||||||
|
includeSources: true,
|
||||||
|
includeLive: true
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let vodConfig = try await fetchConfig(from: trimmedVod)
|
let vodConfig = try await fetchConfig(from: trimmedVod)
|
||||||
parseConfig(vodConfig, apiUrl: trimmedVod, includeSources: true, includeLive: false)
|
parseConfig(
|
||||||
|
vodConfig.config,
|
||||||
|
apiUrl: vodConfig.loadedFrom,
|
||||||
|
includeSources: true,
|
||||||
|
includeLive: false
|
||||||
|
)
|
||||||
|
|
||||||
let liveConfig = try await fetchConfig(from: resolvedLive)
|
let liveConfig = try await fetchConfig(from: resolvedLive)
|
||||||
parseConfig(liveConfig, apiUrl: resolvedLive, includeSources: false, includeLive: true)
|
parseConfig(
|
||||||
|
liveConfig.config,
|
||||||
|
apiUrl: liveConfig.loadedFrom,
|
||||||
|
includeSources: false,
|
||||||
|
includeLive: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.isLoaded = true
|
self.isLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchConfig(from apiUrl: String) async throws -> AppConfigData {
|
private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
|
||||||
let jsonStr = try await network.getString(from: apiUrl)
|
try await fetchConfig(
|
||||||
|
from: apiUrl,
|
||||||
|
visitedUrls: Set<String>(),
|
||||||
|
depth: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchConfig(
|
||||||
|
from apiUrl: String,
|
||||||
|
visitedUrls: Set<String>,
|
||||||
|
depth: Int
|
||||||
|
) async throws -> (config: AppConfigData, loadedFrom: String) {
|
||||||
|
guard depth <= Self.maxConfigResolveDepth else {
|
||||||
|
throw ConfigError.parseError("配置跳转层级过深(超过 \(Self.maxConfigResolveDepth) 层)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
|
||||||
|
guard !normalizedUrl.isEmpty else {
|
||||||
|
throw ConfigError.parseError("配置地址为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
let visitKey = normalizedUrl.lowercased()
|
||||||
|
guard !visitedUrls.contains(visitKey) else {
|
||||||
|
throw ConfigError.parseError("检测到循环引用的配置地址: \(normalizedUrl)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextVisited = visitedUrls
|
||||||
|
nextVisited.insert(visitKey)
|
||||||
|
|
||||||
|
let jsonStr = try await network.getString(from: normalizedUrl)
|
||||||
|
|
||||||
// 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理)
|
// 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理)
|
||||||
let cleanedJson = Self.stripJsonComments(jsonStr)
|
let cleanedJson = Self.stripJsonComments(jsonStr)
|
||||||
@@ -61,7 +114,283 @@ class ApiConfig: ObservableObject {
|
|||||||
throw ConfigError.parseError("无法解析配置数据")
|
throw ConfigError.parseError("无法解析配置数据")
|
||||||
}
|
}
|
||||||
|
|
||||||
return try JSONDecoder().decode(AppConfigData.self, from: data)
|
let decoder = JSONDecoder()
|
||||||
|
let decodedConfig = try? decoder.decode(AppConfigData.self, from: data)
|
||||||
|
if let config = decodedConfig, config.hasUsableContent {
|
||||||
|
return (config, normalizedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) {
|
||||||
|
let candidateUrls = Self.uniqueUrlsInOrder(
|
||||||
|
multiRepo.candidateUrls.map(Self.normalizeConfigUrl)
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastError: Error?
|
||||||
|
for candidateUrl in candidateUrls {
|
||||||
|
do {
|
||||||
|
return try await fetchConfig(
|
||||||
|
from: candidateUrl,
|
||||||
|
visitedUrls: nextVisited,
|
||||||
|
depth: depth + 1
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastError {
|
||||||
|
throw ConfigError.parseError("多仓库配置中没有可用地址,最后错误: \(lastError.localizedDescription)")
|
||||||
|
}
|
||||||
|
throw ConfigError.parseError("多仓库配置中没有可用地址")
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectCandidates = Self.extractConfigRedirectCandidates(from: cleanedJson)
|
||||||
|
.filter { $0.lowercased() != visitKey && !nextVisited.contains($0.lowercased()) }
|
||||||
|
|
||||||
|
if !redirectCandidates.isEmpty {
|
||||||
|
var lastError: Error?
|
||||||
|
for candidate in redirectCandidates {
|
||||||
|
do {
|
||||||
|
return try await fetchConfig(
|
||||||
|
from: candidate,
|
||||||
|
visitedUrls: nextVisited,
|
||||||
|
depth: depth + 1
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastError {
|
||||||
|
throw ConfigError.parseError("页面跳转配置解析失败,最后错误: \(lastError.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if decodedConfig != nil {
|
||||||
|
throw ConfigError.parseError("配置缺少可用站点(sites / lives / parses)")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ConfigError.parseError("配置格式不受支持")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] {
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var result: [String] = []
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
let key = trimmed.lowercased()
|
||||||
|
guard !seen.contains(key) else { continue }
|
||||||
|
seen.insert(key)
|
||||||
|
result.append(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从网页/文本中提取可能的配置地址:
|
||||||
|
/// - 明文 URL 文本
|
||||||
|
/// - data-clipboard-text(常见导航页“点击复制”)
|
||||||
|
/// - JSON 片段中的 url 字段
|
||||||
|
private static func extractConfigRedirectCandidates(from content: String) -> [String] {
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var rawCandidates: [String] = []
|
||||||
|
|
||||||
|
if (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) && !trimmed.contains("\n") {
|
||||||
|
rawCandidates.append(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawCandidates.append(contentsOf: matchCaptureGroup(
|
||||||
|
pattern: #"data-clipboard-text\s*=\s*["']([^"']+)["']"#,
|
||||||
|
in: content
|
||||||
|
))
|
||||||
|
rawCandidates.append(contentsOf: matchCaptureGroup(
|
||||||
|
pattern: #""url"\s*:\s*"([^"]+)""#,
|
||||||
|
in: content
|
||||||
|
))
|
||||||
|
rawCandidates.append(contentsOf: matchCaptureGroup(
|
||||||
|
pattern: #"(https?://[^\s"'<>\\]+)"#,
|
||||||
|
in: content
|
||||||
|
))
|
||||||
|
|
||||||
|
let normalized = rawCandidates
|
||||||
|
.map(sanitizeExtractedUrl)
|
||||||
|
.map(normalizeConfigUrl)
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.filter(isLikelyConfigPointerUrl)
|
||||||
|
.filter { !isLikelyBinaryAssetUrl($0) }
|
||||||
|
|
||||||
|
return Array(uniqueUrlsInOrder(normalized).prefix(maxRedirectCandidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchCaptureGroup(pattern: String, in content: String) -> [String] {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let range = NSRange(content.startIndex..<content.endIndex, in: content)
|
||||||
|
let matches = regex.matches(in: content, options: [], range: range)
|
||||||
|
|
||||||
|
return matches.compactMap { match in
|
||||||
|
guard match.numberOfRanges >= 2,
|
||||||
|
let subRange = Range(match.range(at: 1), in: content) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return String(content[subRange])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizeExtractedUrl(_ value: String) -> String {
|
||||||
|
var result = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
result = result.replacingOccurrences(of: "&", with: "&")
|
||||||
|
result = result.replacingOccurrences(of: "\\/", with: "/")
|
||||||
|
|
||||||
|
while let last = result.last, [".", ",", ";", ")", "]", "}", "\"", "'"].contains(last) {
|
||||||
|
result.removeLast()
|
||||||
|
}
|
||||||
|
while let first = result.first, ["\"", "'", "(", "[", "{"].contains(first) {
|
||||||
|
result.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLikelyBinaryAssetUrl(_ urlString: String) -> Bool {
|
||||||
|
guard let components = URLComponents(string: urlString) else { return false }
|
||||||
|
let path = components.path.lowercased()
|
||||||
|
return path.hasSuffix(".png")
|
||||||
|
|| path.hasSuffix(".jpg")
|
||||||
|
|| path.hasSuffix(".jpeg")
|
||||||
|
|| path.hasSuffix(".webp")
|
||||||
|
|| path.hasSuffix(".gif")
|
||||||
|
|| path.hasSuffix(".svg")
|
||||||
|
|| path.hasSuffix(".ico")
|
||||||
|
|| path.hasSuffix(".css")
|
||||||
|
|| path.hasSuffix(".woff")
|
||||||
|
|| path.hasSuffix(".woff2")
|
||||||
|
|| path.hasSuffix(".ttf")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLikelyConfigPointerUrl(_ urlString: String) -> Bool {
|
||||||
|
guard let components = URLComponents(string: urlString) else { return false }
|
||||||
|
|
||||||
|
let host = (components.host ?? "").lowercased()
|
||||||
|
let path = components.path.lowercased()
|
||||||
|
let query = (components.percentEncodedQuery ?? "").lowercased()
|
||||||
|
|
||||||
|
if host.contains("raw.githubusercontent.com") || host.contains("githubusercontent.com") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.contains(".json")
|
||||||
|
|| path.hasSuffix("/tv")
|
||||||
|
|| path.hasSuffix("/tv/")
|
||||||
|
|| path.hasSuffix("/m")
|
||||||
|
|| path.hasSuffix("/m/")
|
||||||
|
|| path.contains("tvbox")
|
||||||
|
|| path.contains("box")
|
||||||
|
|| query.contains("json")
|
||||||
|
|| query.contains("config")
|
||||||
|
|| query.contains("url=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一规范配置 URL:
|
||||||
|
/// 1) 修正 https:/xxx 之类的单斜杠写法;
|
||||||
|
/// 2) 将 github.com/.../blob/... 转为 raw.githubusercontent.com/...;
|
||||||
|
/// 3) 兼容 gh-proxy + github/blob 的嵌套代理地址。
|
||||||
|
static func normalizeConfigUrl(_ rawUrl: String) -> String {
|
||||||
|
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return trimmed }
|
||||||
|
|
||||||
|
let fixedScheme = fixMalformedSchemeIfNeeded(trimmed)
|
||||||
|
|
||||||
|
if let normalizedProxy = normalizeGhProxyWrappedUrl(fixedScheme) {
|
||||||
|
return normalizedProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
if let githubRaw = convertGitHubBlobUrlToRaw(fixedScheme) {
|
||||||
|
return githubRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fixMalformedSchemeIfNeeded(_ urlString: String) -> String {
|
||||||
|
let fixedHttps = urlString.replacingOccurrences(
|
||||||
|
of: #"^https:/(?!/)"#,
|
||||||
|
with: "https://",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
return fixedHttps.replacingOccurrences(
|
||||||
|
of: #"^http:/(?!/)"#,
|
||||||
|
with: "http://",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeGhProxyWrappedUrl(_ urlString: String) -> String? {
|
||||||
|
guard let components = URLComponents(string: urlString),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
host.contains("gh-proxy") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard !path.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let decodedPath = path.removingPercentEncoding ?? path
|
||||||
|
let fixedEmbedded = fixMalformedSchemeIfNeeded(decodedPath)
|
||||||
|
guard fixedEmbedded.hasPrefix("http://") || fixedEmbedded.hasPrefix("https://") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedEmbedded = convertGitHubBlobUrlToRaw(fixedEmbedded) ?? fixedEmbedded
|
||||||
|
let scheme = components.scheme ?? "https"
|
||||||
|
let portSuffix = components.port.map { ":\($0)" } ?? ""
|
||||||
|
|
||||||
|
var rebuilt = "\(scheme)://\(host)\(portSuffix)/\(normalizedEmbedded)"
|
||||||
|
if let query = components.percentEncodedQuery, !query.isEmpty {
|
||||||
|
rebuilt += "?\(query)"
|
||||||
|
}
|
||||||
|
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
|
||||||
|
rebuilt += "#\(fragment)"
|
||||||
|
}
|
||||||
|
return rebuilt
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertGitHubBlobUrlToRaw(_ urlString: String) -> String? {
|
||||||
|
guard let components = URLComponents(string: urlString),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
host == "github.com" || host == "www.github.com" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = components.path.split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
guard parts.count >= 5, parts[2] == "blob" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner = String(parts[0])
|
||||||
|
let repo = String(parts[1])
|
||||||
|
let branch = String(parts[3])
|
||||||
|
let filePath = parts.dropFirst(4).joined(separator: "/")
|
||||||
|
guard !filePath.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawUrl = "https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(filePath)"
|
||||||
|
if let query = components.percentEncodedQuery, !query.isEmpty {
|
||||||
|
rawUrl += "?\(query)"
|
||||||
|
}
|
||||||
|
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
|
||||||
|
rawUrl += "#\(fragment)"
|
||||||
|
}
|
||||||
|
return rawUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式
|
/// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式
|
||||||
@@ -124,6 +453,54 @@ class ApiConfig: ObservableObject {
|
|||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅探测“多仓库入口”并返回可选项。
|
||||||
|
/// 返回 nil 表示不是多仓库入口;返回数组表示是多仓库入口(数组可能为空)。
|
||||||
|
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
|
||||||
|
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
|
||||||
|
let jsonStr = try await network.getString(from: normalizedUrl)
|
||||||
|
let cleanedJson = Self.stripJsonComments(jsonStr)
|
||||||
|
|
||||||
|
guard let data = cleanedJson.data(using: .utf8) else {
|
||||||
|
throw ConfigError.parseError("无法解析配置数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
if let config = try? decoder.decode(AppConfigData.self, from: data), config.hasUsableContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedCandidates = Self.uniqueUrlsInOrder(
|
||||||
|
multiRepo.candidateUrls.map(Self.normalizeConfigUrl)
|
||||||
|
)
|
||||||
|
|
||||||
|
var options: [MultiRepoOption] = []
|
||||||
|
for candidate in normalizedCandidates {
|
||||||
|
let matchedEntry = multiRepo.urls?.first(where: {
|
||||||
|
Self.normalizeConfigUrl($0.url ?? "") == candidate
|
||||||
|
})
|
||||||
|
let displayName = matchedEntry?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let fallbackName = URL(string: candidate)?.host ?? candidate
|
||||||
|
let resolvedName: String
|
||||||
|
if let displayName, !displayName.isEmpty {
|
||||||
|
resolvedName = displayName
|
||||||
|
} else {
|
||||||
|
resolvedName = fallbackName
|
||||||
|
}
|
||||||
|
options.append(
|
||||||
|
MultiRepoOption(
|
||||||
|
name: resolvedName,
|
||||||
|
url: candidate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
/// 解析配置数据
|
/// 解析配置数据
|
||||||
private func parseConfig(
|
private func parseConfig(
|
||||||
_ config: AppConfigData,
|
_ config: AppConfigData,
|
||||||
@@ -142,6 +519,7 @@ class ApiConfig: ObservableObject {
|
|||||||
api: site.api ?? "",
|
api: site.api ?? "",
|
||||||
searchable: site.searchable?.value ?? 1,
|
searchable: site.searchable?.value ?? 1,
|
||||||
filterable: site.filterable?.value ?? 1,
|
filterable: site.filterable?.value ?? 1,
|
||||||
|
quickSearch: site.quickSearch?.value ?? 0,
|
||||||
playerType: site.playerType?.value ?? 0,
|
playerType: site.playerType?.value ?? 0,
|
||||||
type: site.type?.value ?? 1,
|
type: site.type?.value ?? 1,
|
||||||
ext: site.ext?.stringValue
|
ext: site.ext?.stringValue
|
||||||
@@ -389,13 +767,13 @@ class ApiConfig: ObservableObject {
|
|||||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return trimmed }
|
guard !trimmed.isEmpty else { return trimmed }
|
||||||
if let url = URL(string: trimmed), url.scheme != nil {
|
if let url = URL(string: trimmed), url.scheme != nil {
|
||||||
return trimmed
|
return Self.normalizeConfigUrl(trimmed)
|
||||||
}
|
}
|
||||||
guard let baseUrl = URL(string: baseConfigUrl),
|
guard let baseUrl = URL(string: baseConfigUrl),
|
||||||
let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else {
|
let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else {
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
return resolved.absoluteString
|
return Self.normalizeConfigUrl(resolved.absoluteString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func uniqueLiveUrls(_ urls: [String]) -> [String] {
|
private static func uniqueLiveUrls(_ urls: [String]) -> [String] {
|
||||||
|
|||||||
@@ -2,11 +2,42 @@ import Foundation
|
|||||||
|
|
||||||
/// 网络请求封装 - 对应 Android 版 OkGo
|
/// 网络请求封装 - 对应 Android 版 OkGo
|
||||||
class NetworkManager {
|
class NetworkManager {
|
||||||
|
/// 全局共享实例。
|
||||||
static let shared = NetworkManager()
|
static let shared = NetworkManager()
|
||||||
|
|
||||||
|
/// 兜底字符集名称列表(按常见中文资源站编码优先级排序)。
|
||||||
|
private static let fallbackCharsetNames: [String] = [
|
||||||
|
"utf-8",
|
||||||
|
"gb18030",
|
||||||
|
"gbk",
|
||||||
|
"gb2312",
|
||||||
|
"utf-16",
|
||||||
|
"utf-16le",
|
||||||
|
"utf-16be",
|
||||||
|
"utf-32",
|
||||||
|
"windows-1252",
|
||||||
|
"iso-8859-1"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 兜底字符串编码列表(与字符集列表互补)。
|
||||||
|
private static let fallbackStringEncodings: [String.Encoding] = [
|
||||||
|
.utf8,
|
||||||
|
.utf16,
|
||||||
|
.utf16LittleEndian,
|
||||||
|
.utf16BigEndian,
|
||||||
|
.utf32,
|
||||||
|
.utf32LittleEndian,
|
||||||
|
.utf32BigEndian,
|
||||||
|
.windowsCP1252,
|
||||||
|
.isoLatin1
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 内部会话对象,统一超时与连接上限配置。
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
/// JSON 解码器。
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
/// 私有初始化,防止外部创建多个请求管理器。
|
||||||
private init() {
|
private init() {
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 15
|
config.timeoutIntervalForRequest = 15
|
||||||
@@ -35,8 +66,9 @@ class NetworkManager {
|
|||||||
throw NetworkError.httpError(httpResponse.statusCode)
|
throw NetworkError.httpError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let str = String(data: data, encoding: .utf8) else {
|
// 统一走字符集探测 + 多编码兜底,降低跨源乱码概率。
|
||||||
throw NetworkError.decodingError("UTF-8 解码失败")
|
guard let str = Self.decodeString(data: data, response: httpResponse) else {
|
||||||
|
throw NetworkError.decodingError("文本解码失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
return str
|
return str
|
||||||
@@ -59,14 +91,59 @@ class NetworkManager {
|
|||||||
let (data, _) = try await session.data(from: url)
|
let (data, _) = try await session.data(from: url)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 文本解码策略:
|
||||||
|
/// 1) 先用响应头声明字符集;
|
||||||
|
/// 2) 再按常见字符集与编码顺序尝试;
|
||||||
|
/// 3) 最后用 UTF-8 宽容解码兜底。
|
||||||
|
private static func decodeString(data: Data, response: HTTPURLResponse) -> String? {
|
||||||
|
// 优先使用服务端声明的字符集(如 gbk / gb2312 / gb18030)
|
||||||
|
if let charset = response.textEncodingName,
|
||||||
|
let declaredEncoding = encoding(fromIANACharset: charset),
|
||||||
|
let value = String(data: data, encoding: declaredEncoding) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
for charset in fallbackCharsetNames {
|
||||||
|
if let encoding = encoding(fromIANACharset: charset),
|
||||||
|
let value = String(data: data, encoding: encoding) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for encoding in fallbackStringEncodings {
|
||||||
|
if let value = String(data: data, encoding: encoding) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后兜底,避免单纯因编码不一致直接报错
|
||||||
|
if !data.isEmpty {
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IANA 字符集名转 `String.Encoding`。
|
||||||
|
private static func encoding(fromIANACharset charset: String) -> String.Encoding? {
|
||||||
|
let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString)
|
||||||
|
guard cfEncoding != kCFStringEncodingInvalidId else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding)
|
||||||
|
return String.Encoding(rawValue: nsEncoding)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 网络层错误定义。
|
||||||
enum NetworkError: LocalizedError {
|
enum NetworkError: LocalizedError {
|
||||||
case invalidURL(String)
|
case invalidURL(String)
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
case httpError(Int)
|
case httpError(Int)
|
||||||
case decodingError(String)
|
case decodingError(String)
|
||||||
|
|
||||||
|
/// 面向 UI/日志的错误描述。
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidURL(let url): return "无效的URL: \(url)"
|
case .invalidURL(let url): return "无效的URL: \(url)"
|
||||||
|
|||||||
@@ -325,9 +325,10 @@ class SourceService {
|
|||||||
url = "\(api)?wd=\(encodedKeyword)"
|
url = "\(api)?wd=\(encodedKeyword)"
|
||||||
} else if sourceBean.type == 4 {
|
} else if sourceBean.type == 4 {
|
||||||
// Type 4: 远程接口
|
// Type 4: 远程接口
|
||||||
|
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
|
||||||
url = api.contains("?")
|
url = api.contains("?")
|
||||||
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false"
|
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
|
||||||
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false"
|
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
|
||||||
|
|
||||||
// 加载 extend
|
// 加载 extend
|
||||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||||
@@ -345,7 +346,8 @@ class SourceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let jsonStr = try await network.getString(from: url)
|
let jsonStr = try await network.getString(from: url)
|
||||||
return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
|
let videos = try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
|
||||||
|
return filterSearchResults(videos, keyword: keyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 多源并发搜索
|
/// 多源并发搜索
|
||||||
@@ -374,6 +376,43 @@ class SourceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 对源返回结果做本地关键词过滤,规避部分接口返回推荐/无关内容。
|
||||||
|
private func filterSearchResults(_ videos: [Movie.Video], keyword: String) -> [Movie.Video] {
|
||||||
|
let tokens = keyword
|
||||||
|
.split(whereSeparator: \.isWhitespace)
|
||||||
|
.map { normalizeSearchText(String($0)) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
guard !tokens.isEmpty else { return videos }
|
||||||
|
|
||||||
|
return videos.filter { video in
|
||||||
|
let searchableText = normalizeSearchText([
|
||||||
|
video.name,
|
||||||
|
video.note,
|
||||||
|
video.actor,
|
||||||
|
video.director,
|
||||||
|
video.type,
|
||||||
|
video.area,
|
||||||
|
video.year
|
||||||
|
].joined(separator: " "))
|
||||||
|
guard !searchableText.isEmpty else { return false }
|
||||||
|
return tokens.allSatisfy { searchableText.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizeSearchText(_ text: String) -> String {
|
||||||
|
let folded = text.folding(
|
||||||
|
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
|
||||||
|
locale: .current
|
||||||
|
)
|
||||||
|
let scalars = folded.unicodeScalars.filter { scalar in
|
||||||
|
!CharacterSet.whitespacesAndNewlines.contains(scalar) &&
|
||||||
|
!CharacterSet.punctuationCharacters.contains(scalar) &&
|
||||||
|
!CharacterSet.symbols.contains(scalar)
|
||||||
|
}
|
||||||
|
return String(String.UnicodeScalarView(scalars)).lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Extend 解析
|
// MARK: - Extend 解析
|
||||||
|
|
||||||
/// 解析 extend 参数(对应 Android 端 getFixUrl)
|
/// 解析 extend 参数(对应 Android 端 getFixUrl)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PlaybackQualityOption: Identifiable, Hashable {
|
struct PlaybackQualityOption: Identifiable, Hashable {
|
||||||
|
/// “自动”选项固定标识。
|
||||||
static let autoIdentifier = "auto"
|
static let autoIdentifier = "auto"
|
||||||
|
/// 选项唯一标识(这里直接使用播放地址或固定 auto id)。
|
||||||
let id: String
|
let id: String
|
||||||
|
/// UI 展示名(如 1080p / 720p / 自动)。
|
||||||
let name: String
|
let name: String
|
||||||
|
/// 对应播放地址。
|
||||||
let url: String
|
let url: String
|
||||||
|
|
||||||
var isAuto: Bool {
|
var isAuto: Bool {
|
||||||
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
|
|||||||
/// 详情页 ViewModel
|
/// 详情页 ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class DetailViewModel: ObservableObject {
|
class DetailViewModel: ObservableObject {
|
||||||
|
/// 详情信息主体。
|
||||||
@Published var vodInfo: VodInfo?
|
@Published var vodInfo: VodInfo?
|
||||||
|
/// 加载状态。
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
/// 错误提示。
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
/// 当前选中线路。
|
||||||
@Published var selectedFlag: String = ""
|
@Published var selectedFlag: String = ""
|
||||||
|
/// 当前选中剧集索引。
|
||||||
@Published var selectedEpisodeIndex: Int = 0
|
@Published var selectedEpisodeIndex: Int = 0
|
||||||
|
/// 是否处于播放态。
|
||||||
@Published var isPlaying = false
|
@Published var isPlaying = false
|
||||||
|
/// 当前实际播放地址(可能是原始地址,也可能是清晰度切换后的子流地址)。
|
||||||
@Published var playUrl: String?
|
@Published var playUrl: String?
|
||||||
|
/// 续播起始位置(秒)。
|
||||||
@Published var resumeSeconds: Double = 0
|
@Published var resumeSeconds: Double = 0
|
||||||
|
/// 当前可选清晰度列表。
|
||||||
@Published var qualityOptions: [PlaybackQualityOption] = []
|
@Published var qualityOptions: [PlaybackQualityOption] = []
|
||||||
|
/// 当前选中的清晰度 id。
|
||||||
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
|
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
|
||||||
|
/// 播放器高频回调进度,不直接绑定 UI,避免高频刷新引发性能问题。
|
||||||
private var realtimeProgressSeconds: Double = 0
|
private var realtimeProgressSeconds: Double = 0
|
||||||
|
|
||||||
|
/// 数据服务与网络服务。
|
||||||
private let sourceService = SourceService.shared
|
private let sourceService = SourceService.shared
|
||||||
private let network = NetworkManager.shared
|
private let network = NetworkManager.shared
|
||||||
|
/// 当前清晰度列表对应的基础剧集地址。
|
||||||
private var qualityBaseEpisodeURL: String = ""
|
private var qualityBaseEpisodeURL: String = ""
|
||||||
|
/// 清晰度解析缓存,key 为原始剧集 URL。
|
||||||
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
|
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
|
||||||
|
/// 清晰度解析任务,用于取消旧请求。
|
||||||
private var qualityResolveTask: Task<Void, Never>?
|
private var qualityResolveTask: Task<Void, Never>?
|
||||||
|
/// 解析令牌,防止异步结果回写到过期状态。
|
||||||
private var qualityResolveToken = UUID()
|
private var qualityResolveToken = UUID()
|
||||||
|
|
||||||
/// 加载视频详情
|
/// 加载视频详情
|
||||||
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
realtimeProgressSeconds = 0
|
realtimeProgressSeconds = 0
|
||||||
|
|
||||||
if let episode = vodInfo?.currentEpisode {
|
if let episode = vodInfo?.currentEpisode {
|
||||||
|
// 仅当剧集 URL 变化时重置清晰度选择。
|
||||||
let shouldResetQuality = qualityBaseEpisodeURL != episode.url
|
let shouldResetQuality = qualityBaseEpisodeURL != episode.url
|
||||||
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
|
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
|
||||||
playUrl = selectedPlayableURL(fallback: episode.url)
|
playUrl = selectedPlayableURL(fallback: episode.url)
|
||||||
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
selectedQualityId = option.id
|
selectedQualityId = option.id
|
||||||
guard isPlaying else { return }
|
guard isPlaying else { return }
|
||||||
|
|
||||||
|
// “自动”使用基础剧集地址;其他选项使用对应变体地址。
|
||||||
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
|
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
|
||||||
guard !targetURL.isEmpty, playUrl != targetURL else { return }
|
guard !targetURL.isEmpty, playUrl != targetURL else { return }
|
||||||
|
|
||||||
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func selectedPlayableURL(fallback: String) -> String {
|
private func selectedPlayableURL(fallback: String) -> String {
|
||||||
|
// 若当前清晰度存在有效 URL,则优先使用;否则回退剧集原始地址。
|
||||||
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
|
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
|
||||||
if let selected, !selected.isEmpty {
|
if let selected, !selected.isEmpty {
|
||||||
return selected
|
return selected
|
||||||
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置清晰度解析与选择状态。
|
||||||
private func resetQualityState() {
|
private func resetQualityState() {
|
||||||
qualityResolveTask?.cancel()
|
qualityResolveTask?.cancel()
|
||||||
qualityResolveTask = nil
|
qualityResolveTask = nil
|
||||||
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换剧集时先取消旧任务,避免异步回写错位。
|
||||||
qualityResolveTask?.cancel()
|
qualityResolveTask?.cancel()
|
||||||
qualityResolveTask = nil
|
qualityResolveTask = nil
|
||||||
|
|
||||||
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
qualityOptions = [autoOption]
|
qualityOptions = [autoOption]
|
||||||
|
|
||||||
if let cached = qualityOptionCache[trimmedEpisodeURL] {
|
if let cached = qualityOptionCache[trimmedEpisodeURL] {
|
||||||
|
// 缓存命中时直接复用,避免重复网络解析。
|
||||||
qualityOptions = cached
|
qualityOptions = cached
|
||||||
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
|
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
|
||||||
selectedQualityId = PlaybackQualityOption.autoIdentifier
|
selectedQualityId = PlaybackQualityOption.autoIdentifier
|
||||||
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 尝试从 HLS 主播放列表解析多清晰度选项。
|
||||||
private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] {
|
private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] {
|
||||||
guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] }
|
guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] }
|
||||||
guard let playlist = try? await network.getString(from: episodeURL) else { return [] }
|
guard let playlist = try? await network.getString(from: episodeURL) else { return [] }
|
||||||
return Self.parseMasterPlaylist(playlist, masterURL: url)
|
return Self.parseMasterPlaylist(playlist, masterURL: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HLS 变体流中间模型。
|
||||||
private struct HLSVariant {
|
private struct HLSVariant {
|
||||||
let url: String
|
let url: String
|
||||||
let name: String?
|
let name: String?
|
||||||
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
let bandwidth: Int?
|
let bandwidth: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 轻量判断 URL 是否可能是 HLS 播放列表。
|
||||||
private static func looksLikeHLSURL(_ url: URL) -> Bool {
|
private static func looksLikeHLSURL(_ url: URL) -> Bool {
|
||||||
let lowercased = url.absoluteString.lowercased()
|
let lowercased = url.absoluteString.lowercased()
|
||||||
if lowercased.contains(".m3u8") { return true }
|
if lowercased.contains(".m3u8") { return true }
|
||||||
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
|
|||||||
return ext == "m3u8" || ext == "m3u"
|
return ext == "m3u8" || ext == "m3u"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析 HLS 主播放列表并生成清晰度选项。
|
||||||
|
/// 仅当解析出 2 个及以上有效变体时才返回(否则不显示清晰度切换)。
|
||||||
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
|
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
|
||||||
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
|
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
|
||||||
|
|
||||||
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析 `EXT-X-STREAM-INF` 的属性串为键值字典。
|
||||||
private static func parseAttributeMap(_ raw: String) -> [String: String] {
|
private static func parseAttributeMap(_ raw: String) -> [String: String] {
|
||||||
var result: [String: String] = [:]
|
var result: [String: String] = [:]
|
||||||
let pairs = splitAttributes(raw)
|
let pairs = splitAttributes(raw)
|
||||||
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按逗号分隔属性,但保留引号内逗号。
|
||||||
private static func splitAttributes(_ raw: String) -> [String] {
|
private static func splitAttributes(_ raw: String) -> [String] {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
var buffer = ""
|
var buffer = ""
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ import SwiftUI
|
|||||||
/// 首页 ViewModel
|
/// 首页 ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class HomeViewModel: ObservableObject {
|
class HomeViewModel: ObservableObject {
|
||||||
|
/// 分类列表(包含手动注入的“推荐”分类)。
|
||||||
@Published var sorts: [MovieSort.SortData] = []
|
@Published var sorts: [MovieSort.SortData] = []
|
||||||
|
/// 当前选中的分类。
|
||||||
@Published var selectedSort: MovieSort.SortData?
|
@Published var selectedSort: MovieSort.SortData?
|
||||||
|
/// 首页推荐内容(对应“推荐”分类)。
|
||||||
@Published var homeVideos: [Movie.Video] = []
|
@Published var homeVideos: [Movie.Video] = []
|
||||||
|
/// 普通分类的视频列表(分页加载)。
|
||||||
@Published var categoryVideos: [Movie.Video] = []
|
@Published var categoryVideos: [Movie.Video] = []
|
||||||
|
/// 页面加载状态(分类加载与分页共用)。
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
/// 当前分类的分页页码。
|
||||||
@Published var currentPage = 1
|
@Published var currentPage = 1
|
||||||
|
/// 是否还有下一页。
|
||||||
@Published var hasMore = true
|
@Published var hasMore = true
|
||||||
|
/// 错误提示文案。
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
/// 源数据访问服务。
|
||||||
private let sourceService = SourceService.shared
|
private let sourceService = SourceService.shared
|
||||||
|
|
||||||
/// 加载分类列表
|
/// 加载分类列表
|
||||||
@@ -24,7 +33,7 @@ class HomeViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let result = try await sourceService.getSort(sourceBean: source)
|
let result = try await sourceService.getSort(sourceBean: source)
|
||||||
|
|
||||||
// 添加"推荐"到首位
|
// 插入本地“推荐”分类,保持 UI 与 Android 版本习惯一致。
|
||||||
var allSorts = [MovieSort.SortData.home()]
|
var allSorts = [MovieSort.SortData.home()]
|
||||||
allSorts.append(contentsOf: result.sorts)
|
allSorts.append(contentsOf: result.sorts)
|
||||||
|
|
||||||
@@ -43,6 +52,7 @@ class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// 选择分类
|
/// 选择分类
|
||||||
func selectSort(_ sort: MovieSort.SortData) {
|
func selectSort(_ sort: MovieSort.SortData) {
|
||||||
|
// 切分类时先重置分页状态,避免旧分类残留数据闪烁。
|
||||||
selectedSort = sort
|
selectedSort = sort
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
categoryVideos = []
|
categoryVideos = []
|
||||||
@@ -62,6 +72,7 @@ class HomeViewModel: ObservableObject {
|
|||||||
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
|
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
|
||||||
guard sort.id != "home" else { return }
|
guard sort.id != "home" else { return }
|
||||||
guard let source = ApiConfig.shared.homeSourceBean else { return }
|
guard let source = ApiConfig.shared.homeSourceBean else { return }
|
||||||
|
// 防重复并发加载,避免分页错序。
|
||||||
guard !isLoading else { return }
|
guard !isLoading else { return }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -78,6 +89,7 @@ class HomeViewModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
categoryVideos.append(contentsOf: videos)
|
categoryVideos.append(contentsOf: videos)
|
||||||
}
|
}
|
||||||
|
// 以“返回非空”作为是否继续分页的轻量判断。
|
||||||
currentPage = page
|
currentPage = page
|
||||||
hasMore = !videos.isEmpty
|
hasMore = !videos.isEmpty
|
||||||
} catch {
|
} catch {
|
||||||
@@ -105,6 +117,7 @@ class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// 刷新
|
/// 刷新
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
|
// 全量刷新时重置分页与错误态,再重新拉分类与当前分类内容。
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
hasMore = true
|
hasMore = true
|
||||||
categoryVideos = []
|
categoryVideos = []
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import SwiftUI
|
|||||||
/// 直播 ViewModel
|
/// 直播 ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class LiveViewModel: ObservableObject {
|
class LiveViewModel: ObservableObject {
|
||||||
|
/// 全部频道分组。
|
||||||
@Published var channelGroups: [LiveChannelGroup] = []
|
@Published var channelGroups: [LiveChannelGroup] = []
|
||||||
|
/// 当前选中分组索引。
|
||||||
@Published var selectedGroupIndex: Int = 0
|
@Published var selectedGroupIndex: Int = 0
|
||||||
|
/// 当前选中频道索引(相对于当前分组)。
|
||||||
@Published var selectedChannelIndex: Int = 0
|
@Published var selectedChannelIndex: Int = 0
|
||||||
|
/// 当前播放频道。
|
||||||
@Published var currentChannel: LiveChannelItem?
|
@Published var currentChannel: LiveChannelItem?
|
||||||
|
/// 当前频道节目单(预留)。
|
||||||
@Published var epgList: [Epginfo] = []
|
@Published var epgList: [Epginfo] = []
|
||||||
|
/// 加载状态(预留,便于后续接入远程 EPG)。
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
/// 是否显示频道列表(预留给 TV 遥控交互)。
|
||||||
@Published var showChannelList = false
|
@Published var showChannelList = false
|
||||||
|
|
||||||
/// 加载直播频道
|
/// 加载直播频道
|
||||||
@@ -67,6 +74,7 @@ class LiveViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// 切换线路
|
/// 切换线路
|
||||||
func switchSource() {
|
func switchSource() {
|
||||||
|
// `currentChannel` 为值类型,调用 mutating 方法会触发 @Published 重新发布。
|
||||||
currentChannel?.nextSource()
|
currentChannel?.nextSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +86,15 @@ class LiveViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// 加载 EPG 节目单
|
/// 加载 EPG 节目单
|
||||||
private func loadEPG(for channel: LiveChannelItem) {
|
private func loadEPG(for channel: LiveChannelItem) {
|
||||||
// EPG 加载 - 简化实现
|
// 预留:后续可在此按频道名/频道 ID 请求远程 EPG。
|
||||||
|
// 当前版本先清空,避免展示过期节目单。
|
||||||
epgList = []
|
epgList = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全数组下标访问
|
// 安全数组下标访问
|
||||||
extension Collection {
|
extension Collection {
|
||||||
|
/// 安全下标读取,越界时返回 `nil`。
|
||||||
subscript(safe index: Index) -> Element? {
|
subscript(safe index: Index) -> Element? {
|
||||||
indices.contains(index) ? self[index] : nil
|
indices.contains(index) ? self[index] : nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,23 @@ import SwiftUI
|
|||||||
/// 搜索 ViewModel
|
/// 搜索 ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class SearchViewModel: ObservableObject {
|
class SearchViewModel: ObservableObject {
|
||||||
|
/// 搜索关键词输入。
|
||||||
@Published var keyword: String = ""
|
@Published var keyword: String = ""
|
||||||
|
/// 当前结果列表。
|
||||||
@Published var results: [Movie.Video] = []
|
@Published var results: [Movie.Video] = []
|
||||||
|
/// 搜索加载状态,用于控制进度指示器。
|
||||||
@Published var isSearching = false
|
@Published var isSearching = false
|
||||||
|
/// 本地搜索历史(最近在前)。
|
||||||
@Published var searchHistory: [String] = []
|
@Published var searchHistory: [String] = []
|
||||||
|
/// 搜索失败或空结果提示。
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
/// 源数据服务(负责多源并发搜索)。
|
||||||
private let sourceService = SourceService.shared
|
private let sourceService = SourceService.shared
|
||||||
|
/// 搜索请求序号(用于丢弃过期异步结果)。
|
||||||
|
private var latestSearchRequestId: UUID = UUID()
|
||||||
|
|
||||||
|
/// 初始化时同步加载本地历史记录,确保搜索页首次渲染即可展示。
|
||||||
init() {
|
init() {
|
||||||
loadSearchHistory()
|
loadSearchHistory()
|
||||||
}
|
}
|
||||||
@@ -20,15 +29,19 @@ class SearchViewModel: ObservableObject {
|
|||||||
func search() async {
|
func search() async {
|
||||||
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let requestId = UUID()
|
||||||
|
latestSearchRequestId = requestId
|
||||||
|
|
||||||
isSearching = true
|
isSearching = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
// 保存搜索历史
|
// 搜索一旦触发就先落历史,保持行为与移动端常见搜索体验一致。
|
||||||
addToHistory(trimmed)
|
addToHistory(trimmed)
|
||||||
|
|
||||||
|
// 走多源并发搜索,返回聚合后的影片列表。
|
||||||
let videos = await sourceService.searchAll(keyword: trimmed)
|
let videos = await sourceService.searchAll(keyword: trimmed)
|
||||||
|
guard requestId == latestSearchRequestId else { return }
|
||||||
self.results = videos
|
self.results = videos
|
||||||
|
|
||||||
if videos.isEmpty {
|
if videos.isEmpty {
|
||||||
@@ -42,13 +55,18 @@ class SearchViewModel: ObservableObject {
|
|||||||
func searchInSource(_ source: SourceBean) async {
|
func searchInSource(_ source: SourceBean) async {
|
||||||
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let requestId = UUID()
|
||||||
|
latestSearchRequestId = requestId
|
||||||
|
|
||||||
isSearching = true
|
isSearching = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let videos = try await sourceService.search(sourceBean: source, keyword: trimmed)
|
let videos = try await sourceService.search(sourceBean: source, keyword: trimmed)
|
||||||
|
guard requestId == latestSearchRequestId else { return }
|
||||||
self.results = videos
|
self.results = videos
|
||||||
} catch {
|
} catch {
|
||||||
|
guard requestId == latestSearchRequestId else { return }
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +75,12 @@ class SearchViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - 搜索历史
|
// MARK: - 搜索历史
|
||||||
|
|
||||||
|
/// 从本地读取历史。
|
||||||
private func loadSearchHistory() {
|
private func loadSearchHistory() {
|
||||||
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
|
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 新增历史项并去重,最多保留 20 条。
|
||||||
private func addToHistory(_ keyword: String) {
|
private func addToHistory(_ keyword: String) {
|
||||||
searchHistory.removeAll { $0 == keyword }
|
searchHistory.removeAll { $0 == keyword }
|
||||||
searchHistory.insert(keyword, at: 0)
|
searchHistory.insert(keyword, at: 0)
|
||||||
@@ -70,11 +90,13 @@ class SearchViewModel: ObservableObject {
|
|||||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空历史。
|
||||||
func clearHistory() {
|
func clearHistory() {
|
||||||
searchHistory = []
|
searchHistory = []
|
||||||
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
|
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除单条历史。
|
||||||
func removeFromHistory(_ keyword: String) {
|
func removeFromHistory(_ keyword: String) {
|
||||||
searchHistory.removeAll { $0 == keyword }
|
searchHistory.removeAll { $0 == keyword }
|
||||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||||
|
|||||||
@@ -4,24 +4,70 @@ import SwiftUI
|
|||||||
/// 设置 ViewModel
|
/// 设置 ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class SettingsViewModel: ObservableObject {
|
class SettingsViewModel: ObservableObject {
|
||||||
|
/// 当输入地址是“多仓库入口”时,先弹出候选仓库供用户确认。
|
||||||
|
struct PendingMultiRepoSelection: Identifiable {
|
||||||
|
/// 当前待选择的是点播仓库还是直播仓库。
|
||||||
|
enum Target {
|
||||||
|
case vod
|
||||||
|
case live
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .vod: return "点播"
|
||||||
|
case .live: return "直播"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
/// 目标类型。
|
||||||
|
let target: Target
|
||||||
|
/// 用户原始输入地址(用于后续“是否联动 live 地址”判断)。
|
||||||
|
let sourceUrl: String
|
||||||
|
/// 可选仓库列表。
|
||||||
|
let options: [ApiConfig.MultiRepoOption]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点播配置地址。
|
||||||
@Published var vodApiUrl: String = ""
|
@Published var vodApiUrl: String = ""
|
||||||
|
/// 直播配置地址。
|
||||||
@Published var liveApiUrl: String = ""
|
@Published var liveApiUrl: String = ""
|
||||||
|
/// 配置加载中状态。
|
||||||
@Published var isLoadingConfig = false
|
@Published var isLoadingConfig = false
|
||||||
|
/// 配置错误提示。
|
||||||
@Published var configError: String?
|
@Published var configError: String?
|
||||||
|
/// 配置是否加载成功(供 UI 执行后续跳转/收起流程)。
|
||||||
@Published var configSuccess = false
|
@Published var configSuccess = false
|
||||||
|
/// 多仓库待选状态,为 nil 表示无需弹窗。
|
||||||
|
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
|
||||||
|
/// 最近输入过的 API 历史。
|
||||||
@Published var apiHistory: [String] = []
|
@Published var apiHistory: [String] = []
|
||||||
|
/// 点播播放器内核选择。
|
||||||
@Published var vodPlayerEngine: PlayerEngine = .system
|
@Published var vodPlayerEngine: PlayerEngine = .system
|
||||||
|
/// 直播播放器内核选择。
|
||||||
@Published var livePlayerEngine: PlayerEngine = .system
|
@Published var livePlayerEngine: PlayerEngine = .system
|
||||||
|
/// 解码模式选择。
|
||||||
@Published var decodeMode: VideoDecodeMode = .auto
|
@Published var decodeMode: VideoDecodeMode = .auto
|
||||||
|
/// VLC 缓冲策略。
|
||||||
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
||||||
|
/// 快进/快退步长(秒)。
|
||||||
@Published var playTimeStep: Int = 10
|
@Published var playTimeStep: Int = 10
|
||||||
|
/// 缓存占用展示文本。
|
||||||
@Published var cacheSizeString: String = "0 KB"
|
@Published var cacheSizeString: String = "0 KB"
|
||||||
|
|
||||||
|
/// 快进步长候选项。
|
||||||
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
|
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
|
||||||
|
/// 当前构建可用播放器列表。
|
||||||
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
|
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
|
||||||
|
/// 解码模式候选。
|
||||||
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
|
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
|
||||||
|
/// VLC 缓冲模式候选。
|
||||||
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
||||||
|
|
||||||
|
/// 初始化时完成三件事:
|
||||||
|
/// 1) 回填已保存的配置地址
|
||||||
|
/// 2) 兼容老版本单一播放器字段到新字段
|
||||||
|
/// 3) 回填播放/缓存相关设置
|
||||||
init() {
|
init() {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||||
@@ -74,10 +120,23 @@ class SettingsViewModel: ObservableObject {
|
|||||||
isLoadingConfig = true
|
isLoadingConfig = true
|
||||||
configError = nil
|
configError = nil
|
||||||
configSuccess = false
|
configSuccess = false
|
||||||
|
pendingMultiRepoSelection = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||||
|
|
||||||
|
// 若探测到多仓库入口,先中断加载并弹出候选,让用户显式选定目标仓库。
|
||||||
|
if let pending = try await detectPendingMultiRepoSelection(
|
||||||
|
vodUrl: trimmedVod,
|
||||||
|
liveUrl: resolvedLive
|
||||||
|
) {
|
||||||
|
pendingMultiRepoSelection = pending
|
||||||
|
isLoadingConfig = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
|
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
|
||||||
|
// 保存用户输入(live 允许空值,表示跟随点播地址)。
|
||||||
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
|
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
|
||||||
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
|
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
|
||||||
vodApiUrl = trimmedVod
|
vodApiUrl = trimmedVod
|
||||||
@@ -92,12 +151,80 @@ class SettingsViewModel: ObservableObject {
|
|||||||
isLoadingConfig = false
|
isLoadingConfig = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理多仓库弹窗选择结果,并继续走统一加载流程。
|
||||||
|
func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async {
|
||||||
|
guard let pending = pendingMultiRepoSelection else { return }
|
||||||
|
let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl)
|
||||||
|
|
||||||
|
switch pending.target {
|
||||||
|
case .vod:
|
||||||
|
let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl)
|
||||||
|
// 若 live 输入与原始 vod 相同,说明用户希望两者共用,选择后同步更新。
|
||||||
|
let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
&& normalizedLive == normalizedSource
|
||||||
|
vodApiUrl = option.url
|
||||||
|
if shouldSyncLive {
|
||||||
|
liveApiUrl = option.url
|
||||||
|
}
|
||||||
|
case .live:
|
||||||
|
liveApiUrl = option.url
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMultiRepoSelection = nil
|
||||||
|
await loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消多仓库选择,恢复到普通待输入状态。
|
||||||
|
func cancelPendingMultiRepoSelection() {
|
||||||
|
pendingMultiRepoSelection = nil
|
||||||
|
isLoadingConfig = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试识别输入地址是否为多仓库入口。
|
||||||
|
/// - Returns: 需要弹窗选择时返回待选对象,否则返回 `nil`。
|
||||||
|
private func detectPendingMultiRepoSelection(
|
||||||
|
vodUrl: String,
|
||||||
|
liveUrl: String
|
||||||
|
) async throws -> PendingMultiRepoSelection? {
|
||||||
|
if let vodOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: vodUrl) {
|
||||||
|
guard !vodOptions.isEmpty else {
|
||||||
|
throw ConfigError.parseError("点播多仓库配置中没有可用地址")
|
||||||
|
}
|
||||||
|
return PendingMultiRepoSelection(
|
||||||
|
target: .vod,
|
||||||
|
sourceUrl: vodUrl,
|
||||||
|
options: vodOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedVod = ApiConfig.normalizeConfigUrl(vodUrl)
|
||||||
|
let normalizedLive = ApiConfig.normalizeConfigUrl(liveUrl)
|
||||||
|
guard normalizedLive != normalizedVod else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let liveOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: liveUrl) {
|
||||||
|
guard !liveOptions.isEmpty else {
|
||||||
|
throw ConfigError.parseError("直播多仓库配置中没有可用地址")
|
||||||
|
}
|
||||||
|
return PendingMultiRepoSelection(
|
||||||
|
target: .live,
|
||||||
|
sourceUrl: liveUrl,
|
||||||
|
options: liveOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - API 历史
|
// MARK: - API 历史
|
||||||
|
|
||||||
|
/// 读取 API 历史。
|
||||||
private func loadApiHistory() {
|
private func loadApiHistory() {
|
||||||
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
|
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 新增历史并去重,最多保留 10 条。
|
||||||
private func addToApiHistory(_ url: String) {
|
private func addToApiHistory(_ url: String) {
|
||||||
apiHistory.removeAll { $0 == url }
|
apiHistory.removeAll { $0 == url }
|
||||||
apiHistory.insert(url, at: 0)
|
apiHistory.insert(url, at: 0)
|
||||||
@@ -107,6 +234,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除单条 API 历史。
|
||||||
func removeApiHistory(_ url: String) {
|
func removeApiHistory(_ url: String) {
|
||||||
apiHistory.removeAll { $0 == url }
|
apiHistory.removeAll { $0 == url }
|
||||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||||
@@ -155,12 +283,14 @@ class SettingsViewModel: ObservableObject {
|
|||||||
UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
|
UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统计并刷新缓存占用展示(网络缓存 + 图片缓存磁盘占用)。
|
||||||
private func refreshCacheSize() {
|
private func refreshCacheSize() {
|
||||||
let sharedDisk = URLCache.shared.currentDiskUsage
|
let sharedDisk = URLCache.shared.currentDiskUsage
|
||||||
let imageDisk = ImageLoader.shared.cacheUsage.disk
|
let imageDisk = ImageLoader.shared.cacheUsage.disk
|
||||||
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
|
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 格式化字节大小。
|
||||||
private static func formatSize(bytes: Int) -> String {
|
private static func formatSize(bytes: Int) -> String {
|
||||||
let size = max(0, bytes)
|
let size = max(0, bytes)
|
||||||
if size < 1024 * 1024 {
|
if size < 1024 * 1024 {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 通用空状态组件。
|
||||||
|
/// 在收藏、历史等页面复用,统一空页面视觉风格。
|
||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
|
/// SF Symbol 图标名。
|
||||||
let icon: String
|
let icon: String
|
||||||
|
/// 主标题。
|
||||||
let title: String
|
let title: String
|
||||||
|
/// 可选说明文字,为空时不渲染副文案区域。
|
||||||
var message: String? = nil
|
var message: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
// 标题强调当前页面状态,例如“暂无收藏”“暂无播放记录”。
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.foregroundColor(.white.opacity(0.9))
|
.foregroundColor(.white.opacity(0.9))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
|
|
||||||
|
// 副文案用于提供下一步引导,不参与核心逻辑判断。
|
||||||
if let message = message {
|
if let message = message {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航
|
/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
/// 首次配置页点击“最近使用”时,当前要写入的输入框目标。
|
||||||
private enum ApiInputTarget {
|
private enum ApiInputTarget {
|
||||||
case vod
|
case vod
|
||||||
case live
|
case live
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全局状态(配置加载、分栏状态等)。
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
/// 设置页 ViewModel。根视图复用它处理首次配置与多仓库选择。
|
||||||
@StateObject private var settingsVM = SettingsViewModel()
|
@StateObject private var settingsVM = SettingsViewModel()
|
||||||
|
/// 当前主标签索引。
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
/// 预留:控制首次配置页显隐(当前逻辑由 `appState.isConfigLoaded` 驱动)。
|
||||||
@State private var showSetup = false
|
@State private var showSetup = false
|
||||||
|
/// 首次配置页历史回填目标输入框。
|
||||||
@State private var setupInputTarget: ApiInputTarget = .vod
|
@State private var setupInputTarget: ApiInputTarget = .vod
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -21,6 +27,7 @@ struct ContentView: View {
|
|||||||
setupView
|
setupView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay(multiRepoSelectionOverlay)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// 自动加载已保存的配置
|
// 自动加载已保存的配置
|
||||||
@@ -28,6 +35,7 @@ struct ContentView: View {
|
|||||||
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||||
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
|
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
|
||||||
if !savedVodUrl.isEmpty {
|
if !savedVodUrl.isEmpty {
|
||||||
|
// 启动自动恢复配置,避免每次重启都回到首次配置页。
|
||||||
Task {
|
Task {
|
||||||
await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
|
await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
|
||||||
}
|
}
|
||||||
@@ -35,8 +43,34 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var multiRepoSelectionOverlay: some View {
|
||||||
|
// 若配置地址解析出“多仓库入口”,在根层统一弹窗,避免被子页面导航遮挡。
|
||||||
|
if let pending = settingsVM.pendingMultiRepoSelection {
|
||||||
|
SelectionModal(
|
||||||
|
title: "选择\(pending.target.title)仓库",
|
||||||
|
icon: "list.bullet.rectangle.portrait.fill",
|
||||||
|
items: pending.options,
|
||||||
|
selectedItem: nil,
|
||||||
|
itemTitle: { $0.name },
|
||||||
|
onSelect: { option in
|
||||||
|
Task {
|
||||||
|
await settingsVM.selectPendingMultiRepoOption(option)
|
||||||
|
if settingsVM.configSuccess {
|
||||||
|
appState.applyLoadedConfigState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
settingsVM.cancelPendingMultiRepoSelection()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 主界面
|
// MARK: - 主界面
|
||||||
|
|
||||||
|
/// 主体导航容器:iOS 使用 TabView,macOS 使用 NavigationSplitView。
|
||||||
private var mainTabView: some View {
|
private var mainTabView: some View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -105,6 +139,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - 首次配置页面
|
// MARK: - 首次配置页面
|
||||||
|
|
||||||
|
/// 首次启动或未加载配置时的引导页面。
|
||||||
private var setupView: some View {
|
private var setupView: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景装饰
|
// 背景装饰
|
||||||
@@ -321,6 +356,7 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string
|
UIPasteboard.general.string
|
||||||
#else
|
#else
|
||||||
|
// macOS 下通过 NSPasteboard 读取纯文本。
|
||||||
NSPasteboard.general.string(forType: .string)
|
NSPasteboard.general.string(forType: .string)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
|
// 该文件已迁移至 `tvbox/Utils/Extensions.swift`。
|
||||||
|
// 保留此占位文件仅为兼容历史引用,避免旧路径导入时报错。
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 剧集列表组件 - 对应 Android 版 SeriesAdapter
|
/// 剧集列表组件 - 对应 Android 版 SeriesAdapter
|
||||||
struct EpisodeListView: View {
|
struct EpisodeListView: View {
|
||||||
|
/// 当前线路下的全部剧集。
|
||||||
let episodes: [VodInfo.Episode]
|
let episodes: [VodInfo.Episode]
|
||||||
|
/// 外部传入的当前选中集索引(绝对索引)。
|
||||||
let selectedIndex: Int
|
let selectedIndex: Int
|
||||||
|
/// 点击某一集后的回调(返回绝对索引)。
|
||||||
let onSelect: (Int) -> Void
|
let onSelect: (Int) -> Void
|
||||||
|
|
||||||
|
/// 当前分组索引(每 50 集一个分组,避免超长列表影响渲染与选择体验)。
|
||||||
@State private var currentGroup = 0
|
@State private var currentGroup = 0
|
||||||
|
/// 每个分组展示的剧集数量。
|
||||||
private let groupSize = 50
|
private let groupSize = 50
|
||||||
|
/// 自适应列:根据可用宽度自动换行,避免集数过多时右侧被截断。
|
||||||
|
private let gridColumns = [GridItem(.adaptive(minimum: 78), spacing: 10)]
|
||||||
|
|
||||||
|
/// 分组总数,至少为 1,避免空数组时出现 0 组的边界问题。
|
||||||
private var groupCount: Int {
|
private var groupCount: Int {
|
||||||
max(1, (episodes.count + groupSize - 1) / groupSize)
|
max(1, (episodes.count + groupSize - 1) / groupSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前分组对应的切片数据。
|
||||||
private var currentEpisodes: [VodInfo.Episode] {
|
private var currentEpisodes: [VodInfo.Episode] {
|
||||||
let start = currentGroup * groupSize
|
let start = currentGroup * groupSize
|
||||||
let end = min(start + groupSize, episodes.count)
|
let end = min(start + groupSize, episodes.count)
|
||||||
@@ -56,43 +65,40 @@ struct EpisodeListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 剧集网格
|
// 剧集网格:自适应换行,避免横向裁切。
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 10) {
|
||||||
LazyHGrid(rows: [
|
// 这里使用当前组内索引 + 组偏移,换算成全局索引以便外部状态一致。
|
||||||
GridItem(.fixed(44)),
|
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||||
GridItem(.fixed(44))
|
let actualIndex = currentGroup * groupSize + index
|
||||||
], spacing: 10) {
|
Button {
|
||||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
onSelect(actualIndex)
|
||||||
let actualIndex = currentGroup * groupSize + index
|
} label: {
|
||||||
Button {
|
Text(episode.name)
|
||||||
onSelect(actualIndex)
|
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
||||||
} label: {
|
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
||||||
Text(episode.name)
|
.lineLimit(1)
|
||||||
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
.minimumScaleFactor(0.85)
|
||||||
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
.frame(maxWidth: .infinity)
|
||||||
.frame(minWidth: 70)
|
.frame(height: 44)
|
||||||
.frame(height: 44)
|
.background(
|
||||||
.background(
|
ZStack {
|
||||||
ZStack {
|
if actualIndex == selectedIndex {
|
||||||
if actualIndex == selectedIndex {
|
AppTheme.accentGradient
|
||||||
AppTheme.accentGradient
|
} else {
|
||||||
} else {
|
Color.white.opacity(0.05)
|
||||||
Color.white.opacity(0.05)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
)
|
||||||
.overlay(
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
RoundedRectangle(cornerRadius: 10)
|
.overlay(
|
||||||
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
)
|
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
||||||
}
|
)
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
}
|
||||||
.frame(height: 100)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ import SwiftData
|
|||||||
|
|
||||||
/// 收藏页 - 对应 Android 版 CollectActivity
|
/// 收藏页 - 对应 Android 版 CollectActivity
|
||||||
struct FavoritesView: View {
|
struct FavoritesView: View {
|
||||||
|
/// 按更新时间倒序展示收藏,最近收藏/更新的内容靠前。
|
||||||
@Query(sort: \VodCollect.updateTime, order: .reverse)
|
@Query(sort: \VodCollect.updateTime, order: .reverse)
|
||||||
private var favorites: [VodCollect]
|
private var favorites: [VodCollect]
|
||||||
|
/// SwiftData 上下文,用于删除收藏并持久化。
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
/// iOS 下卡片尺寸更紧凑,适配手机竖屏。
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||||
]
|
]
|
||||||
#else
|
#else
|
||||||
|
/// macOS 下卡片适度放大,提升桌面端可读性。
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||||
]
|
]
|
||||||
@@ -25,6 +29,7 @@ struct FavoritesView: View {
|
|||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
|
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
||||||
ForEach(favorites) { item in
|
ForEach(favorites) { item in
|
||||||
NavigationLink(value: movieVideo(from: item)) {
|
NavigationLink(value: movieVideo(from: item)) {
|
||||||
favoriteCard(item)
|
favoriteCard(item)
|
||||||
@@ -50,12 +55,14 @@ struct FavoritesView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
// 通过 Movie.Video 作为路由载体,保持与首页/搜索页一致的详情入口。
|
||||||
.navigationDestination(for: Movie.Video.self) { video in
|
.navigationDestination(for: Movie.Video.self) { video in
|
||||||
DetailView(video: video)
|
DetailView(video: video)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 空列表占位。
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "heart.text.square",
|
icon: "heart.text.square",
|
||||||
@@ -65,6 +72,8 @@ struct FavoritesView: View {
|
|||||||
.padding(40)
|
.padding(40)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 收藏卡片。
|
||||||
|
/// 只展示海报与标题,保持网格信息密度一致。
|
||||||
private func favoriteCard(_ item: VodCollect) -> some View {
|
private func favoriteCard(_ item: VodCollect) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
|
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
|
||||||
@@ -83,6 +92,7 @@ struct FavoritesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将收藏记录映射成详情页可识别的视频对象。
|
||||||
private func movieVideo(from item: VodCollect) -> Movie.Video {
|
private func movieVideo(from item: VodCollect) -> Movie.Video {
|
||||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ import SwiftData
|
|||||||
|
|
||||||
/// 历史记录页 - 对应 Android 版 HistoryActivity
|
/// 历史记录页 - 对应 Android 版 HistoryActivity
|
||||||
struct HistoryView: View {
|
struct HistoryView: View {
|
||||||
|
/// 按最近播放时间倒序展示历史记录。
|
||||||
@Query(sort: \VodRecord.updateTime, order: .reverse)
|
@Query(sort: \VodRecord.updateTime, order: .reverse)
|
||||||
private var records: [VodRecord]
|
private var records: [VodRecord]
|
||||||
|
/// SwiftData 上下文,用于删除单条记录或清空历史。
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
/// iOS 网格配置。
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||||
]
|
]
|
||||||
#else
|
#else
|
||||||
|
/// macOS 网格配置。
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||||
]
|
]
|
||||||
@@ -25,6 +29,7 @@ struct HistoryView: View {
|
|||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
|
// 记录卡片支持跳转详情与右键删除。
|
||||||
ForEach(records) { item in
|
ForEach(records) { item in
|
||||||
NavigationLink(value: movieVideo(from: item)) {
|
NavigationLink(value: movieVideo(from: item)) {
|
||||||
recordCard(item)
|
recordCard(item)
|
||||||
@@ -53,6 +58,7 @@ struct HistoryView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
if !records.isEmpty {
|
if !records.isEmpty {
|
||||||
ToolbarItem(placement: .automatic) {
|
ToolbarItem(placement: .automatic) {
|
||||||
|
// 清空历史使用统一缓存服务,确保行为与其他入口一致。
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
CacheStore.shared.clearHistory(context: modelContext)
|
CacheStore.shared.clearHistory(context: modelContext)
|
||||||
@@ -64,12 +70,14 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 与首页/搜索/收藏共用同一种详情路由模型。
|
||||||
.navigationDestination(for: Movie.Video.self) { video in
|
.navigationDestination(for: Movie.Video.self) { video in
|
||||||
DetailView(video: video)
|
DetailView(video: video)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 无历史时的占位视图。
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "clock.arrow.circlepath",
|
icon: "clock.arrow.circlepath",
|
||||||
@@ -79,6 +87,8 @@ struct HistoryView: View {
|
|||||||
.padding(40)
|
.padding(40)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 历史卡片。
|
||||||
|
/// 除海报和标题外,额外显示播放进度与更新时间,便于快速续播。
|
||||||
private func recordCard(_ item: VodRecord) -> some View {
|
private func recordCard(_ item: VodRecord) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ZStack(alignment: .bottomLeading) {
|
ZStack(alignment: .bottomLeading) {
|
||||||
@@ -115,6 +125,7 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将历史记录转换为详情页的入参模型。
|
||||||
private func movieVideo(from item: VodRecord) -> Movie.Video {
|
private func movieVideo(from item: VodRecord) -> Movie.Video {
|
||||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 视频卡片组件
|
/// 视频卡片组件
|
||||||
struct VodCardView: View {
|
struct VodCardView: View {
|
||||||
|
/// 卡片对应的视频数据。
|
||||||
let video: Movie.Video
|
let video: Movie.Video
|
||||||
|
/// 悬停状态(主要用于 macOS 悬停放大动效)。
|
||||||
@State private var isHovered = false
|
@State private var isHovered = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -44,6 +46,7 @@ struct VodCardView: View {
|
|||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 悬停缩放只增强视觉反馈,不影响点击命中区域。
|
||||||
.scaleEffect(isHovered ? 1.05 : 1.0)
|
.scaleEffect(isHovered ? 1.05 : 1.0)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
|
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
@@ -67,6 +70,7 @@ struct VodCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
||||||
private var placeholderImage: some View {
|
private var placeholderImage: some View {
|
||||||
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
|
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
|
||||||
.fill(Color.white.opacity(0.05))
|
.fill(Color.white.opacity(0.05))
|
||||||
|
|||||||
@@ -6,24 +6,43 @@ import AppKit
|
|||||||
|
|
||||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||||
struct LiveView: View {
|
struct LiveView: View {
|
||||||
|
/// 直播频道与选中状态管理。
|
||||||
@StateObject private var viewModel = LiveViewModel()
|
@StateObject private var viewModel = LiveViewModel()
|
||||||
|
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
/// 系统播放器实例(仅在选择系统内核时使用)。
|
||||||
@State private var avPlayer: AVPlayer?
|
@State private var avPlayer: AVPlayer?
|
||||||
|
/// 直播播放器内核配置(新字段)。
|
||||||
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
|
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
|
||||||
|
/// 兼容旧版本单播放器字段。
|
||||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||||
|
/// 是否展示左侧频道抽屉。
|
||||||
@State private var showChannelDrawer = true
|
@State private var showChannelDrawer = true
|
||||||
|
/// 当前窗口是否处于全屏。
|
||||||
@State private var isWindowFullScreen = false
|
@State private var isWindowFullScreen = false
|
||||||
|
/// 底部频道信息卡最大宽度。
|
||||||
private let currentChannelInfoMaxWidth: CGFloat = 600
|
private let currentChannelInfoMaxWidth: CGFloat = 600
|
||||||
|
/// AVPlayer 状态观察者。
|
||||||
@State private var itemStatusObserver: NSKeyValueObservation?
|
@State private var itemStatusObserver: NSKeyValueObservation?
|
||||||
|
/// 播放失败通知观察者。
|
||||||
@State private var playbackFailedObserver: NSObjectProtocol?
|
@State private var playbackFailedObserver: NSObjectProtocol?
|
||||||
|
/// 播放卡顿通知观察者。
|
||||||
@State private var playbackStalledObserver: NSObjectProtocol?
|
@State private var playbackStalledObserver: NSObjectProtocol?
|
||||||
|
/// 当前频道已失败的线路索引,用于自动切线去重。
|
||||||
@State private var failedSourceIndices: Set<Int> = []
|
@State private var failedSourceIndices: Set<Int> = []
|
||||||
|
/// 当前跟踪的频道 ID(频道切换时重置失败状态)。
|
||||||
@State private var trackedChannelId: String = ""
|
@State private var trackedChannelId: String = ""
|
||||||
|
/// 底部频道信息是否显示。
|
||||||
@State private var showCurrentChannelInfo = true
|
@State private var showCurrentChannelInfo = true
|
||||||
|
/// 自动隐藏频道信息的定时器。
|
||||||
@State private var channelInfoTimer: Timer?
|
@State private var channelInfoTimer: Timer?
|
||||||
|
/// 频道信息自动隐藏延迟(秒)。
|
||||||
private let channelInfoAutoHideDelay: TimeInterval = 3.0
|
private let channelInfoAutoHideDelay: TimeInterval = 3.0
|
||||||
|
/// 用户交互令牌,递增后可通知 VLC 子视图重置自动隐藏逻辑。
|
||||||
@State private var vlcInteractionToken = 0
|
@State private var vlcInteractionToken = 0
|
||||||
|
|
||||||
|
/// 当前实际生效的播放器内核。
|
||||||
|
/// 优先读取直播专用字段,再回退老字段,最后使用默认值。
|
||||||
private var selectedEngine: PlayerEngine {
|
private var selectedEngine: PlayerEngine {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let rawValue: Int
|
let rawValue: Int
|
||||||
@@ -80,6 +99,7 @@ struct LiveView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
// 首次进入时加载频道并展示频道信息卡。
|
||||||
viewModel.loadChannels()
|
viewModel.loadChannels()
|
||||||
wakeUpCurrentChannelInfo()
|
wakeUpCurrentChannelInfo()
|
||||||
}
|
}
|
||||||
@@ -92,6 +112,7 @@ struct LiveView: View {
|
|||||||
wakeUpCurrentChannelInfo()
|
wakeUpCurrentChannelInfo()
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.currentChannel?.id) { _, _ in
|
.onChange(of: viewModel.currentChannel?.id) { _, _ in
|
||||||
|
// 切台后清空失败线路记录,避免复用上个频道的失败状态。
|
||||||
resetFailureTracking(for: viewModel.currentChannel)
|
resetFailureTracking(for: viewModel.currentChannel)
|
||||||
wakeUpCurrentChannelInfo()
|
wakeUpCurrentChannelInfo()
|
||||||
}
|
}
|
||||||
@@ -364,6 +385,7 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reportUserActivity() {
|
private func reportUserActivity() {
|
||||||
|
// 任意交互都刷新显示时间,并触发 VLC 子层同步交互状态。
|
||||||
wakeUpCurrentChannelInfo()
|
wakeUpCurrentChannelInfo()
|
||||||
vlcInteractionToken &+= 1
|
vlcInteractionToken &+= 1
|
||||||
}
|
}
|
||||||
@@ -459,6 +481,7 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupPlayer() {
|
private func cleanupPlayer() {
|
||||||
|
// 先移除观察者再释放播放器,避免悬空回调。
|
||||||
if let observer = playbackFailedObserver {
|
if let observer = playbackFailedObserver {
|
||||||
NotificationCenter.default.removeObserver(observer)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
playbackFailedObserver = nil
|
playbackFailedObserver = nil
|
||||||
@@ -475,6 +498,7 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func observePlaybackFailure(for item: AVPlayerItem) {
|
private func observePlaybackFailure(for item: AVPlayerItem) {
|
||||||
|
// KVO 监听 item 状态失败。
|
||||||
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||||
if observedItem.status == .failed {
|
if observedItem.status == .failed {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -483,6 +507,7 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 播放到结尾失败回调。
|
||||||
playbackFailedObserver = NotificationCenter.default.addObserver(
|
playbackFailedObserver = NotificationCenter.default.addObserver(
|
||||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||||
object: item,
|
object: item,
|
||||||
@@ -491,6 +516,7 @@ struct LiveView: View {
|
|||||||
handlePlaybackFailure(trigger: "item_failed")
|
handlePlaybackFailure(trigger: "item_failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 播放卡顿回调。
|
||||||
playbackStalledObserver = NotificationCenter.default.addObserver(
|
playbackStalledObserver = NotificationCenter.default.addObserver(
|
||||||
forName: .AVPlayerItemPlaybackStalled,
|
forName: .AVPlayerItemPlaybackStalled,
|
||||||
object: item,
|
object: item,
|
||||||
@@ -526,6 +552,7 @@ struct LiveView: View {
|
|||||||
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
|
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
|
||||||
guard failedSourceIndices.count < totalSources else { return false }
|
guard failedSourceIndices.count < totalSources else { return false }
|
||||||
|
|
||||||
|
// 最多轮询 `totalSources` 次,找到一条尚未失败的线路即返回。
|
||||||
for _ in 0..<totalSources {
|
for _ in 0..<totalSources {
|
||||||
viewModel.switchSource()
|
viewModel.switchSource()
|
||||||
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
|
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 搜索页 - 对应 Android 版 SearchActivity
|
/// 搜索页 - 对应 Android 版 SearchActivity
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
|
/// 搜索状态与结果管理。
|
||||||
@StateObject private var viewModel = SearchViewModel()
|
@StateObject private var viewModel = SearchViewModel()
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
/// iOS 卡片网格参数。
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||||
]
|
]
|
||||||
#else
|
#else
|
||||||
|
/// macOS 卡片网格参数。
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||||
]
|
]
|
||||||
@@ -29,6 +32,7 @@ struct SearchView: View {
|
|||||||
} else if !viewModel.results.isEmpty {
|
} else if !viewModel.results.isEmpty {
|
||||||
searchResults
|
searchResults
|
||||||
} else if viewModel.keyword.isEmpty {
|
} else if viewModel.keyword.isEmpty {
|
||||||
|
// 输入为空时显示历史;输入非空但无结果时显示提示文案。
|
||||||
searchHistorySection
|
searchHistorySection
|
||||||
} else if let error = viewModel.errorMessage {
|
} else if let error = viewModel.errorMessage {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -53,6 +57,7 @@ struct SearchView: View {
|
|||||||
|
|
||||||
// MARK: - 搜索栏
|
// MARK: - 搜索栏
|
||||||
|
|
||||||
|
/// 顶部搜索输入区。
|
||||||
private var searchBar: some View {
|
private var searchBar: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -114,6 +119,7 @@ struct SearchView: View {
|
|||||||
|
|
||||||
// MARK: - 搜索结果
|
// MARK: - 搜索结果
|
||||||
|
|
||||||
|
/// 搜索结果网格。
|
||||||
private var searchResults: some View {
|
private var searchResults: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
@@ -134,6 +140,7 @@ struct SearchView: View {
|
|||||||
|
|
||||||
// MARK: - 搜索历史
|
// MARK: - 搜索历史
|
||||||
|
|
||||||
|
/// 搜索历史区域,支持复用历史关键词与一键清空。
|
||||||
private var searchHistorySection: some View {
|
private var searchHistorySection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if !viewModel.searchHistory.isEmpty {
|
if !viewModel.searchHistory.isEmpty {
|
||||||
@@ -183,13 +190,16 @@ struct SearchView: View {
|
|||||||
|
|
||||||
/// 流式布局
|
/// 流式布局
|
||||||
struct FlowLayout: Layout {
|
struct FlowLayout: Layout {
|
||||||
|
/// 子项间距。
|
||||||
var spacing: CGFloat = 8
|
var spacing: CGFloat = 8
|
||||||
|
|
||||||
|
/// 计算整体尺寸。
|
||||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
let result = arrangement(proposal: proposal, subviews: subviews)
|
let result = arrangement(proposal: proposal, subviews: subviews)
|
||||||
return result.size
|
return result.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按计算结果放置子视图。
|
||||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
|
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
|
||||||
for (index, position) in result.positions.enumerated() {
|
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]) {
|
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
||||||
let maxWidth = proposal.width ?? .infinity
|
let maxWidth = proposal.width ?? .infinity
|
||||||
var positions: [CGPoint] = []
|
var positions: [CGPoint] = []
|
||||||
|
|||||||
@@ -335,11 +335,37 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay(multiRepoSelectionOverlay)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var multiRepoSelectionOverlay: some View {
|
||||||
|
if let pending = viewModel.pendingMultiRepoSelection {
|
||||||
|
SelectionModal(
|
||||||
|
title: "选择\(pending.target.title)仓库",
|
||||||
|
icon: "list.bullet.rectangle.portrait.fill",
|
||||||
|
items: pending.options,
|
||||||
|
selectedItem: nil,
|
||||||
|
itemTitle: { $0.name },
|
||||||
|
onSelect: { option in
|
||||||
|
Task {
|
||||||
|
await viewModel.selectPendingMultiRepoOption(option)
|
||||||
|
if viewModel.configSuccess {
|
||||||
|
appState.applyLoadedConfigState()
|
||||||
|
showApiInput = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
viewModel.cancelPendingMultiRepoSelection()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var currentApiBinding: Binding<String> {
|
private var currentApiBinding: Binding<String> {
|
||||||
switch editingApiType {
|
switch editingApiType {
|
||||||
case .vod:
|
case .vod:
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
/// 应用入口。
|
||||||
|
/// 负责初始化 SwiftData 容器,并将全局状态 `AppState` 注入到根视图。
|
||||||
@main
|
@main
|
||||||
struct tvboxApp: App {
|
struct tvboxApp: App {
|
||||||
|
/// 全局运行时状态(配置加载状态、当前源、分栏布局状态等)。
|
||||||
@StateObject private var appState = AppState()
|
@StateObject private var appState = AppState()
|
||||||
|
|
||||||
|
/// 全局共享的 SwiftData 容器。
|
||||||
|
/// 这里显式声明 Schema,确保收藏/历史/缓存三类数据使用同一持久化存储。
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
VodCollect.self,
|
VodCollect.self,
|
||||||
@@ -19,6 +24,7 @@ struct tvboxApp: App {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// 应用窗口与根视图。
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@@ -31,20 +37,32 @@ struct tvboxApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 应用级状态容器。
|
||||||
|
/// 统一管理配置加载与页面共享状态,避免在各页面重复拉取配置。
|
||||||
@MainActor
|
@MainActor
|
||||||
class AppState: ObservableObject {
|
class AppState: ObservableObject {
|
||||||
|
/// 解析后的配置单例,提供给所有页面与 ViewModel 使用。
|
||||||
@Published var apiConfig = ApiConfig.shared
|
@Published var apiConfig = ApiConfig.shared
|
||||||
|
/// 配置是否已经成功加载。控制 `ContentView` 显示主界面或首次配置页。
|
||||||
@Published var isConfigLoaded = false
|
@Published var isConfigLoaded = false
|
||||||
|
/// 当前首页选中的视频源 key(用于跨页面同步)。
|
||||||
@Published var currentSourceKey: String = ""
|
@Published var currentSourceKey: String = ""
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
/// macOS 三栏布局可见性(侧栏/内容/详情)。
|
||||||
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all
|
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all
|
||||||
|
/// 进入播放器全屏前的分栏状态快照,用于退出全屏后恢复。
|
||||||
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
|
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// 仅提供点播地址时的快捷加载入口(直播地址默认与点播一致)。
|
||||||
func loadConfig(url: String) async {
|
func loadConfig(url: String) async {
|
||||||
await loadConfig(vodUrl: url, liveUrl: nil)
|
await loadConfig(vodUrl: url, liveUrl: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载点播与直播配置。
|
||||||
|
/// - Parameters:
|
||||||
|
/// - vodUrl: 点播配置地址
|
||||||
|
/// - liveUrl: 直播配置地址;为空时自动回退到点播地址
|
||||||
func loadConfig(vodUrl: String, liveUrl: String?) async {
|
func loadConfig(vodUrl: String, liveUrl: String?) async {
|
||||||
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -59,12 +77,15 @@ class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将“配置已加载”的统一状态写回全局。
|
||||||
|
/// 该方法会在设置页和启动自动加载两个入口中复用。
|
||||||
func applyLoadedConfigState() {
|
func applyLoadedConfigState() {
|
||||||
isConfigLoaded = true
|
isConfigLoaded = true
|
||||||
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
|
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
/// 进入播放器全屏时隐藏侧栏,减少播放器可视区域干扰。
|
||||||
func enterPlayerFullScreen() {
|
func enterPlayerFullScreen() {
|
||||||
if splitViewVisibilityBeforePlayerFullScreen == nil {
|
if splitViewVisibilityBeforePlayerFullScreen == nil {
|
||||||
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
|
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
|
||||||
@@ -72,6 +93,7 @@ class AppState: ObservableObject {
|
|||||||
splitViewVisibility = .detailOnly
|
splitViewVisibility = .detailOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 退出播放器全屏时恢复之前的分栏状态。
|
||||||
func exitPlayerFullScreen() {
|
func exitPlayerFullScreen() {
|
||||||
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
|
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
|
||||||
splitViewVisibility = previous
|
splitViewVisibility = previous
|
||||||
|
|||||||
Reference in New Issue
Block a user