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