Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc1a010e05 | ||
|
|
e58322339f | ||
|
|
ee8c71dcd4 | ||
|
|
99a8cc799b | ||
|
|
54de47deb1 | ||
|
|
3f302f83b3 | ||
|
|
a89ce6eb69 |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>development</string> <!-- 可选: ad-hoc, enterprise, app-store, development -->
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string></string> <!-- 此处填写您的 Apple Team ID -->
|
||||
<key>thinning</key>
|
||||
<string><none></string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -99,6 +99,23 @@ xcodegen generate
|
||||
|
||||
---
|
||||
|
||||
### iOS 版本打包
|
||||
|
||||
1. 确保已安装通用依赖:
|
||||
- Xcode 15+
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (可选,用于生成工程)
|
||||
|
||||
2. 运行打包脚本:
|
||||
```bash
|
||||
chmod +x package_ios.sh
|
||||
./package_ios.sh
|
||||
```
|
||||
|
||||
3. **注意**:
|
||||
- 脚本默认生成 `.xcarchive` 文件。
|
||||
- 如需自动生成 `.ipa`,请在 `ExportOptions.plist` 中填写您的 `teamID`。
|
||||
- 您也可以在 Xcode 中打开 `tvbox.xcodeproj`,选择 `tvbox` scheme,然后点击 `Product > Archive` 手动打包。
|
||||
|
||||
## 📦 macOS 独立打包
|
||||
|
||||
项目已内置自动化打包脚本 `package_mac.sh`。当您需要分发 macOS 版本时,直接运行即可自动清理缓存、构建并生成免安装的 `.dmg` 拖拽式安装包。
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 设置工作目录为脚本所在目录
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "清理构建目录..."
|
||||
rm -rf build
|
||||
rm -rf tvbox.xcarchive
|
||||
rm -f TVBox.ipa
|
||||
|
||||
SCHEME="tvbox"
|
||||
CONFIGURATION="Release"
|
||||
ARCHIVE_PATH="build/tvbox.xcarchive"
|
||||
EXPORT_PATH="build/exported"
|
||||
EXPORT_OPTIONS="ExportOptions.plist"
|
||||
|
||||
echo "开始构建 iOS Archive..."
|
||||
xcodebuild archive \
|
||||
-project tvbox.xcodeproj \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath "$ARCHIVE_PATH"
|
||||
|
||||
if [ -f "$EXPORT_OPTIONS" ]; then
|
||||
echo "发现 $EXPORT_OPTIONS,尝试导出 IPA..."
|
||||
mkdir -p "$EXPORT_PATH"
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_OPTIONS" \
|
||||
-exportPath "$EXPORT_PATH" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
# 查找生成的 .ipa 文件并移动到根目录
|
||||
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -n 1)
|
||||
if [ -n "$IPA_FILE" ]; then
|
||||
cp "$IPA_FILE" ./TVBox.ipa
|
||||
echo "✅ 打包完成!生成文件: TVBox.ipa"
|
||||
else
|
||||
echo "⚠️ 未能在导出目录中找到 .ipa 文件。"
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ 未找到 $EXPORT_OPTIONS,仅生成 Archive。"
|
||||
echo "✅ 构建完成!Archive 路径: $ARCHIVE_PATH"
|
||||
echo "您可以打开 Xcode 使用 Distribute App 手动导出 IPA。"
|
||||
fi
|
||||
@@ -214,3 +214,31 @@ struct AppConfigData: Codable {
|
||||
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 {
|
||||
/// 以分组名作为稳定标识,便于 SwiftUI 列表 diff。
|
||||
var id: String { groupName }
|
||||
/// 分组名称(如“央视”“卫视”)。
|
||||
var groupName: String = ""
|
||||
/// 分组在列表中的顺序索引。
|
||||
var groupIndex: Int = 0
|
||||
/// 分组下频道列表。
|
||||
var channels: [LiveChannelItem] = []
|
||||
/// 是否为加密分组(当前实现仅保留字段,未启用密码校验)。
|
||||
var isPassword: Bool = false
|
||||
|
||||
init(groupName: String = "", groupIndex: Int = 0) {
|
||||
@@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable {
|
||||
|
||||
/// 直播频道
|
||||
struct LiveChannelItem: Codable, Identifiable, Hashable {
|
||||
/// 频道标识由名称+索引组成,规避同名频道冲突。
|
||||
var id: String { "\(channelName)_\(channelIndex)" }
|
||||
/// 频道名。
|
||||
var channelName: String = ""
|
||||
/// 频道在分组内的顺序索引。
|
||||
var channelIndex: Int = 0
|
||||
/// 多线路播放地址。
|
||||
var channelUrls: [String] = []
|
||||
/// 当前选中的线路索引。
|
||||
var sourceIndex: Int = 0
|
||||
/// 可用线路总数。
|
||||
var sourceNum: Int { channelUrls.count }
|
||||
/// 台标地址(预留)。
|
||||
var logo: String = ""
|
||||
|
||||
init(channelName: String = "", channelIndex: Int = 0) {
|
||||
@@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable {
|
||||
self.channelIndex = channelIndex
|
||||
}
|
||||
|
||||
/// 当前线路对应的播放地址。
|
||||
/// 当索引越界时兜底返回第一条线路,避免直接播放失败。
|
||||
var currentUrl: String? {
|
||||
guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first }
|
||||
return channelUrls[sourceIndex]
|
||||
}
|
||||
|
||||
/// 轮换到下一条线路。
|
||||
mutating func nextSource() {
|
||||
if channelUrls.count > 0 {
|
||||
sourceIndex = (sourceIndex + 1) % channelUrls.count
|
||||
@@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable {
|
||||
var endTime: String = ""
|
||||
var index: Int = 0
|
||||
|
||||
/// 根据 `HH:mm` 时间段判断节目是否正在播出。
|
||||
var isLive: Bool {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm"
|
||||
@@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable {
|
||||
/// EPG 日期分组
|
||||
struct LiveEpgDate: Codable, Identifiable, Hashable {
|
||||
var id: String { datePresent }
|
||||
var datePresent: String = "" // 显示日期
|
||||
var date: String = "" // 查询日期
|
||||
/// 供 UI 展示的日期文案。
|
||||
var datePresent: String = ""
|
||||
/// 供接口查询的原始日期值。
|
||||
var date: String = ""
|
||||
/// 在日期列表中的位置索引。
|
||||
var index: Int = 0
|
||||
/// 是否被当前 UI 选中。
|
||||
var isSelected: Bool = false
|
||||
}
|
||||
|
||||
@@ -2,28 +2,47 @@ import Foundation
|
||||
|
||||
/// 电影/视频数据模型 - 对应 Android 版 Movie.java
|
||||
struct Movie: Codable {
|
||||
/// 列表数据主体。
|
||||
var videoList: [Video] = []
|
||||
/// 总页数。
|
||||
var pagecount: Int = 0
|
||||
/// 当前页码。
|
||||
var page: Int = 0
|
||||
/// 总条数。
|
||||
var total: Int = 0
|
||||
/// 每页条数。
|
||||
var limit: Int = 0
|
||||
|
||||
/// 单个视频条目
|
||||
struct Video: Codable, Identifiable, Hashable {
|
||||
/// 视频唯一 ID(接口可能返回 Int 或 String,见自定义解码)。
|
||||
var id: String
|
||||
/// 片名。
|
||||
var name: String = ""
|
||||
/// 海报地址。
|
||||
var pic: String = ""
|
||||
var note: String = "" // 备注(如"更新至第20集")
|
||||
/// 备注(如“更新至第20集”)。
|
||||
var note: String = ""
|
||||
/// 年份。
|
||||
var year: String = ""
|
||||
/// 地区。
|
||||
var area: String = ""
|
||||
var type: String = "" // 类型/分类名
|
||||
/// 类型/分类名。
|
||||
var type: String = ""
|
||||
/// 导演。
|
||||
var director: String = ""
|
||||
/// 演员。
|
||||
var actor: String = ""
|
||||
var des: String = "" // 简介
|
||||
var sourceKey: String = "" // 来源站点 key
|
||||
var tid: String = "" // 分类 ID
|
||||
var last: String = "" // 最后更新
|
||||
var dt: String = "" // 日期
|
||||
/// 简介。
|
||||
var des: String = ""
|
||||
/// 来源站点 key,用于跨源隔离收藏与历史。
|
||||
var sourceKey: String = ""
|
||||
/// 分类 ID。
|
||||
var tid: String = ""
|
||||
/// 最后更新时间。
|
||||
var last: String = ""
|
||||
/// 播放来源信息(部分接口会复用该字段)。
|
||||
var dt: String = ""
|
||||
|
||||
init(id: String = UUID().uuidString, name: String = "", pic: String = "",
|
||||
note: String = "", sourceKey: String = "") {
|
||||
@@ -51,6 +70,7 @@ struct Movie: Codable {
|
||||
case sourceKey
|
||||
}
|
||||
|
||||
/// 自定义解码以兼容多源字段类型差异(如 `vod_id` / `type_id` 可能是 Int 或 String)。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// 支持 String 或 Int 类型的 id
|
||||
|
||||
@@ -2,13 +2,18 @@ import Foundation
|
||||
|
||||
/// 分类排序模型 - 对应 Android 版 MovieSort.java
|
||||
struct MovieSort: Codable {
|
||||
/// 分类列表(包含首页推荐、影视分类等)。
|
||||
var sortList: [SortData] = []
|
||||
|
||||
/// 单个分类数据
|
||||
struct SortData: Codable, Identifiable, Hashable {
|
||||
/// 分类唯一标识(接口字段通常为 type_id)。
|
||||
var id: String
|
||||
/// 分类显示名。
|
||||
var name: String = ""
|
||||
/// 标记位(不同源可定义不同语义,常用于首页/推荐标识)。
|
||||
var flag: String = ""
|
||||
/// 分类下可选筛选项(年份、地区、类型等)。
|
||||
var filters: [SortFilter] = []
|
||||
|
||||
init(id: String = "", name: String = "", flag: String = "") {
|
||||
@@ -17,6 +22,8 @@ struct MovieSort: Codable {
|
||||
self.flag = flag
|
||||
}
|
||||
|
||||
/// 生成首页推荐占位分类。
|
||||
/// 该分类不走常规分类接口,直接渲染首页推荐列表。
|
||||
static func home() -> SortData {
|
||||
SortData(id: "home", name: "推荐", flag: "1")
|
||||
}
|
||||
@@ -24,13 +31,18 @@ struct MovieSort: Codable {
|
||||
|
||||
/// 筛选条件
|
||||
struct SortFilter: Codable, Hashable {
|
||||
/// 接口参数键,例如 `year`、`area`。
|
||||
var key: String = ""
|
||||
/// UI 展示名称。
|
||||
var name: String = ""
|
||||
/// 可选值集合。
|
||||
var values: [SortFilterValue] = []
|
||||
|
||||
struct SortFilterValue: Codable, Hashable {
|
||||
var n: String = "" // 显示名
|
||||
var v: String = "" // 值
|
||||
/// 展示名。
|
||||
var n: String = ""
|
||||
/// 真实参数值。
|
||||
var v: String = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import Foundation
|
||||
|
||||
/// 播放器引擎类型
|
||||
enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
/// 系统 AVPlayer 内核。
|
||||
case system = 0
|
||||
/// VLC 内核(需编译时可导入 VLCKitSPM)。
|
||||
case vlc = 10
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// UI 展示名。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .system:
|
||||
@@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前构建产物是否包含 VLC 能力。
|
||||
static var isVLCAvailable: Bool {
|
||||
#if canImport(VLCKitSPM)
|
||||
return true
|
||||
@@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 实际可供用户选择的播放器列表。
|
||||
/// 当 VLC 不可用时,仅暴露系统播放器,避免无效配置。
|
||||
static var availableEngines: [PlayerEngine] {
|
||||
var engines: [PlayerEngine] = [.system]
|
||||
if isVLCAvailable {
|
||||
@@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
return engines
|
||||
}
|
||||
|
||||
/// 从持久化值恢复播放器选项,并自动兜底到可用引擎。
|
||||
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
|
||||
guard let engine = PlayerEngine(rawValue: rawValue) else {
|
||||
return .system
|
||||
@@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
|
||||
/// 视频解码模式
|
||||
enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||
/// 自动策略,优先硬解,失败时用户可切换。
|
||||
case auto = 0
|
||||
/// 强制硬解。
|
||||
case hardware = 1
|
||||
/// 强制软解。
|
||||
case software = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
@@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
/// VLC 媒体选项
|
||||
/// 返回 `avcodec-hw` 对应的值。
|
||||
var vlcHardwareDecodeOption: String? {
|
||||
switch self {
|
||||
case .auto:
|
||||
@@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||
|
||||
/// VLC 缓冲策略
|
||||
enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
||||
/// 低延迟优先,适合直播但容错较低。
|
||||
case lowLatency = 0
|
||||
/// 兼顾延迟与稳定性,作为默认策略。
|
||||
case balanced = 1
|
||||
/// 稳定流畅优先,允许更高缓冲。
|
||||
case smooth = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
@@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
||||
self == .lowLatency
|
||||
}
|
||||
|
||||
/// 根据直播/点播场景输出三类缓存值(单位毫秒)。
|
||||
/// - Parameters:
|
||||
/// - isLive: 是否直播场景
|
||||
/// - Returns: network/live/file 三类缓存配置
|
||||
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
|
||||
switch self {
|
||||
case .lowLatency:
|
||||
@@ -122,12 +140,12 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
||||
if isLive {
|
||||
return (network: 2600, live: 2600, file: 3200)
|
||||
}
|
||||
return (network: 3800, live: 3200, file: 4400)
|
||||
return (network: 6000, live: 5000, file: 6400)
|
||||
case .smooth:
|
||||
if isLive {
|
||||
return (network: 4200, live: 4200, file: 5200)
|
||||
}
|
||||
return (network: 5600, live: 4800, file: 6400)
|
||||
return (network: 8000, live: 6800, file: 8400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,37 @@ import Foundation
|
||||
|
||||
/// 视频源站点配置 - 对应 Android 版 SourceBean.java
|
||||
struct SourceBean: Codable, Identifiable, Hashable {
|
||||
/// 以源 key 作为稳定标识。
|
||||
var id: String { key }
|
||||
|
||||
/// 源唯一键。
|
||||
let key: String
|
||||
/// 源显示名。
|
||||
let name: String
|
||||
/// 源接口地址。
|
||||
let api: String
|
||||
let searchable: Int // 0:关闭搜索 1:启用搜索
|
||||
let filterable: Int // 0:首页不可选 1:首页可选
|
||||
let playerType: Int // 0:系统 1:IJK 2:EXO
|
||||
let type: Int // 0:xml 1:json 3:jar 4:remote
|
||||
/// 搜索开关:0 关闭,1 开启。
|
||||
let searchable: Int
|
||||
/// 是否允许出现在首页分类:0 不可选,1 可选。
|
||||
let filterable: Int
|
||||
/// 快速搜索开关: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?
|
||||
|
||||
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) {
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.api = api
|
||||
self.searchable = searchable
|
||||
self.filterable = filterable
|
||||
self.quickSearch = quickSearch
|
||||
self.playerType = playerType
|
||||
self.type = type
|
||||
self.ext = ext
|
||||
@@ -28,6 +40,7 @@ struct SourceBean: Codable, Identifiable, Hashable {
|
||||
|
||||
var isSearchable: Bool { searchable == 1 }
|
||||
var isFilterable: Bool { filterable == 1 }
|
||||
var isQuickSearchEnabled: Bool { quickSearch == 1 }
|
||||
|
||||
/// 是否在 Swift 版中受支持(type=3 为 JAR/Spider,需要 Java 运行时,暂不支持)
|
||||
var isSupportedInSwift: Bool {
|
||||
|
||||
@@ -2,16 +2,27 @@ import Foundation
|
||||
|
||||
/// 视频详情模型 - 对应 Android 版 VodInfo.java
|
||||
struct VodInfo: Codable, Identifiable {
|
||||
/// 视频唯一 ID。
|
||||
var id: String
|
||||
/// 标题。
|
||||
var name: String = ""
|
||||
/// 海报地址。
|
||||
var pic: String = ""
|
||||
/// 备注(更新状态等)。
|
||||
var note: String = ""
|
||||
/// 年份。
|
||||
var year: String = ""
|
||||
/// 地区。
|
||||
var area: String = ""
|
||||
/// 类型名。
|
||||
var typeName: String = ""
|
||||
/// 导演。
|
||||
var director: String = ""
|
||||
/// 演员。
|
||||
var actor: String = ""
|
||||
/// 简介。
|
||||
var des: String = ""
|
||||
/// 来源站点 key。
|
||||
var sourceKey: String = ""
|
||||
|
||||
/// 播放来源(线路)列表
|
||||
@@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable {
|
||||
/// key: flag名称, value: 剧集列表
|
||||
var playUrlMap: [String: [Episode]] = [:]
|
||||
|
||||
var playFlag: String = "" // 当前选中线路
|
||||
var playIndex: Int = 0 // 当前播放剧集索引
|
||||
/// 当前选中线路。
|
||||
var playFlag: String = ""
|
||||
/// 当前播放剧集索引。
|
||||
var playIndex: Int = 0
|
||||
|
||||
/// 单集信息
|
||||
struct Episode: Codable, Identifiable, Hashable {
|
||||
var id: String { name }
|
||||
/// 集标题。
|
||||
let name: String
|
||||
/// 集播放地址。
|
||||
let url: String
|
||||
|
||||
init(name: String, url: String) {
|
||||
@@ -48,7 +63,7 @@ struct VodInfo: Codable, Identifiable {
|
||||
info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
info.sourceKey = video.sourceKey
|
||||
|
||||
// 解析播放列表
|
||||
// 解析播放列表:
|
||||
// playFrom 格式: "线路1$$$线路2$$$线路3"
|
||||
// playUrl 格式: "第1集$url1#第2集$url2$$$第1集$url3#第2集$url4"
|
||||
let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty }
|
||||
@@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable {
|
||||
return info
|
||||
}
|
||||
|
||||
/// 当前线路下的剧集。
|
||||
var currentEpisodes: [Episode] {
|
||||
playUrlMap[playFlag] ?? []
|
||||
}
|
||||
|
||||
/// 当前线路 + 当前索引对应的剧集对象。
|
||||
var currentEpisode: Episode? {
|
||||
let eps = currentEpisodes
|
||||
guard playIndex >= 0, playIndex < eps.count else { return nil }
|
||||
|
||||
@@ -5,18 +5,26 @@ import SwiftData
|
||||
|
||||
/// 单部剧的续播状态
|
||||
struct VodPlaybackState: Codable {
|
||||
/// 当前播放线路标识。
|
||||
var flag: String
|
||||
/// 剧集索引。
|
||||
var episodeIndex: Int
|
||||
/// 播放进度(秒)。
|
||||
var progressSeconds: Double
|
||||
}
|
||||
|
||||
/// 视频收藏
|
||||
@Model
|
||||
final class VodCollect {
|
||||
/// 视频 ID(与 sourceKey 组成唯一语义键)。
|
||||
var vodId: String = ""
|
||||
/// 片名。
|
||||
var vodName: String = ""
|
||||
/// 海报地址。
|
||||
var vodPic: String = ""
|
||||
/// 来源站点 key。
|
||||
var sourceKey: String = ""
|
||||
/// 最近更新时间(收藏创建/刷新时间)。
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
|
||||
@@ -31,12 +39,19 @@ final class VodCollect {
|
||||
/// 播放历史记录
|
||||
@Model
|
||||
final class VodRecord {
|
||||
/// 视频 ID。
|
||||
var vodId: String = ""
|
||||
/// 片名。
|
||||
var vodName: String = ""
|
||||
/// 海报地址。
|
||||
var vodPic: String = ""
|
||||
/// 来源站点 key。
|
||||
var sourceKey: String = ""
|
||||
var playNote: String = "" // 如 "第5集 03:45"
|
||||
var dataJson: String = "" // 续播状态 JSON(VodPlaybackState)
|
||||
/// 播放标记,如“第5集 03:45”。
|
||||
var playNote: String = ""
|
||||
/// 续播状态 JSON(`VodPlaybackState` 编码结果)。
|
||||
var dataJson: String = ""
|
||||
/// 最近播放时间。
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
|
||||
@@ -52,8 +67,11 @@ final class VodRecord {
|
||||
/// 通用缓存
|
||||
@Model
|
||||
final class CacheItem {
|
||||
/// 唯一缓存键。
|
||||
@Attribute(.unique) var key: String = ""
|
||||
/// 缓存值(字符串形式)。
|
||||
var value: String = ""
|
||||
/// 更新时间。
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(key: String, value: String) {
|
||||
@@ -95,6 +113,7 @@ actor CacheStore {
|
||||
|
||||
@MainActor
|
||||
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
|
||||
// 收藏以 (vodId, sourceKey) 为业务唯一键,删除时也按该组合匹配。
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
@@ -151,6 +170,7 @@ actor CacheStore {
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// 读取续播状态(若无记录或 JSON 无法解码则返回 `nil`)。
|
||||
@MainActor
|
||||
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
|
||||
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else {
|
||||
@@ -171,6 +191,7 @@ actor CacheStore {
|
||||
|
||||
@MainActor
|
||||
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
|
||||
// 历史记录同样以 (vodId, sourceKey) 作为业务键。
|
||||
let predicate = #Predicate<VodRecord> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
@@ -185,6 +206,7 @@ actor CacheStore {
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串反序列化续播状态。
|
||||
private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(VodPlaybackState.self, from: data)
|
||||
|
||||
+471
-57
@@ -5,6 +5,15 @@ import Foundation
|
||||
@MainActor
|
||||
class ApiConfig: ObservableObject {
|
||||
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 homeSourceBean: SourceBean?
|
||||
@@ -13,6 +22,7 @@ class ApiConfig: ObservableObject {
|
||||
@Published var dohList: [(name: String, url: String)] = []
|
||||
@Published var isLoaded: Bool = false
|
||||
@Published var configUrl: String = ""
|
||||
@Published var liveConfigUrl: String = ""
|
||||
@Published var wallpaper: String = ""
|
||||
|
||||
private let network = NetworkManager.shared
|
||||
@@ -21,9 +31,81 @@ class ApiConfig: ObservableObject {
|
||||
|
||||
/// 加载远程配置
|
||||
func loadConfig(from apiUrl: String) async throws {
|
||||
self.configUrl = apiUrl
|
||||
try await loadConfigs(vodApiUrl: apiUrl, liveApiUrl: apiUrl)
|
||||
}
|
||||
|
||||
let jsonStr = try await network.getString(from: apiUrl)
|
||||
/// 分别加载点播配置和直播配置
|
||||
func loadConfigs(vodApiUrl: String, liveApiUrl: String) async throws {
|
||||
let trimmedVod = vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedLive = liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedVod.isEmpty else {
|
||||
throw ConfigError.parseError("点播接口地址不能为空")
|
||||
}
|
||||
|
||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||
self.configUrl = trimmedVod
|
||||
self.liveConfigUrl = resolvedLive
|
||||
|
||||
if trimmedVod == resolvedLive {
|
||||
let configResult = try await fetchConfig(from: trimmedVod)
|
||||
parseConfig(
|
||||
configResult.config,
|
||||
apiUrl: configResult.loadedFrom,
|
||||
includeSources: true,
|
||||
includeLive: true
|
||||
)
|
||||
} else {
|
||||
let vodConfig = try await fetchConfig(from: trimmedVod)
|
||||
parseConfig(
|
||||
vodConfig.config,
|
||||
apiUrl: vodConfig.loadedFrom,
|
||||
includeSources: true,
|
||||
includeLive: false
|
||||
)
|
||||
|
||||
let liveConfig = try await fetchConfig(from: resolvedLive)
|
||||
parseConfig(
|
||||
liveConfig.config,
|
||||
apiUrl: liveConfig.loadedFrom,
|
||||
includeSources: false,
|
||||
includeLive: true
|
||||
)
|
||||
}
|
||||
|
||||
self.isLoaded = true
|
||||
}
|
||||
|
||||
private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
|
||||
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 需要手动处理)
|
||||
let cleanedJson = Self.stripJsonComments(jsonStr)
|
||||
@@ -32,8 +114,283 @@ class ApiConfig: ObservableObject {
|
||||
throw ConfigError.parseError("无法解析配置数据")
|
||||
}
|
||||
|
||||
let config = try JSONDecoder().decode(AppConfigData.self, from: data)
|
||||
parseConfig(config, apiUrl: apiUrl)
|
||||
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 配置文件格式
|
||||
@@ -96,60 +453,117 @@ class ApiConfig: ObservableObject {
|
||||
return line
|
||||
}
|
||||
|
||||
/// 解析配置数据
|
||||
private func parseConfig(_ config: AppConfigData, apiUrl: String) {
|
||||
// 解析站点列表
|
||||
var sources: [SourceBean] = []
|
||||
if let sites = config.sites {
|
||||
for site in sites {
|
||||
let bean = SourceBean(
|
||||
key: site.key ?? UUID().uuidString,
|
||||
name: site.name ?? "未命名",
|
||||
api: site.api ?? "",
|
||||
searchable: site.searchable?.value ?? 1,
|
||||
filterable: site.filterable?.value ?? 1,
|
||||
playerType: site.playerType?.value ?? 0,
|
||||
type: site.type?.value ?? 1,
|
||||
ext: site.ext?.stringValue
|
||||
/// 仅探测“多仓库入口”并返回可选项。
|
||||
/// 返回 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
|
||||
)
|
||||
sources.append(bean)
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/// 解析配置数据
|
||||
private func parseConfig(
|
||||
_ config: AppConfigData,
|
||||
apiUrl: String,
|
||||
includeSources: Bool,
|
||||
includeLive: Bool
|
||||
) {
|
||||
if includeSources {
|
||||
// 解析站点列表
|
||||
var sources: [SourceBean] = []
|
||||
if let sites = config.sites {
|
||||
for site in sites {
|
||||
let bean = SourceBean(
|
||||
key: site.key ?? UUID().uuidString,
|
||||
name: site.name ?? "未命名",
|
||||
api: site.api ?? "",
|
||||
searchable: site.searchable?.value ?? 1,
|
||||
filterable: site.filterable?.value ?? 1,
|
||||
quickSearch: site.quickSearch?.value ?? 0,
|
||||
playerType: site.playerType?.value ?? 0,
|
||||
type: site.type?.value ?? 1,
|
||||
ext: site.ext?.stringValue
|
||||
)
|
||||
sources.append(bean)
|
||||
}
|
||||
}
|
||||
self.sourceBeanList = sources
|
||||
|
||||
// 设置默认主页源:优先选择 Swift 支持的源
|
||||
if let saved = UserDefaults.standard.string(forKey: HawkConfig.HOME_API),
|
||||
let found = sources.first(where: { $0.key == saved }) {
|
||||
self.homeSourceBean = found
|
||||
} else {
|
||||
// 优先选择支持的源(type 0/1/4),跳过 type=3 (JAR)
|
||||
self.homeSourceBean = sources.first(where: { $0.isSupportedInSwift }) ?? sources.first
|
||||
}
|
||||
|
||||
// 解析解析器列表
|
||||
if let parses = config.parses {
|
||||
self.parseBeanList = parses.map { p in
|
||||
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 DoH 列表
|
||||
if let dohs = config.doh {
|
||||
self.dohList = dohs.compactMap { d in
|
||||
guard let name = d.name, let url = d.url else { return nil }
|
||||
return (name: name, url: url)
|
||||
}
|
||||
}
|
||||
|
||||
// 壁纸
|
||||
self.wallpaper = config.wallpaper ?? ""
|
||||
}
|
||||
|
||||
if includeLive {
|
||||
if let lives = config.lives {
|
||||
parseLives(lives, apiUrl: apiUrl)
|
||||
} else {
|
||||
liveChannelGroupList = []
|
||||
}
|
||||
}
|
||||
self.sourceBeanList = sources
|
||||
|
||||
// 设置默认主页源:优先选择 Swift 支持的源
|
||||
if let saved = UserDefaults.standard.string(forKey: HawkConfig.HOME_API),
|
||||
let found = sources.first(where: { $0.key == saved }) {
|
||||
self.homeSourceBean = found
|
||||
} else {
|
||||
// 优先选择支持的源(type 0/1/4),跳过 type=3 (JAR)
|
||||
self.homeSourceBean = sources.first(where: { $0.isSupportedInSwift }) ?? sources.first
|
||||
}
|
||||
|
||||
// 解析解析器列表
|
||||
if let parses = config.parses {
|
||||
self.parseBeanList = parses.map { p in
|
||||
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 DoH 列表
|
||||
if let dohs = config.doh {
|
||||
self.dohList = dohs.compactMap { d in
|
||||
guard let name = d.name, let url = d.url else { return nil }
|
||||
return (name: name, url: url)
|
||||
}
|
||||
}
|
||||
|
||||
// 壁纸
|
||||
self.wallpaper = config.wallpaper ?? ""
|
||||
|
||||
// 解析直播源
|
||||
if let lives = config.lives {
|
||||
parseLives(lives, apiUrl: apiUrl)
|
||||
}
|
||||
|
||||
self.isLoaded = true
|
||||
}
|
||||
|
||||
/// 解析直播列表
|
||||
@@ -353,13 +767,13 @@ class ApiConfig: ObservableObject {
|
||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
if let url = URL(string: trimmed), url.scheme != nil {
|
||||
return trimmed
|
||||
return Self.normalizeConfigUrl(trimmed)
|
||||
}
|
||||
guard let baseUrl = URL(string: baseConfigUrl),
|
||||
let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else {
|
||||
return trimmed
|
||||
}
|
||||
return resolved.absoluteString
|
||||
return Self.normalizeConfigUrl(resolved.absoluteString)
|
||||
}
|
||||
|
||||
private static func uniqueLiveUrls(_ urls: [String]) -> [String] {
|
||||
|
||||
@@ -2,11 +2,42 @@ import Foundation
|
||||
|
||||
/// 网络请求封装 - 对应 Android 版 OkGo
|
||||
class 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
|
||||
/// JSON 解码器。
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
/// 私有初始化,防止外部创建多个请求管理器。
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
@@ -35,8 +66,9 @@ class NetworkManager {
|
||||
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
|
||||
@@ -59,14 +91,59 @@ class NetworkManager {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
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 {
|
||||
case invalidURL(String)
|
||||
case invalidResponse
|
||||
case httpError(Int)
|
||||
case decodingError(String)
|
||||
|
||||
/// 面向 UI/日志的错误描述。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let url): return "无效的URL: \(url)"
|
||||
|
||||
@@ -325,9 +325,10 @@ class SourceService {
|
||||
url = "\(api)?wd=\(encodedKeyword)"
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口
|
||||
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
|
||||
url = api.contains("?")
|
||||
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false"
|
||||
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false"
|
||||
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
|
||||
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
@@ -345,7 +346,8 @@ class SourceService {
|
||||
}
|
||||
|
||||
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 解析
|
||||
|
||||
/// 解析 extend 参数(对应 Android 端 getFixUrl)
|
||||
|
||||
@@ -11,6 +11,8 @@ struct HawkConfig {
|
||||
static let HOME_API = "home_api"
|
||||
static let HOME_REC = "home_rec"
|
||||
static let PLAY_TYPE = "play_type"
|
||||
static let PLAY_TYPE_VOD = "play_type_vod"
|
||||
static let PLAY_TYPE_LIVE = "play_type_live"
|
||||
static let DOH_URL = "doh_url"
|
||||
static let SEARCH_VIEW = "search_view"
|
||||
static let LIVE_API_URL = "live_api_url"
|
||||
|
||||
@@ -2,9 +2,13 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackQualityOption: Identifiable, Hashable {
|
||||
/// “自动”选项固定标识。
|
||||
static let autoIdentifier = "auto"
|
||||
/// 选项唯一标识(这里直接使用播放地址或固定 auto id)。
|
||||
let id: String
|
||||
/// UI 展示名(如 1080p / 720p / 自动)。
|
||||
let name: String
|
||||
/// 对应播放地址。
|
||||
let url: String
|
||||
|
||||
var isAuto: Bool {
|
||||
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
|
||||
/// 详情页 ViewModel
|
||||
@MainActor
|
||||
class DetailViewModel: ObservableObject {
|
||||
/// 详情信息主体。
|
||||
@Published var vodInfo: VodInfo?
|
||||
/// 加载状态。
|
||||
@Published var isLoading = false
|
||||
/// 错误提示。
|
||||
@Published var errorMessage: String?
|
||||
/// 当前选中线路。
|
||||
@Published var selectedFlag: String = ""
|
||||
/// 当前选中剧集索引。
|
||||
@Published var selectedEpisodeIndex: Int = 0
|
||||
/// 是否处于播放态。
|
||||
@Published var isPlaying = false
|
||||
/// 当前实际播放地址(可能是原始地址,也可能是清晰度切换后的子流地址)。
|
||||
@Published var playUrl: String?
|
||||
/// 续播起始位置(秒)。
|
||||
@Published var resumeSeconds: Double = 0
|
||||
/// 当前可选清晰度列表。
|
||||
@Published var qualityOptions: [PlaybackQualityOption] = []
|
||||
/// 当前选中的清晰度 id。
|
||||
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
|
||||
/// 播放器高频回调进度,不直接绑定 UI,避免高频刷新引发性能问题。
|
||||
private var realtimeProgressSeconds: Double = 0
|
||||
|
||||
/// 数据服务与网络服务。
|
||||
private let sourceService = SourceService.shared
|
||||
private let network = NetworkManager.shared
|
||||
/// 当前清晰度列表对应的基础剧集地址。
|
||||
private var qualityBaseEpisodeURL: String = ""
|
||||
/// 清晰度解析缓存,key 为原始剧集 URL。
|
||||
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
|
||||
/// 清晰度解析任务,用于取消旧请求。
|
||||
private var qualityResolveTask: Task<Void, Never>?
|
||||
/// 解析令牌,防止异步结果回写到过期状态。
|
||||
private var qualityResolveToken = UUID()
|
||||
|
||||
/// 加载视频详情
|
||||
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
|
||||
realtimeProgressSeconds = 0
|
||||
|
||||
if let episode = vodInfo?.currentEpisode {
|
||||
// 仅当剧集 URL 变化时重置清晰度选择。
|
||||
let shouldResetQuality = qualityBaseEpisodeURL != episode.url
|
||||
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
|
||||
playUrl = selectedPlayableURL(fallback: episode.url)
|
||||
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
|
||||
selectedQualityId = option.id
|
||||
guard isPlaying else { return }
|
||||
|
||||
// “自动”使用基础剧集地址;其他选项使用对应变体地址。
|
||||
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
|
||||
guard !targetURL.isEmpty, playUrl != targetURL else { return }
|
||||
|
||||
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func selectedPlayableURL(fallback: String) -> String {
|
||||
// 若当前清晰度存在有效 URL,则优先使用;否则回退剧集原始地址。
|
||||
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
|
||||
if let selected, !selected.isEmpty {
|
||||
return selected
|
||||
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// 重置清晰度解析与选择状态。
|
||||
private func resetQualityState() {
|
||||
qualityResolveTask?.cancel()
|
||||
qualityResolveTask = nil
|
||||
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// 切换剧集时先取消旧任务,避免异步回写错位。
|
||||
qualityResolveTask?.cancel()
|
||||
qualityResolveTask = nil
|
||||
|
||||
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
|
||||
qualityOptions = [autoOption]
|
||||
|
||||
if let cached = qualityOptionCache[trimmedEpisodeURL] {
|
||||
// 缓存命中时直接复用,避免重复网络解析。
|
||||
qualityOptions = cached
|
||||
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
|
||||
selectedQualityId = PlaybackQualityOption.autoIdentifier
|
||||
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试从 HLS 主播放列表解析多清晰度选项。
|
||||
private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] {
|
||||
guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] }
|
||||
guard let playlist = try? await network.getString(from: episodeURL) else { return [] }
|
||||
return Self.parseMasterPlaylist(playlist, masterURL: url)
|
||||
}
|
||||
|
||||
/// HLS 变体流中间模型。
|
||||
private struct HLSVariant {
|
||||
let url: String
|
||||
let name: String?
|
||||
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
|
||||
let bandwidth: Int?
|
||||
}
|
||||
|
||||
/// 轻量判断 URL 是否可能是 HLS 播放列表。
|
||||
private static func looksLikeHLSURL(_ url: URL) -> Bool {
|
||||
let lowercased = url.absoluteString.lowercased()
|
||||
if lowercased.contains(".m3u8") { return true }
|
||||
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
|
||||
return ext == "m3u8" || ext == "m3u"
|
||||
}
|
||||
|
||||
/// 解析 HLS 主播放列表并生成清晰度选项。
|
||||
/// 仅当解析出 2 个及以上有效变体时才返回(否则不显示清晰度切换)。
|
||||
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
|
||||
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
|
||||
|
||||
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
|
||||
return merged
|
||||
}
|
||||
|
||||
/// 解析 `EXT-X-STREAM-INF` 的属性串为键值字典。
|
||||
private static func parseAttributeMap(_ raw: String) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
let pairs = splitAttributes(raw)
|
||||
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
|
||||
return result
|
||||
}
|
||||
|
||||
/// 按逗号分隔属性,但保留引号内逗号。
|
||||
private static func splitAttributes(_ raw: String) -> [String] {
|
||||
var parts: [String] = []
|
||||
var buffer = ""
|
||||
|
||||
@@ -4,15 +4,24 @@ import SwiftUI
|
||||
/// 首页 ViewModel
|
||||
@MainActor
|
||||
class HomeViewModel: ObservableObject {
|
||||
/// 分类列表(包含手动注入的“推荐”分类)。
|
||||
@Published var sorts: [MovieSort.SortData] = []
|
||||
/// 当前选中的分类。
|
||||
@Published var selectedSort: MovieSort.SortData?
|
||||
/// 首页推荐内容(对应“推荐”分类)。
|
||||
@Published var homeVideos: [Movie.Video] = []
|
||||
/// 普通分类的视频列表(分页加载)。
|
||||
@Published var categoryVideos: [Movie.Video] = []
|
||||
/// 页面加载状态(分类加载与分页共用)。
|
||||
@Published var isLoading = false
|
||||
/// 当前分类的分页页码。
|
||||
@Published var currentPage = 1
|
||||
/// 是否还有下一页。
|
||||
@Published var hasMore = true
|
||||
/// 错误提示文案。
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 源数据访问服务。
|
||||
private let sourceService = SourceService.shared
|
||||
|
||||
/// 加载分类列表
|
||||
@@ -24,7 +33,7 @@ class HomeViewModel: ObservableObject {
|
||||
do {
|
||||
let result = try await sourceService.getSort(sourceBean: source)
|
||||
|
||||
// 添加"推荐"到首位
|
||||
// 插入本地“推荐”分类,保持 UI 与 Android 版本习惯一致。
|
||||
var allSorts = [MovieSort.SortData.home()]
|
||||
allSorts.append(contentsOf: result.sorts)
|
||||
|
||||
@@ -43,6 +52,7 @@ class HomeViewModel: ObservableObject {
|
||||
|
||||
/// 选择分类
|
||||
func selectSort(_ sort: MovieSort.SortData) {
|
||||
// 切分类时先重置分页状态,避免旧分类残留数据闪烁。
|
||||
selectedSort = sort
|
||||
errorMessage = nil
|
||||
categoryVideos = []
|
||||
@@ -62,6 +72,7 @@ class HomeViewModel: ObservableObject {
|
||||
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
|
||||
guard sort.id != "home" else { return }
|
||||
guard let source = ApiConfig.shared.homeSourceBean else { return }
|
||||
// 防重复并发加载,避免分页错序。
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
@@ -78,6 +89,7 @@ class HomeViewModel: ObservableObject {
|
||||
} else {
|
||||
categoryVideos.append(contentsOf: videos)
|
||||
}
|
||||
// 以“返回非空”作为是否继续分页的轻量判断。
|
||||
currentPage = page
|
||||
hasMore = !videos.isEmpty
|
||||
} catch {
|
||||
@@ -105,6 +117,7 @@ class HomeViewModel: ObservableObject {
|
||||
|
||||
/// 刷新
|
||||
func refresh() async {
|
||||
// 全量刷新时重置分页与错误态,再重新拉分类与当前分类内容。
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
categoryVideos = []
|
||||
|
||||
@@ -4,12 +4,19 @@ import SwiftUI
|
||||
/// 直播 ViewModel
|
||||
@MainActor
|
||||
class LiveViewModel: ObservableObject {
|
||||
/// 全部频道分组。
|
||||
@Published var channelGroups: [LiveChannelGroup] = []
|
||||
/// 当前选中分组索引。
|
||||
@Published var selectedGroupIndex: Int = 0
|
||||
/// 当前选中频道索引(相对于当前分组)。
|
||||
@Published var selectedChannelIndex: Int = 0
|
||||
/// 当前播放频道。
|
||||
@Published var currentChannel: LiveChannelItem?
|
||||
/// 当前频道节目单(预留)。
|
||||
@Published var epgList: [Epginfo] = []
|
||||
/// 加载状态(预留,便于后续接入远程 EPG)。
|
||||
@Published var isLoading = false
|
||||
/// 是否显示频道列表(预留给 TV 遥控交互)。
|
||||
@Published var showChannelList = false
|
||||
|
||||
/// 加载直播频道
|
||||
@@ -67,6 +74,7 @@ class LiveViewModel: ObservableObject {
|
||||
|
||||
/// 切换线路
|
||||
func switchSource() {
|
||||
// `currentChannel` 为值类型,调用 mutating 方法会触发 @Published 重新发布。
|
||||
currentChannel?.nextSource()
|
||||
}
|
||||
|
||||
@@ -78,13 +86,15 @@ class LiveViewModel: ObservableObject {
|
||||
|
||||
/// 加载 EPG 节目单
|
||||
private func loadEPG(for channel: LiveChannelItem) {
|
||||
// EPG 加载 - 简化实现
|
||||
// 预留:后续可在此按频道名/频道 ID 请求远程 EPG。
|
||||
// 当前版本先清空,避免展示过期节目单。
|
||||
epgList = []
|
||||
}
|
||||
}
|
||||
|
||||
// 安全数组下标访问
|
||||
extension Collection {
|
||||
/// 安全下标读取,越界时返回 `nil`。
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
|
||||
@@ -4,14 +4,23 @@ import SwiftUI
|
||||
/// 搜索 ViewModel
|
||||
@MainActor
|
||||
class SearchViewModel: ObservableObject {
|
||||
/// 搜索关键词输入。
|
||||
@Published var keyword: String = ""
|
||||
/// 当前结果列表。
|
||||
@Published var results: [Movie.Video] = []
|
||||
/// 搜索加载状态,用于控制进度指示器。
|
||||
@Published var isSearching = false
|
||||
/// 本地搜索历史(最近在前)。
|
||||
@Published var searchHistory: [String] = []
|
||||
/// 搜索失败或空结果提示。
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 源数据服务(负责多源并发搜索)。
|
||||
private let sourceService = SourceService.shared
|
||||
/// 搜索请求序号(用于丢弃过期异步结果)。
|
||||
private var latestSearchRequestId: UUID = UUID()
|
||||
|
||||
/// 初始化时同步加载本地历史记录,确保搜索页首次渲染即可展示。
|
||||
init() {
|
||||
loadSearchHistory()
|
||||
}
|
||||
@@ -20,15 +29,19 @@ class SearchViewModel: ObservableObject {
|
||||
func search() async {
|
||||
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let requestId = UUID()
|
||||
latestSearchRequestId = requestId
|
||||
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
results = []
|
||||
|
||||
// 保存搜索历史
|
||||
// 搜索一旦触发就先落历史,保持行为与移动端常见搜索体验一致。
|
||||
addToHistory(trimmed)
|
||||
|
||||
// 走多源并发搜索,返回聚合后的影片列表。
|
||||
let videos = await sourceService.searchAll(keyword: trimmed)
|
||||
guard requestId == latestSearchRequestId else { return }
|
||||
self.results = videos
|
||||
|
||||
if videos.isEmpty {
|
||||
@@ -42,13 +55,18 @@ class SearchViewModel: ObservableObject {
|
||||
func searchInSource(_ source: SourceBean) async {
|
||||
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let requestId = UUID()
|
||||
latestSearchRequestId = requestId
|
||||
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let videos = try await sourceService.search(sourceBean: source, keyword: trimmed)
|
||||
guard requestId == latestSearchRequestId else { return }
|
||||
self.results = videos
|
||||
} catch {
|
||||
guard requestId == latestSearchRequestId else { return }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -57,10 +75,12 @@ class SearchViewModel: ObservableObject {
|
||||
|
||||
// MARK: - 搜索历史
|
||||
|
||||
/// 从本地读取历史。
|
||||
private func loadSearchHistory() {
|
||||
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
|
||||
}
|
||||
|
||||
/// 新增历史项并去重,最多保留 20 条。
|
||||
private func addToHistory(_ keyword: String) {
|
||||
searchHistory.removeAll { $0 == keyword }
|
||||
searchHistory.insert(keyword, at: 0)
|
||||
@@ -70,11 +90,13 @@ class SearchViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||
}
|
||||
|
||||
/// 清空历史。
|
||||
func clearHistory() {
|
||||
searchHistory = []
|
||||
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
|
||||
}
|
||||
|
||||
/// 删除单条历史。
|
||||
func removeFromHistory(_ keyword: String) {
|
||||
searchHistory.removeAll { $0 == keyword }
|
||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||
|
||||
@@ -4,56 +4,145 @@ import SwiftUI
|
||||
/// 设置 ViewModel
|
||||
@MainActor
|
||||
class SettingsViewModel: ObservableObject {
|
||||
@Published var apiUrl: String = ""
|
||||
/// 当输入地址是“多仓库入口”时,先弹出候选仓库供用户确认。
|
||||
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 liveApiUrl: String = ""
|
||||
/// 配置加载中状态。
|
||||
@Published var isLoadingConfig = false
|
||||
/// 配置错误提示。
|
||||
@Published var configError: String?
|
||||
/// 配置是否加载成功(供 UI 执行后续跳转/收起流程)。
|
||||
@Published var configSuccess = false
|
||||
/// 多仓库待选状态,为 nil 表示无需弹窗。
|
||||
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
|
||||
/// 最近输入过的 API 历史。
|
||||
@Published var apiHistory: [String] = []
|
||||
@Published var playerEngine: PlayerEngine = .system
|
||||
/// 点播播放器内核选择。
|
||||
@Published var vodPlayerEngine: PlayerEngine = .system
|
||||
/// 直播播放器内核选择。
|
||||
@Published var livePlayerEngine: PlayerEngine = .system
|
||||
/// 解码模式选择。
|
||||
@Published var decodeMode: VideoDecodeMode = .auto
|
||||
/// VLC 缓冲策略。
|
||||
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
||||
/// 快进/快退步长(秒)。
|
||||
@Published var playTimeStep: Int = 10
|
||||
/// 缓存占用展示文本。
|
||||
@Published var cacheSizeString: String = "0 KB"
|
||||
|
||||
/// 快进步长候选项。
|
||||
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
|
||||
/// 当前构建可用播放器列表。
|
||||
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
|
||||
/// 解码模式候选。
|
||||
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
|
||||
/// VLC 缓冲模式候选。
|
||||
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
||||
|
||||
/// 初始化时完成三件事:
|
||||
/// 1) 回填已保存的配置地址
|
||||
/// 2) 兼容老版本单一播放器字段到新字段
|
||||
/// 3) 回填播放/缓存相关设置
|
||||
init() {
|
||||
apiUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
let defaults = UserDefaults.standard
|
||||
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
vodApiUrl = savedVod
|
||||
if let savedLive = defaults.string(forKey: HawkConfig.LIVE_API_URL) {
|
||||
liveApiUrl = savedLive
|
||||
} else {
|
||||
liveApiUrl = savedVod
|
||||
}
|
||||
loadApiHistory()
|
||||
playerEngine = PlayerEngine.fromStoredValue(
|
||||
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TYPE)
|
||||
let hasLegacyPlayer = defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil
|
||||
let legacyPlayerRaw = defaults.integer(forKey: HawkConfig.PLAY_TYPE)
|
||||
let defaultVodRaw = PlayerEngine.system.rawValue
|
||||
let defaultLiveRaw = PlayerEngine.isVLCAvailable
|
||||
? PlayerEngine.vlc.rawValue
|
||||
: PlayerEngine.system.rawValue
|
||||
if defaults.object(forKey: HawkConfig.PLAY_TYPE_VOD) == nil {
|
||||
defaults.set(hasLegacyPlayer ? legacyPlayerRaw : defaultVodRaw, forKey: HawkConfig.PLAY_TYPE_VOD)
|
||||
}
|
||||
if defaults.object(forKey: HawkConfig.PLAY_TYPE_LIVE) == nil {
|
||||
defaults.set(hasLegacyPlayer ? legacyPlayerRaw : defaultLiveRaw, forKey: HawkConfig.PLAY_TYPE_LIVE)
|
||||
}
|
||||
vodPlayerEngine = PlayerEngine.fromStoredValue(
|
||||
defaults.integer(forKey: HawkConfig.PLAY_TYPE_VOD)
|
||||
)
|
||||
livePlayerEngine = PlayerEngine.fromStoredValue(
|
||||
defaults.integer(forKey: HawkConfig.PLAY_TYPE_LIVE)
|
||||
)
|
||||
decodeMode = VideoDecodeMode.fromStoredValue(
|
||||
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE)
|
||||
defaults.integer(forKey: HawkConfig.PLAY_DECODE_MODE)
|
||||
)
|
||||
vlcBufferMode = VLCBufferMode.fromStoredValue(
|
||||
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
|
||||
defaults.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
|
||||
)
|
||||
|
||||
let savedStep = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
|
||||
let savedStep = defaults.integer(forKey: HawkConfig.PLAY_TIME_STEP)
|
||||
playTimeStep = savedStep > 0 ? savedStep : 10
|
||||
refreshCacheSize()
|
||||
}
|
||||
|
||||
/// 加载配置
|
||||
func loadConfig() async {
|
||||
let trimmed = apiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
configError = "请输入配置地址"
|
||||
let trimmedVod = vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedLive = liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedVod.isEmpty else {
|
||||
configError = "请输入点播接口地址"
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingConfig = true
|
||||
configError = nil
|
||||
configSuccess = false
|
||||
pendingMultiRepoSelection = nil
|
||||
|
||||
do {
|
||||
try await ApiConfig.shared.loadConfig(from: trimmed)
|
||||
UserDefaults.standard.set(trimmed, forKey: HawkConfig.API_URL)
|
||||
addToApiHistory(trimmed)
|
||||
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)
|
||||
// 保存用户输入(live 允许空值,表示跟随点播地址)。
|
||||
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
|
||||
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
|
||||
vodApiUrl = trimmedVod
|
||||
liveApiUrl = trimmedLive
|
||||
addToApiHistory(trimmedVod)
|
||||
addToApiHistory(resolvedLive)
|
||||
configSuccess = true
|
||||
} catch {
|
||||
configError = error.localizedDescription
|
||||
@@ -62,12 +151,80 @@ class SettingsViewModel: ObservableObject {
|
||||
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 历史
|
||||
|
||||
/// 读取 API 历史。
|
||||
private func loadApiHistory() {
|
||||
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
|
||||
}
|
||||
|
||||
/// 新增历史并去重,最多保留 10 条。
|
||||
private func addToApiHistory(_ url: String) {
|
||||
apiHistory.removeAll { $0 == url }
|
||||
apiHistory.insert(url, at: 0)
|
||||
@@ -77,6 +234,7 @@ class SettingsViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||
}
|
||||
|
||||
/// 删除单条 API 历史。
|
||||
func removeApiHistory(_ url: String) {
|
||||
apiHistory.removeAll { $0 == url }
|
||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||
@@ -97,11 +255,18 @@ class SettingsViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(step, forKey: HawkConfig.PLAY_TIME_STEP)
|
||||
}
|
||||
|
||||
/// 设置播放器内核
|
||||
func setPlayerEngine(_ engine: PlayerEngine) {
|
||||
/// 设置点播播放器内核
|
||||
func setVodPlayerEngine(_ engine: PlayerEngine) {
|
||||
guard playerEngineOptions.contains(engine) else { return }
|
||||
playerEngine = engine
|
||||
UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE)
|
||||
vodPlayerEngine = engine
|
||||
UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE_VOD)
|
||||
}
|
||||
|
||||
/// 设置直播播放器内核
|
||||
func setLivePlayerEngine(_ engine: PlayerEngine) {
|
||||
guard playerEngineOptions.contains(engine) else { return }
|
||||
livePlayerEngine = engine
|
||||
UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE_LIVE)
|
||||
}
|
||||
|
||||
/// 设置视频解码模式
|
||||
@@ -118,12 +283,14 @@ class SettingsViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
|
||||
}
|
||||
|
||||
/// 统计并刷新缓存占用展示(网络缓存 + 图片缓存磁盘占用)。
|
||||
private func refreshCacheSize() {
|
||||
let sharedDisk = URLCache.shared.currentDiskUsage
|
||||
let imageDisk = ImageLoader.shared.cacheUsage.disk
|
||||
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
|
||||
}
|
||||
|
||||
/// 格式化字节大小。
|
||||
private static func formatSize(bytes: Int) -> String {
|
||||
let size = max(0, bytes)
|
||||
if size < 1024 * 1024 {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 通用空状态组件。
|
||||
/// 在收藏、历史等页面复用,统一空页面视觉风格。
|
||||
struct EmptyStateView: View {
|
||||
/// SF Symbol 图标名。
|
||||
let icon: String
|
||||
/// 主标题。
|
||||
let title: String
|
||||
/// 可选说明文字,为空时不渲染副文案区域。
|
||||
var message: String? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// 标题强调当前页面状态,例如“暂无收藏”“暂无播放记录”。
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.tracking(1)
|
||||
|
||||
// 副文案用于提供下一步引导,不参与核心逻辑判断。
|
||||
if let message = message {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
|
||||
@@ -2,10 +2,22 @@ import SwiftUI
|
||||
|
||||
/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航
|
||||
struct ContentView: View {
|
||||
/// 首次配置页点击“最近使用”时,当前要写入的输入框目标。
|
||||
private enum ApiInputTarget {
|
||||
case vod
|
||||
case live
|
||||
}
|
||||
|
||||
/// 全局状态(配置加载、分栏状态等)。
|
||||
@EnvironmentObject var appState: AppState
|
||||
/// 设置页 ViewModel。根视图复用它处理首次配置与多仓库选择。
|
||||
@StateObject private var settingsVM = SettingsViewModel()
|
||||
/// 当前主标签索引。
|
||||
@State private var selectedTab = 0
|
||||
/// 预留:控制首次配置页显隐(当前逻辑由 `appState.isConfigLoaded` 驱动)。
|
||||
@State private var showSetup = false
|
||||
/// 首次配置页历史回填目标输入框。
|
||||
@State private var setupInputTarget: ApiInputTarget = .vod
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -15,20 +27,50 @@ struct ContentView: View {
|
||||
setupView
|
||||
}
|
||||
}
|
||||
.overlay(multiRepoSelectionOverlay)
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
// 自动加载已保存的配置
|
||||
let savedUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
if !savedUrl.isEmpty {
|
||||
let defaults = UserDefaults.standard
|
||||
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
|
||||
if !savedVodUrl.isEmpty {
|
||||
// 启动自动恢复配置,避免每次重启都回到首次配置页。
|
||||
Task {
|
||||
await appState.loadConfig(url: savedUrl)
|
||||
await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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: - 主界面
|
||||
|
||||
/// 主体导航容器:iOS 使用 TabView,macOS 使用 NavigationSplitView。
|
||||
private var mainTabView: some View {
|
||||
#if os(iOS)
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -97,6 +139,7 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - 首次配置页面
|
||||
|
||||
/// 首次启动或未加载配置时的引导页面。
|
||||
private var setupView: some View {
|
||||
ZStack {
|
||||
// 背景装饰
|
||||
@@ -169,24 +212,48 @@ struct ContentView: View {
|
||||
HStack {
|
||||
Image(systemName: "link")
|
||||
.foregroundColor(.orange)
|
||||
TextField("请输入接口地址 (URL)", text: $settingsVM.apiUrl)
|
||||
TextField("请输入点播接口地址 (URL)", text: $settingsVM.vodApiUrl)
|
||||
.textFieldStyle(.plain)
|
||||
.foregroundColor(.white)
|
||||
.onTapGesture {
|
||||
setupInputTarget = .vod
|
||||
}
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
|
||||
Button {
|
||||
if let text = readPasteboardText() {
|
||||
settingsVM.vodApiUrl = text
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding()
|
||||
.glassCard(cornerRadius: 15)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "tv")
|
||||
.foregroundColor(.orange)
|
||||
TextField("请输入直播接口地址 (URL,可留空跟随点播)", text: $settingsVM.liveApiUrl)
|
||||
.textFieldStyle(.plain)
|
||||
.foregroundColor(.white)
|
||||
.onTapGesture {
|
||||
setupInputTarget = .live
|
||||
}
|
||||
#if os(iOS)
|
||||
if let text = UIPasteboard.general.string {
|
||||
settingsVM.apiUrl = text
|
||||
}
|
||||
#else
|
||||
if let text = NSPasteboard.general.string(forType: .string) {
|
||||
settingsVM.apiUrl = text
|
||||
}
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
|
||||
Button {
|
||||
if let text = readPasteboardText() {
|
||||
settingsVM.liveApiUrl = text
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.orange)
|
||||
@@ -202,7 +269,7 @@ struct ContentView: View {
|
||||
Task {
|
||||
await settingsVM.loadConfig()
|
||||
if settingsVM.configSuccess {
|
||||
await appState.loadConfig(url: settingsVM.apiUrl)
|
||||
appState.applyLoadedConfigState()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -222,7 +289,10 @@ struct ContentView: View {
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.disabled(settingsVM.isLoadingConfig || settingsVM.apiUrl.isEmpty)
|
||||
.disabled(
|
||||
settingsVM.isLoadingConfig
|
||||
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
|
||||
// 历史记录
|
||||
if !settingsVM.apiHistory.isEmpty {
|
||||
@@ -234,7 +304,12 @@ struct ContentView: View {
|
||||
|
||||
ForEach(settingsVM.apiHistory.prefix(3), id: \.self) { url in
|
||||
Button {
|
||||
settingsVM.apiUrl = url
|
||||
switch setupInputTarget {
|
||||
case .vod:
|
||||
settingsVM.vodApiUrl = url
|
||||
case .live:
|
||||
settingsVM.liveApiUrl = url
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "clock.arrow.2.circlepath")
|
||||
@@ -276,4 +351,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readPasteboardText() -> String? {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string
|
||||
#else
|
||||
// macOS 下通过 NSPasteboard 读取纯文本。
|
||||
NSPasteboard.general.string(forType: .string)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
|
||||
// 该文件已迁移至 `tvbox/Utils/Extensions.swift`。
|
||||
// 保留此占位文件仅为兼容历史引用,避免旧路径导入时报错。
|
||||
|
||||
@@ -8,10 +8,14 @@ import AppKit
|
||||
struct DetailView: View {
|
||||
let video: Movie.Video
|
||||
@StateObject private var viewModel = DetailViewModel()
|
||||
@StateObject private var sharedSystemController = SystemPlayerSessionController()
|
||||
@StateObject private var sharedVLCController = VLCPlayerController()
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var showFullScreen = false
|
||||
#if os(macOS)
|
||||
@State private var pendingMacWindowFullScreen = false
|
||||
#endif
|
||||
@State private var lastPersistedProgress: Double = 0
|
||||
@State private var isCollected = false
|
||||
|
||||
@@ -19,7 +23,7 @@ struct DetailView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// 播放器区域
|
||||
if viewModel.isPlaying, let url = viewModel.playUrl, !showFullScreen {
|
||||
if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl {
|
||||
PlayerView(
|
||||
urlString: url,
|
||||
startPosition: viewModel.currentPlaybackSeconds(),
|
||||
@@ -30,6 +34,7 @@ struct DetailView: View {
|
||||
},
|
||||
canPlayNext: canPlayNextEpisode,
|
||||
onPlayNext: playNextEpisodeIfNeeded,
|
||||
systemController: sharedSystemController,
|
||||
vlcController: sharedVLCController
|
||||
)
|
||||
.id("\(viewModel.selectedFlag)-\(viewModel.selectedEpisodeIndex)-\(url)")
|
||||
@@ -77,7 +82,7 @@ struct DetailView: View {
|
||||
.background(AppTheme.primaryGradient)
|
||||
.navigationTitle(video.name)
|
||||
#if os(macOS)
|
||||
.toolbar(showFullScreen ? .hidden : .visible, for: .windowToolbar)
|
||||
.toolbar((showFullScreen || pendingMacWindowFullScreen) ? .hidden : .visible, for: .windowToolbar)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -91,8 +96,10 @@ struct DetailView: View {
|
||||
viewModel.commitPlaybackProgressSnapshot()
|
||||
persistHistoryIfNeeded(force: true)
|
||||
showFullScreen = false
|
||||
sharedSystemController.stop()
|
||||
sharedVLCController.stop()
|
||||
#if os(macOS)
|
||||
pendingMacWindowFullScreen = false
|
||||
appState.exitPlayerFullScreen()
|
||||
#endif
|
||||
}
|
||||
@@ -106,6 +113,7 @@ struct DetailView: View {
|
||||
onPlaybackEnded: playNextEpisodeIfNeeded,
|
||||
canPlayNext: canPlayNextEpisode,
|
||||
onPlayNext: playNextEpisodeIfNeeded,
|
||||
systemController: sharedSystemController,
|
||||
vlcController: sharedVLCController,
|
||||
onCloseRequested: closeMacFullScreenOverlay
|
||||
)
|
||||
@@ -114,7 +122,13 @@ struct DetailView: View {
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in
|
||||
guard pendingMacWindowFullScreen else { return }
|
||||
pendingMacWindowFullScreen = false
|
||||
showFullScreen = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in
|
||||
pendingMacWindowFullScreen = false
|
||||
if showFullScreen {
|
||||
showFullScreen = false
|
||||
}
|
||||
@@ -131,6 +145,7 @@ struct DetailView: View {
|
||||
onPlaybackEnded: playNextEpisodeIfNeeded,
|
||||
canPlayNext: canPlayNextEpisode,
|
||||
onPlayNext: playNextEpisodeIfNeeded,
|
||||
systemController: sharedSystemController,
|
||||
vlcController: sharedVLCController
|
||||
)
|
||||
}
|
||||
@@ -502,24 +517,33 @@ struct DetailView: View {
|
||||
showFullScreen = true
|
||||
#else
|
||||
guard viewModel.playUrl != nil else { return }
|
||||
showFullScreen = true
|
||||
appState.enterPlayerFullScreen()
|
||||
requestMacWindowFullScreen(enter: true)
|
||||
|
||||
if let window = NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
window.styleMask.contains(.fullScreen) {
|
||||
showFullScreen = true
|
||||
return
|
||||
}
|
||||
|
||||
pendingMacWindowFullScreen = requestMacWindowFullScreen(enter: true)
|
||||
if !pendingMacWindowFullScreen {
|
||||
showFullScreen = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func requestMacWindowFullScreen(enter: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||||
let isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
if enter != isFullScreen {
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
private func requestMacWindowFullScreen(enter: Bool) -> Bool {
|
||||
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return false }
|
||||
let isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
guard enter != isFullScreen else { return false }
|
||||
window.toggleFullScreen(nil)
|
||||
return true
|
||||
}
|
||||
|
||||
private func closeMacFullScreenOverlay() {
|
||||
pendingMacWindowFullScreen = false
|
||||
let window = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
if window?.styleMask.contains(.fullScreen) == true {
|
||||
requestMacWindowFullScreen(enter: false)
|
||||
@@ -539,6 +563,7 @@ struct FullScreenPlayerView: View {
|
||||
var onPlaybackEnded: (() -> Void)? = nil
|
||||
var canPlayNext: Bool = false
|
||||
var onPlayNext: (() -> Void)? = nil
|
||||
var systemController: SystemPlayerSessionController? = nil
|
||||
var vlcController: VLCPlayerController? = nil
|
||||
var onCloseRequested: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -561,6 +586,7 @@ struct FullScreenPlayerView: View {
|
||||
},
|
||||
canPlayNext: canPlayNext,
|
||||
onPlayNext: onPlayNext,
|
||||
systemController: systemController,
|
||||
vlcController: vlcController
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
@@ -2,17 +2,26 @@ import SwiftUI
|
||||
|
||||
/// 剧集列表组件 - 对应 Android 版 SeriesAdapter
|
||||
struct EpisodeListView: View {
|
||||
/// 当前线路下的全部剧集。
|
||||
let episodes: [VodInfo.Episode]
|
||||
/// 外部传入的当前选中集索引(绝对索引)。
|
||||
let selectedIndex: Int
|
||||
/// 点击某一集后的回调(返回绝对索引)。
|
||||
let onSelect: (Int) -> Void
|
||||
|
||||
/// 当前分组索引(每 50 集一个分组,避免超长列表影响渲染与选择体验)。
|
||||
@State private var currentGroup = 0
|
||||
/// 每个分组展示的剧集数量。
|
||||
private let groupSize = 50
|
||||
/// 自适应列:根据可用宽度自动换行,避免集数过多时右侧被截断。
|
||||
private let gridColumns = [GridItem(.adaptive(minimum: 78), spacing: 10)]
|
||||
|
||||
/// 分组总数,至少为 1,避免空数组时出现 0 组的边界问题。
|
||||
private var groupCount: Int {
|
||||
max(1, (episodes.count + groupSize - 1) / groupSize)
|
||||
}
|
||||
|
||||
/// 当前分组对应的切片数据。
|
||||
private var currentEpisodes: [VodInfo.Episode] {
|
||||
let start = currentGroup * groupSize
|
||||
let end = min(start + groupSize, episodes.count)
|
||||
@@ -56,43 +65,40 @@ struct EpisodeListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集网格
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: [
|
||||
GridItem(.fixed(44)),
|
||||
GridItem(.fixed(44))
|
||||
], spacing: 10) {
|
||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||
let actualIndex = currentGroup * groupSize + index
|
||||
Button {
|
||||
onSelect(actualIndex)
|
||||
} label: {
|
||||
Text(episode.name)
|
||||
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
||||
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
||||
.frame(minWidth: 70)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
ZStack {
|
||||
if actualIndex == selectedIndex {
|
||||
AppTheme.accentGradient
|
||||
} else {
|
||||
Color.white.opacity(0.05)
|
||||
}
|
||||
// 剧集网格:自适应换行,避免横向裁切。
|
||||
LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 10) {
|
||||
// 这里使用当前组内索引 + 组偏移,换算成全局索引以便外部状态一致。
|
||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||
let actualIndex = currentGroup * groupSize + index
|
||||
Button {
|
||||
onSelect(actualIndex)
|
||||
} label: {
|
||||
Text(episode.name)
|
||||
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
||||
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
ZStack {
|
||||
if actualIndex == selectedIndex {
|
||||
AppTheme.accentGradient
|
||||
} else {
|
||||
Color.white.opacity(0.05)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.frame(height: 100)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ import SwiftData
|
||||
|
||||
/// 收藏页 - 对应 Android 版 CollectActivity
|
||||
struct FavoritesView: View {
|
||||
/// 按更新时间倒序展示收藏,最近收藏/更新的内容靠前。
|
||||
@Query(sort: \VodCollect.updateTime, order: .reverse)
|
||||
private var favorites: [VodCollect]
|
||||
/// SwiftData 上下文,用于删除收藏并持久化。
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 下卡片尺寸更紧凑,适配手机竖屏。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 下卡片适度放大,提升桌面端可读性。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -25,6 +29,7 @@ struct FavoritesView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
||||
ForEach(favorites) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
favoriteCard(item)
|
||||
@@ -50,12 +55,14 @@ struct FavoritesView: View {
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
// 通过 Movie.Video 作为路由载体,保持与首页/搜索页一致的详情入口。
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 空列表占位。
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "heart.text.square",
|
||||
@@ -65,6 +72,8 @@ struct FavoritesView: View {
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
/// 收藏卡片。
|
||||
/// 只展示海报与标题,保持网格信息密度一致。
|
||||
private func favoriteCard(_ item: VodCollect) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
|
||||
@@ -83,6 +92,7 @@ struct FavoritesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将收藏记录映射成详情页可识别的视频对象。
|
||||
private func movieVideo(from item: VodCollect) -> Movie.Video {
|
||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ import SwiftData
|
||||
|
||||
/// 历史记录页 - 对应 Android 版 HistoryActivity
|
||||
struct HistoryView: View {
|
||||
/// 按最近播放时间倒序展示历史记录。
|
||||
@Query(sort: \VodRecord.updateTime, order: .reverse)
|
||||
private var records: [VodRecord]
|
||||
/// SwiftData 上下文,用于删除单条记录或清空历史。
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 网格配置。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 网格配置。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -25,6 +29,7 @@ struct HistoryView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 记录卡片支持跳转详情与右键删除。
|
||||
ForEach(records) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
recordCard(item)
|
||||
@@ -53,6 +58,7 @@ struct HistoryView: View {
|
||||
.toolbar {
|
||||
if !records.isEmpty {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
// 清空历史使用统一缓存服务,确保行为与其他入口一致。
|
||||
Button {
|
||||
Task {
|
||||
CacheStore.shared.clearHistory(context: modelContext)
|
||||
@@ -64,12 +70,14 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 与首页/搜索/收藏共用同一种详情路由模型。
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 无历史时的占位视图。
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "clock.arrow.circlepath",
|
||||
@@ -79,6 +87,8 @@ struct HistoryView: View {
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
/// 历史卡片。
|
||||
/// 除海报和标题外,额外显示播放进度与更新时间,便于快速续播。
|
||||
private func recordCard(_ item: VodRecord) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
@@ -115,6 +125,7 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将历史记录转换为详情页的入参模型。
|
||||
private func movieVideo(from item: VodRecord) -> Movie.Video {
|
||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import SwiftUI
|
||||
|
||||
/// 视频卡片组件
|
||||
struct VodCardView: View {
|
||||
/// 卡片对应的视频数据。
|
||||
let video: Movie.Video
|
||||
/// 悬停状态(主要用于 macOS 悬停放大动效)。
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +46,7 @@ struct VodCardView: View {
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
// 悬停缩放只增强视觉反馈,不影响点击命中区域。
|
||||
.scaleEffect(isHovered ? 1.05 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
|
||||
.onHover { hovering in
|
||||
@@ -67,6 +70,7 @@ struct VodCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
||||
private var placeholderImage: some View {
|
||||
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
|
||||
@@ -6,25 +6,56 @@ import AppKit
|
||||
|
||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||
struct LiveView: View {
|
||||
/// 直播频道与选中状态管理。
|
||||
@StateObject private var viewModel = LiveViewModel()
|
||||
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
||||
@EnvironmentObject var appState: AppState
|
||||
/// 系统播放器实例(仅在选择系统内核时使用)。
|
||||
@State private var avPlayer: AVPlayer?
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
|
||||
/// 直播播放器内核配置(新字段)。
|
||||
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
|
||||
/// 兼容旧版本单播放器字段。
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||
/// 是否展示左侧频道抽屉。
|
||||
@State private var showChannelDrawer = true
|
||||
/// 当前窗口是否处于全屏。
|
||||
@State private var isWindowFullScreen = false
|
||||
/// 底部频道信息卡最大宽度。
|
||||
private let currentChannelInfoMaxWidth: CGFloat = 600
|
||||
/// AVPlayer 状态观察者。
|
||||
@State private var itemStatusObserver: NSKeyValueObservation?
|
||||
/// 播放失败通知观察者。
|
||||
@State private var playbackFailedObserver: NSObjectProtocol?
|
||||
/// 播放卡顿通知观察者。
|
||||
@State private var playbackStalledObserver: NSObjectProtocol?
|
||||
/// 当前频道已失败的线路索引,用于自动切线去重。
|
||||
@State private var failedSourceIndices: Set<Int> = []
|
||||
/// 当前跟踪的频道 ID(频道切换时重置失败状态)。
|
||||
@State private var trackedChannelId: String = ""
|
||||
/// 底部频道信息是否显示。
|
||||
@State private var showCurrentChannelInfo = true
|
||||
/// 自动隐藏频道信息的定时器。
|
||||
@State private var channelInfoTimer: Timer?
|
||||
/// 频道信息自动隐藏延迟(秒)。
|
||||
private let channelInfoAutoHideDelay: TimeInterval = 3.0
|
||||
/// 用户交互令牌,递增后可通知 VLC 子视图重置自动隐藏逻辑。
|
||||
@State private var vlcInteractionToken = 0
|
||||
|
||||
/// 当前实际生效的播放器内核。
|
||||
/// 优先读取直播专用字段,再回退老字段,最后使用默认值。
|
||||
private var selectedEngine: PlayerEngine {
|
||||
PlayerEngine.fromStoredValue(playTypeRaw)
|
||||
let defaults = UserDefaults.standard
|
||||
let rawValue: Int
|
||||
if defaults.object(forKey: HawkConfig.PLAY_TYPE_LIVE) != nil {
|
||||
rawValue = livePlayTypeRaw
|
||||
} else if defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil {
|
||||
rawValue = legacyPlayTypeRaw
|
||||
} else {
|
||||
rawValue = PlayerEngine.isVLCAvailable
|
||||
? PlayerEngine.vlc.rawValue
|
||||
: PlayerEngine.system.rawValue
|
||||
}
|
||||
return PlayerEngine.fromStoredValue(rawValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -68,6 +99,7 @@ struct LiveView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
// 首次进入时加载频道并展示频道信息卡。
|
||||
viewModel.loadChannels()
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
@@ -80,10 +112,11 @@ struct LiveView: View {
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: viewModel.currentChannel?.id) { _, _ in
|
||||
// 切台后清空失败线路记录,避免复用上个频道的失败状态。
|
||||
resetFailureTracking(for: viewModel.currentChannel)
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: playTypeRaw) { _, _ in
|
||||
.onChange(of: selectedEngine) { _, _ in
|
||||
if selectedEngine == .system {
|
||||
playChannel(url: viewModel.currentChannel?.currentUrl)
|
||||
} else {
|
||||
@@ -352,6 +385,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func reportUserActivity() {
|
||||
// 任意交互都刷新显示时间,并触发 VLC 子层同步交互状态。
|
||||
wakeUpCurrentChannelInfo()
|
||||
vlcInteractionToken &+= 1
|
||||
}
|
||||
@@ -447,6 +481,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func cleanupPlayer() {
|
||||
// 先移除观察者再释放播放器,避免悬空回调。
|
||||
if let observer = playbackFailedObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
playbackFailedObserver = nil
|
||||
@@ -463,6 +498,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func observePlaybackFailure(for item: AVPlayerItem) {
|
||||
// KVO 监听 item 状态失败。
|
||||
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||
if observedItem.status == .failed {
|
||||
DispatchQueue.main.async {
|
||||
@@ -471,6 +507,7 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 播放到结尾失败回调。
|
||||
playbackFailedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: item,
|
||||
@@ -479,6 +516,7 @@ struct LiveView: View {
|
||||
handlePlaybackFailure(trigger: "item_failed")
|
||||
}
|
||||
|
||||
// 播放卡顿回调。
|
||||
playbackStalledObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: item,
|
||||
@@ -514,6 +552,7 @@ struct LiveView: View {
|
||||
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
|
||||
guard failedSourceIndices.count < totalSources else { return false }
|
||||
|
||||
// 最多轮询 `totalSources` 次,找到一条尚未失败的线路即返回。
|
||||
for _ in 0..<totalSources {
|
||||
viewModel.switchSource()
|
||||
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
|
||||
|
||||
@@ -40,12 +40,34 @@ private struct MacOSPlayerView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: AVPlayerView, coordinator: ()) {
|
||||
nsView.player?.pause()
|
||||
nsView.player = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 系统播放器会话控制器:用于在页面内联与全屏视图间复用同一 AVPlayer,避免重复拉流
|
||||
@MainActor
|
||||
final class SystemPlayerSessionController: ObservableObject {
|
||||
fileprivate var player: AVPlayer?
|
||||
fileprivate var mediaURLString: String?
|
||||
|
||||
func setPlayer(_ newPlayer: AVPlayer, urlString: String) {
|
||||
if player !== newPlayer {
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
}
|
||||
player = newPlayer
|
||||
mediaURLString = urlString
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
player = nil
|
||||
mediaURLString = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频播放器组件 - 对应 Android 版 PlayFragment
|
||||
struct PlayerView: View {
|
||||
let urlString: String
|
||||
@@ -55,11 +77,22 @@ struct PlayerView: View {
|
||||
var onToggleFullScreen: (() -> Void)? = nil
|
||||
var canPlayNext: Bool = false
|
||||
var onPlayNext: (() -> Void)? = nil
|
||||
var systemController: SystemPlayerSessionController? = nil
|
||||
var vlcController: VLCPlayerController? = nil
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
|
||||
@AppStorage(HawkConfig.PLAY_TYPE_VOD) private var vodPlayTypeRaw = -1
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||
|
||||
private var selectedEngine: PlayerEngine {
|
||||
PlayerEngine.fromStoredValue(playTypeRaw)
|
||||
let defaults = UserDefaults.standard
|
||||
let rawValue: Int
|
||||
if defaults.object(forKey: HawkConfig.PLAY_TYPE_VOD) != nil {
|
||||
rawValue = vodPlayTypeRaw
|
||||
} else if defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil {
|
||||
rawValue = legacyPlayTypeRaw
|
||||
} else {
|
||||
rawValue = PlayerEngine.system.rawValue
|
||||
}
|
||||
return PlayerEngine.fromStoredValue(rawValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -73,7 +106,8 @@ struct PlayerView: View {
|
||||
onPlaybackEnded: onPlaybackEnded,
|
||||
onToggleFullScreen: onToggleFullScreen,
|
||||
canPlayNext: canPlayNext,
|
||||
onPlayNext: onPlayNext
|
||||
onPlayNext: onPlayNext,
|
||||
sharedController: systemController
|
||||
)
|
||||
case .vlc:
|
||||
VLCVodPlayerView(
|
||||
@@ -89,6 +123,22 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
.id(selectedEngine.rawValue)
|
||||
.onAppear {
|
||||
if selectedEngine != .system {
|
||||
systemController?.stop()
|
||||
}
|
||||
if selectedEngine != .vlc {
|
||||
vlcController?.stop()
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedEngine) { _, newValue in
|
||||
if newValue != .system {
|
||||
systemController?.stop()
|
||||
}
|
||||
if newValue != .vlc {
|
||||
vlcController?.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +151,7 @@ struct AVPlayerContentView: View {
|
||||
var onToggleFullScreen: (() -> Void)? = nil
|
||||
var canPlayNext: Bool = false
|
||||
var onPlayNext: (() -> Void)? = nil
|
||||
var sharedController: SystemPlayerSessionController? = nil
|
||||
@State private var player: AVPlayer?
|
||||
@State private var playbackEndObserver: NSObjectProtocol?
|
||||
@State private var timeObserverToken: Any?
|
||||
@@ -195,7 +246,7 @@ struct AVPlayerContentView: View {
|
||||
wakeUpControls()
|
||||
}
|
||||
.onDisappear {
|
||||
cleanupPlayer()
|
||||
cleanupPlayer(keepSharedPlayer: sharedController != nil)
|
||||
controlsTimer?.invalidate()
|
||||
osdTimer?.invalidate()
|
||||
}
|
||||
@@ -203,40 +254,60 @@ struct AVPlayerContentView: View {
|
||||
|
||||
private func setupPlayer() {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
let targetURLString = url.absoluteString
|
||||
|
||||
if let sharedController,
|
||||
sharedController.mediaURLString == targetURLString,
|
||||
let sharedPlayer = sharedController.player {
|
||||
cleanupPlayer(keepSharedPlayer: true)
|
||||
player = sharedPlayer
|
||||
bindPlayerObservers(for: sharedPlayer)
|
||||
reportProgress(for: sharedPlayer)
|
||||
return
|
||||
}
|
||||
|
||||
// 清理旧播放器
|
||||
cleanupPlayer()
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
let newPlayer = AVPlayer(playerItem: playerItem)
|
||||
if let sharedController {
|
||||
sharedController.setPlayer(newPlayer, urlString: targetURLString)
|
||||
}
|
||||
|
||||
// 设置监听
|
||||
player = newPlayer
|
||||
bindPlayerObservers(for: newPlayer)
|
||||
startPlayback(for: newPlayer)
|
||||
}
|
||||
|
||||
private func bindPlayerObservers(for player: AVPlayer) {
|
||||
playerObservers = [
|
||||
newPlayer.observe(\.timeControlStatus, options: [.new]) { p, _ in
|
||||
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
|
||||
let status = p.timeControlStatus
|
||||
DispatchQueue.main.async { isPlaying = status == .playing }
|
||||
},
|
||||
newPlayer.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in
|
||||
player.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in
|
||||
let reason = p.reasonForWaitingToPlay
|
||||
DispatchQueue.main.async { isPreparing = reason != nil }
|
||||
},
|
||||
newPlayer.observe(\.volume, options: [.new]) { p, _ in
|
||||
player.observe(\.volume, options: [.new]) { p, _ in
|
||||
let vol = Double(p.volume)
|
||||
DispatchQueue.main.async { volume = vol }
|
||||
},
|
||||
newPlayer.observe(\.rate, options: [.new]) { p, _ in
|
||||
player.observe(\.rate, options: [.new]) { p, _ in
|
||||
let r = p.rate
|
||||
DispatchQueue.main.async { rate = r }
|
||||
}
|
||||
]
|
||||
|
||||
observePlaybackProgress(for: newPlayer)
|
||||
observePlaybackEnd(for: newPlayer)
|
||||
player = newPlayer
|
||||
startPlayback(for: newPlayer)
|
||||
observePlaybackProgress(for: player)
|
||||
observePlaybackEnd(for: player)
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
isPreparing = player.reasonForWaitingToPlay != nil
|
||||
volume = Double(player.volume)
|
||||
rate = player.rate
|
||||
}
|
||||
|
||||
private func cleanupPlayer() {
|
||||
private func detachPlayerObservers() {
|
||||
if let token = timeObserverToken {
|
||||
player?.removeTimeObserver(token)
|
||||
timeObserverToken = nil
|
||||
@@ -247,8 +318,24 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
playerObservers.forEach { $0.invalidate() }
|
||||
playerObservers.removeAll()
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
private func cleanupPlayer(keepSharedPlayer: Bool = false) {
|
||||
let currentPlayer = player
|
||||
detachPlayerObservers()
|
||||
|
||||
guard let currentPlayer else { return }
|
||||
if keepSharedPlayer, sharedController?.player === currentPlayer {
|
||||
player = nil
|
||||
return
|
||||
}
|
||||
|
||||
currentPlayer.pause()
|
||||
currentPlayer.replaceCurrentItem(with: nil)
|
||||
if sharedController?.player === currentPlayer {
|
||||
sharedController?.player = nil
|
||||
sharedController?.mediaURLString = nil
|
||||
}
|
||||
player = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
private static let maxVolume = 200
|
||||
private static let drawableSizeChangeThreshold: CGFloat = 24
|
||||
private static let drawableRebindMinimumInterval: TimeInterval = 1.2
|
||||
// 这些选项在播放器实例级别生效,优先约束 libvlc 的追时钟/丢帧行为。
|
||||
private static let stablePlaybackOptions: [String] = [
|
||||
"--no-drop-late-frames",
|
||||
"--no-skip-frames",
|
||||
"--clock-synchro=0",
|
||||
"--clock-jitter=0"
|
||||
]
|
||||
private static let playerInstanceSelector = NSSelectorFromString("playerInstance")
|
||||
private static let libVLCStopAsync: LibVLCStopAsyncFunction? = {
|
||||
// RTLD_DEFAULT 在 Swift 中不可直接用常量名,-2 等价于 C 宏 RTLD_DEFAULT。
|
||||
@@ -25,7 +32,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}()
|
||||
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
||||
|
||||
let mediaPlayer = VLCMediaPlayer()
|
||||
let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
|
||||
@Published var isPreparing = true
|
||||
@Published var isPlaying = false
|
||||
@Published var currentTimeSeconds: Double = 0
|
||||
@@ -60,10 +67,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
private var bufferMode: VLCBufferMode = .defaultMode
|
||||
private var bufferingFallbackWorkItem: DispatchWorkItem?
|
||||
private var delayedPreparingWorkItem: DispatchWorkItem?
|
||||
private var bufferingEventCountVod = 0
|
||||
private var lastVodBufferingCountedAt: Date = .distantPast
|
||||
private var hasAttemptedVodCacheBoost = false
|
||||
private var useVodCacheBoost = false
|
||||
private var currentMediaURLString: String?
|
||||
private var currentMediaIsLive = false
|
||||
private var currentMediaDecodeMode: VideoDecodeMode = .auto
|
||||
@@ -73,16 +76,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
private var onPlaybackFailed: (() -> Void)?
|
||||
private let progressUpdateIntervalVod: TimeInterval = 0.5
|
||||
private let progressUpdateIntervalLive: TimeInterval = 1.0
|
||||
private let bufferingFallbackThresholdVod: TimeInterval = 6.0
|
||||
private let bufferingFallbackThresholdLive: TimeInterval = 4.0
|
||||
private let bufferingConfirmDelayVod: TimeInterval = 0.35
|
||||
private let bufferingIndicatorDelayVod: TimeInterval = 1.2
|
||||
private let vodBufferingEventDebounceInterval: TimeInterval = 1.5
|
||||
private let vodBufferingProgressAdvanceThreshold: Double = 0.25
|
||||
private let vodCacheBoostTriggerCount = 2
|
||||
private let vodCacheBoostExtraNetwork = 4500
|
||||
private let vodCacheBoostExtraLive = 3500
|
||||
private let vodCacheBoostExtraFile = 5500
|
||||
private let progressPublishThreshold: Double = 0.25
|
||||
private let durationPublishThreshold: Double = 0.5
|
||||
private var lastNonZeroVolume = defaultVolume
|
||||
@@ -163,22 +160,19 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
mediaPlayer.stop()
|
||||
let media = VLCMedia(url: url)
|
||||
|
||||
var cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode)
|
||||
if !isLive && useVodCacheBoost {
|
||||
cacheConfig.network += vodCacheBoostExtraNetwork
|
||||
cacheConfig.live += vodCacheBoostExtraLive
|
||||
cacheConfig.file += vodCacheBoostExtraFile
|
||||
}
|
||||
let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : true
|
||||
let cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode)
|
||||
let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : false
|
||||
let enableSkipFrames = isLive && bufferMode.enableFrameDrop
|
||||
var mediaOptions: [String: Any] = [
|
||||
"network-caching": cacheConfig.network,
|
||||
"live-caching": cacheConfig.live,
|
||||
"file-caching": cacheConfig.file,
|
||||
"drop-late-frames": enableFrameDrop ? 1 : 0,
|
||||
"skip-frames": enableSkipFrames ? 1 : 0,
|
||||
"http-reconnect": 1
|
||||
]
|
||||
if !isLive {
|
||||
mediaOptions["avcodec-hurry-up"] = 0
|
||||
mediaOptions["clock-synchro"] = 0
|
||||
}
|
||||
|
||||
if let hwOption = decodeMode.vlcHardwareDecodeOption {
|
||||
mediaOptions["avcodec-hw"] = hwOption
|
||||
@@ -191,6 +185,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}
|
||||
|
||||
media.addOptions(mediaOptions)
|
||||
// 对布尔型选项使用显式 no- 前缀,避免 0/1 在不同 libvlc 版本下解释不一致。
|
||||
media.addOption(enableFrameDrop ? "drop-late-frames" : "no-drop-late-frames")
|
||||
media.addOption(enableSkipFrames ? "skip-frames" : "no-skip-frames")
|
||||
|
||||
mediaPlayer.media = media
|
||||
mediaPlayer.play()
|
||||
@@ -580,11 +577,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}
|
||||
|
||||
private func applyPlaybackRate() {
|
||||
guard mediaPlayer.rate != playbackRate else { return }
|
||||
mediaPlayer.rate = playbackRate
|
||||
}
|
||||
|
||||
private func applyVolume() {
|
||||
mediaPlayer.audio?.volume = Int32(volume)
|
||||
let target = Int32(volume)
|
||||
guard mediaPlayer.audio?.volume != target else { return }
|
||||
mediaPlayer.audio?.volume = target
|
||||
}
|
||||
|
||||
private func syncDecodeModeFromSettings() {
|
||||
@@ -617,13 +617,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}
|
||||
|
||||
private func scheduleBufferingFallbackIfNeeded() {
|
||||
guard isLive else { return }
|
||||
guard bufferingFallbackWorkItem == nil else { return }
|
||||
guard !hasAttemptedSoftDecodeFallback else { return }
|
||||
if !isLive && !hasAttemptedVodCacheBoost { return }
|
||||
guard decodeMode != .software else { return }
|
||||
guard mediaPlayer.media != nil else { return }
|
||||
|
||||
let delay = isLive ? bufferingFallbackThresholdLive : bufferingFallbackThresholdVod
|
||||
let delay = bufferingFallbackThresholdLive
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.attemptSoftDecodeFallbackIfNeeded()
|
||||
@@ -673,7 +673,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
isPlaying = false
|
||||
}
|
||||
scheduleDelayedPreparingIndicatorForVod()
|
||||
scheduleBufferingFallbackIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -736,24 +735,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
guard !mediaPlayer.isPlaying else { return }
|
||||
if !isInBufferingState {
|
||||
isInBufferingState = true
|
||||
markVodBufferingEventIfNeeded()
|
||||
if shouldAttemptVodCacheBoost() {
|
||||
attemptVodCacheBoostIfNeeded()
|
||||
return
|
||||
}
|
||||
}
|
||||
if isPlaying {
|
||||
isPlaying = false
|
||||
}
|
||||
scheduleDelayedPreparingIndicatorForVod()
|
||||
scheduleBufferingFallbackIfNeeded()
|
||||
}
|
||||
|
||||
private func markVodBufferingEventIfNeeded() {
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastVodBufferingCountedAt) >= vodBufferingEventDebounceInterval else { return }
|
||||
lastVodBufferingCountedAt = now
|
||||
bufferingEventCountVod += 1
|
||||
}
|
||||
|
||||
private func isStillStalledSinceBufferingStartedVod() -> Bool {
|
||||
@@ -770,30 +756,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldAttemptVodCacheBoost() -> Bool {
|
||||
!isLive && !hasAttemptedVodCacheBoost && bufferingEventCountVod >= vodCacheBoostTriggerCount
|
||||
}
|
||||
|
||||
private func attemptVodCacheBoostIfNeeded() {
|
||||
guard !isLive else { return }
|
||||
guard !hasAttemptedVodCacheBoost else { return }
|
||||
guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return }
|
||||
guard mediaPlayer.media != nil else { return }
|
||||
hasAttemptedVodCacheBoost = true
|
||||
useVodCacheBoost = true
|
||||
cancelDelayedPreparingIndicator()
|
||||
cancelBufferingFallbackTimer()
|
||||
let resumePosition = max(currentSeconds(), 0)
|
||||
play(
|
||||
url: url,
|
||||
startPosition: resumePosition,
|
||||
isLive: false,
|
||||
onProgressChanged: onProgressChanged,
|
||||
onPlaybackEnded: onPlaybackEnded,
|
||||
onPlaybackFailed: onPlaybackFailed
|
||||
)
|
||||
}
|
||||
|
||||
private func resetPlaybackRecoveryState() {
|
||||
cancelBufferingFallbackTimer()
|
||||
cancelDelayedPreparingIndicator()
|
||||
@@ -802,10 +764,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
decodeModeOverride = nil
|
||||
isInBufferingState = false
|
||||
bufferingBaselineSecondsVod = 0
|
||||
bufferingEventCountVod = 0
|
||||
lastVodBufferingCountedAt = .distantPast
|
||||
hasAttemptedVodCacheBoost = false
|
||||
useVodCacheBoost = false
|
||||
}
|
||||
|
||||
private func setPlaybackStatus(preparing: Bool, playing: Bool) {
|
||||
|
||||
@@ -2,13 +2,16 @@ import SwiftUI
|
||||
|
||||
/// 搜索页 - 对应 Android 版 SearchActivity
|
||||
struct SearchView: View {
|
||||
/// 搜索状态与结果管理。
|
||||
@StateObject private var viewModel = SearchViewModel()
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 卡片网格参数。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 卡片网格参数。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -29,6 +32,7 @@ struct SearchView: View {
|
||||
} else if !viewModel.results.isEmpty {
|
||||
searchResults
|
||||
} else if viewModel.keyword.isEmpty {
|
||||
// 输入为空时显示历史;输入非空但无结果时显示提示文案。
|
||||
searchHistorySection
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
@@ -53,6 +57,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索栏
|
||||
|
||||
/// 顶部搜索输入区。
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -114,6 +119,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索结果
|
||||
|
||||
/// 搜索结果网格。
|
||||
private var searchResults: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
@@ -134,6 +140,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索历史
|
||||
|
||||
/// 搜索历史区域,支持复用历史关键词与一键清空。
|
||||
private var searchHistorySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !viewModel.searchHistory.isEmpty {
|
||||
@@ -183,13 +190,16 @@ struct SearchView: View {
|
||||
|
||||
/// 流式布局
|
||||
struct FlowLayout: Layout {
|
||||
/// 子项间距。
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
/// 计算整体尺寸。
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = arrangement(proposal: proposal, subviews: subviews)
|
||||
return result.size
|
||||
}
|
||||
|
||||
/// 按计算结果放置子视图。
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
|
||||
for (index, position) in result.positions.enumerated() {
|
||||
@@ -197,6 +207,7 @@ struct FlowLayout: Layout {
|
||||
}
|
||||
}
|
||||
|
||||
/// 核心排版算法:按最大宽度逐个放置,超宽后自动换行。
|
||||
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var positions: [CGPoint] = []
|
||||
|
||||
@@ -2,17 +2,38 @@ import SwiftUI
|
||||
|
||||
/// 设置页 - 对应 Android 版 SettingActivity + ModelSettingFragment
|
||||
struct SettingsView: View {
|
||||
enum ApiInputType {
|
||||
case vod
|
||||
case live
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .vod: return "点播接口地址"
|
||||
case .live: return "直播接口地址"
|
||||
}
|
||||
}
|
||||
|
||||
var placeholder: String {
|
||||
switch self {
|
||||
case .vod: return "请输入点播接口地址"
|
||||
case .live: return "请输入直播接口地址(可留空跟随点播)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@StateObject private var apiConfig = ApiConfig.shared
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var showApiInput = false
|
||||
@State private var editingApiType: ApiInputType = .vod
|
||||
@State private var showAbout = false
|
||||
@State private var sourceSearchText = ""
|
||||
@State private var showingPicker: PickerType = .none
|
||||
|
||||
enum PickerType {
|
||||
case none
|
||||
case player
|
||||
case vodPlayer
|
||||
case livePlayer
|
||||
case decode
|
||||
case vlcBuffer
|
||||
case playTimeStep
|
||||
@@ -24,7 +45,21 @@ struct SettingsView: View {
|
||||
VStack(spacing: 24) {
|
||||
// API 配置
|
||||
SectionCard(title: "数据源") {
|
||||
SettingsRow(icon: "link", title: "接口地址", value: viewModel.apiUrl.isEmpty ? "未配置" : viewModel.apiUrl) {
|
||||
SettingsRow(
|
||||
icon: "film",
|
||||
title: "点播接口地址",
|
||||
value: viewModel.vodApiUrl.isEmpty ? "未配置" : viewModel.vodApiUrl
|
||||
) {
|
||||
editingApiType = .vod
|
||||
showApiInput = true
|
||||
}
|
||||
Divider().background(Color.white.opacity(0.1))
|
||||
SettingsRow(
|
||||
icon: "tv",
|
||||
title: "直播接口地址",
|
||||
value: viewModel.liveApiUrl.isEmpty ? "跟随点播接口" : viewModel.liveApiUrl
|
||||
) {
|
||||
editingApiType = .live
|
||||
showApiInput = true
|
||||
}
|
||||
Divider().background(Color.white.opacity(0.1))
|
||||
@@ -39,9 +74,15 @@ struct SettingsView: View {
|
||||
|
||||
// 播放设置
|
||||
SectionCard(title: "播放设置") {
|
||||
SettingsRow(icon: "play.rectangle", title: "播放器", value: viewModel.playerEngine.title) {
|
||||
SettingsRow(icon: "play.rectangle", title: "点播播放器", value: viewModel.vodPlayerEngine.title) {
|
||||
if viewModel.playerEngineOptions.count > 1 {
|
||||
showingPicker = .player
|
||||
showingPicker = .vodPlayer
|
||||
}
|
||||
}
|
||||
Divider().background(Color.white.opacity(0.1))
|
||||
SettingsRow(icon: "dot.radiowaves.left.and.right", title: "直播播放器", value: viewModel.livePlayerEngine.title) {
|
||||
if viewModel.playerEngineOptions.count > 1 {
|
||||
showingPicker = .livePlayer
|
||||
}
|
||||
}
|
||||
Divider().background(Color.white.opacity(0.1))
|
||||
@@ -115,15 +156,28 @@ struct SettingsView: View {
|
||||
@ViewBuilder
|
||||
private var pickerOverlay: some View {
|
||||
switch showingPicker {
|
||||
case .player:
|
||||
case .vodPlayer:
|
||||
SelectionModal(
|
||||
title: "选择播放器",
|
||||
title: "选择点播播放器",
|
||||
icon: "play.rectangle.fill",
|
||||
items: viewModel.playerEngineOptions,
|
||||
selectedItem: viewModel.playerEngine,
|
||||
selectedItem: viewModel.vodPlayerEngine,
|
||||
itemTitle: { $0.title },
|
||||
onSelect: { engine in
|
||||
viewModel.setPlayerEngine(engine)
|
||||
viewModel.setVodPlayerEngine(engine)
|
||||
showingPicker = .none
|
||||
},
|
||||
onCancel: { showingPicker = .none }
|
||||
)
|
||||
case .livePlayer:
|
||||
SelectionModal(
|
||||
title: "选择直播播放器",
|
||||
icon: "dot.radiowaves.left.and.right",
|
||||
items: viewModel.playerEngineOptions,
|
||||
selectedItem: viewModel.livePlayerEngine,
|
||||
itemTitle: { $0.title },
|
||||
onSelect: { engine in
|
||||
viewModel.setLivePlayerEngine(engine)
|
||||
showingPicker = .none
|
||||
},
|
||||
onCancel: { showingPicker = .none }
|
||||
@@ -180,7 +234,7 @@ struct SettingsView: View {
|
||||
HStack {
|
||||
Image(systemName: "link")
|
||||
.foregroundColor(.secondary)
|
||||
TextField("请输入接口地址", text: $viewModel.apiUrl)
|
||||
TextField(editingApiType.placeholder, text: currentApiBinding)
|
||||
.textFieldStyle(.plain)
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
@@ -194,15 +248,9 @@ struct SettingsView: View {
|
||||
// 粘贴按钮
|
||||
HStack {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
if let text = UIPasteboard.general.string {
|
||||
viewModel.apiUrl = text
|
||||
if let text = readPasteboardText() {
|
||||
currentApiBinding.wrappedValue = text
|
||||
}
|
||||
#else
|
||||
if let text = NSPasteboard.general.string(forType: .string) {
|
||||
viewModel.apiUrl = text
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
Label("粘贴", systemImage: "doc.on.clipboard")
|
||||
.font(.subheadline)
|
||||
@@ -221,7 +269,7 @@ struct SettingsView: View {
|
||||
ForEach(viewModel.apiHistory, id: \.self) { url in
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.apiUrl = url
|
||||
currentApiBinding.wrappedValue = url
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
@@ -256,7 +304,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("配置接口")
|
||||
.navigationTitle(editingApiType.title)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
@@ -269,7 +317,7 @@ struct SettingsView: View {
|
||||
Task {
|
||||
await viewModel.loadConfig()
|
||||
if viewModel.configSuccess {
|
||||
await appState.loadConfig(url: viewModel.apiUrl)
|
||||
appState.applyLoadedConfigState()
|
||||
showApiInput = false
|
||||
}
|
||||
}
|
||||
@@ -280,15 +328,61 @@ struct SettingsView: View {
|
||||
Text("确认")
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty)
|
||||
.disabled(
|
||||
viewModel.isLoadingConfig
|
||||
|| viewModel.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(multiRepoSelectionOverlay)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium, .large])
|
||||
#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> {
|
||||
switch editingApiType {
|
||||
case .vod:
|
||||
return $viewModel.vodApiUrl
|
||||
case .live:
|
||||
return $viewModel.liveApiUrl
|
||||
}
|
||||
}
|
||||
|
||||
private func readPasteboardText() -> String? {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string
|
||||
#else
|
||||
NSPasteboard.general.string(forType: .string)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - 源选择
|
||||
|
||||
private var filteredSources: [SourceBean] {
|
||||
|
||||
+38
-5
@@ -1,10 +1,15 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 应用入口。
|
||||
/// 负责初始化 SwiftData 容器,并将全局状态 `AppState` 注入到根视图。
|
||||
@main
|
||||
struct tvboxApp: App {
|
||||
/// 全局运行时状态(配置加载状态、当前源、分栏布局状态等)。
|
||||
@StateObject private var appState = AppState()
|
||||
|
||||
/// 全局共享的 SwiftData 容器。
|
||||
/// 这里显式声明 Schema,确保收藏/历史/缓存三类数据使用同一持久化存储。
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
VodCollect.self,
|
||||
@@ -19,6 +24,7 @@ struct tvboxApp: App {
|
||||
}
|
||||
}()
|
||||
|
||||
/// 应用窗口与根视图。
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
@@ -31,29 +37,55 @@ struct tvboxApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用级状态容器。
|
||||
/// 统一管理配置加载与页面共享状态,避免在各页面重复拉取配置。
|
||||
@MainActor
|
||||
class AppState: ObservableObject {
|
||||
/// 解析后的配置单例,提供给所有页面与 ViewModel 使用。
|
||||
@Published var apiConfig = ApiConfig.shared
|
||||
/// 配置是否已经成功加载。控制 `ContentView` 显示主界面或首次配置页。
|
||||
@Published var isConfigLoaded = false
|
||||
/// 当前首页选中的视频源 key(用于跨页面同步)。
|
||||
@Published var currentSourceKey: String = ""
|
||||
#if os(macOS)
|
||||
/// macOS 三栏布局可见性(侧栏/内容/详情)。
|
||||
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all
|
||||
/// 进入播放器全屏前的分栏状态快照,用于退出全屏后恢复。
|
||||
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
|
||||
#endif
|
||||
|
||||
/// 仅提供点播地址时的快捷加载入口(直播地址默认与点播一致)。
|
||||
func loadConfig(url: String) async {
|
||||
await loadConfig(vodUrl: url, liveUrl: nil)
|
||||
}
|
||||
|
||||
/// 加载点播与直播配置。
|
||||
/// - Parameters:
|
||||
/// - vodUrl: 点播配置地址
|
||||
/// - liveUrl: 直播配置地址;为空时自动回退到点播地址
|
||||
func loadConfig(vodUrl: String, liveUrl: String?) async {
|
||||
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedVod.isEmpty else { return }
|
||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||
|
||||
do {
|
||||
try await ApiConfig.shared.loadConfig(from: url)
|
||||
await MainActor.run {
|
||||
self.isConfigLoaded = true
|
||||
self.currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
|
||||
}
|
||||
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
|
||||
applyLoadedConfigState()
|
||||
} catch {
|
||||
print("Failed to load config: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 将“配置已加载”的统一状态写回全局。
|
||||
/// 该方法会在设置页和启动自动加载两个入口中复用。
|
||||
func applyLoadedConfigState() {
|
||||
isConfigLoaded = true
|
||||
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// 进入播放器全屏时隐藏侧栏,减少播放器可视区域干扰。
|
||||
func enterPlayerFullScreen() {
|
||||
if splitViewVisibilityBeforePlayerFullScreen == nil {
|
||||
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
|
||||
@@ -61,6 +93,7 @@ class AppState: ObservableObject {
|
||||
splitViewVisibility = .detailOnly
|
||||
}
|
||||
|
||||
/// 退出播放器全屏时恢复之前的分栏状态。
|
||||
func exitPlayerFullScreen() {
|
||||
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
|
||||
splitViewVisibility = previous
|
||||
|
||||
Reference in New Issue
Block a user