Compare commits
3
Commits
200301
..
Release1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54de47deb1 | ||
|
|
3f302f83b3 | ||
|
|
a89ce6eb69 |
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>development</string> <!-- 可选: ad-hoc, enterprise, app-store, development -->
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string></string> <!-- 此处填写您的 Apple Team ID -->
|
||||||
|
<key>thinning</key>
|
||||||
|
<string><none></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -99,6 +99,23 @@ xcodegen generate
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### iOS 版本打包
|
||||||
|
|
||||||
|
1. 确保已安装通用依赖:
|
||||||
|
- Xcode 15+
|
||||||
|
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (可选,用于生成工程)
|
||||||
|
|
||||||
|
2. 运行打包脚本:
|
||||||
|
```bash
|
||||||
|
chmod +x package_ios.sh
|
||||||
|
./package_ios.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **注意**:
|
||||||
|
- 脚本默认生成 `.xcarchive` 文件。
|
||||||
|
- 如需自动生成 `.ipa`,请在 `ExportOptions.plist` 中填写您的 `teamID`。
|
||||||
|
- 您也可以在 Xcode 中打开 `tvbox.xcodeproj`,选择 `tvbox` scheme,然后点击 `Product > Archive` 手动打包。
|
||||||
|
|
||||||
## 📦 macOS 独立打包
|
## 📦 macOS 独立打包
|
||||||
|
|
||||||
项目已内置自动化打包脚本 `package_mac.sh`。当您需要分发 macOS 版本时,直接运行即可自动清理缓存、构建并生成免安装的 `.dmg` 拖拽式安装包。
|
项目已内置自动化打包脚本 `package_mac.sh`。当您需要分发 macOS 版本时,直接运行即可自动清理缓存、构建并生成免安装的 `.dmg` 拖拽式安装包。
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 设置工作目录为脚本所在目录
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "清理构建目录..."
|
||||||
|
rm -rf build
|
||||||
|
rm -rf tvbox.xcarchive
|
||||||
|
rm -f TVBox.ipa
|
||||||
|
|
||||||
|
SCHEME="tvbox"
|
||||||
|
CONFIGURATION="Release"
|
||||||
|
ARCHIVE_PATH="build/tvbox.xcarchive"
|
||||||
|
EXPORT_PATH="build/exported"
|
||||||
|
EXPORT_OPTIONS="ExportOptions.plist"
|
||||||
|
|
||||||
|
echo "开始构建 iOS Archive..."
|
||||||
|
xcodebuild archive \
|
||||||
|
-project tvbox.xcodeproj \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIGURATION" \
|
||||||
|
-destination "generic/platform=iOS" \
|
||||||
|
-archivePath "$ARCHIVE_PATH"
|
||||||
|
|
||||||
|
if [ -f "$EXPORT_OPTIONS" ]; then
|
||||||
|
echo "发现 $EXPORT_OPTIONS,尝试导出 IPA..."
|
||||||
|
mkdir -p "$EXPORT_PATH"
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-exportOptionsPlist "$EXPORT_OPTIONS" \
|
||||||
|
-exportPath "$EXPORT_PATH" \
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
|
||||||
|
# 查找生成的 .ipa 文件并移动到根目录
|
||||||
|
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -n 1)
|
||||||
|
if [ -n "$IPA_FILE" ]; then
|
||||||
|
cp "$IPA_FILE" ./TVBox.ipa
|
||||||
|
echo "✅ 打包完成!生成文件: TVBox.ipa"
|
||||||
|
else
|
||||||
|
echo "⚠️ 未能在导出目录中找到 .ipa 文件。"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ 未找到 $EXPORT_OPTIONS,仅生成 Archive。"
|
||||||
|
echo "✅ 构建完成!Archive 路径: $ARCHIVE_PATH"
|
||||||
|
echo "您可以打开 Xcode 使用 Distribute App 手动导出 IPA。"
|
||||||
|
fi
|
||||||
@@ -122,12 +122,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,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,8 +22,36 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分别加载点播配置和直播配置
|
||||||
|
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 config = try await fetchConfig(from: trimmedVod)
|
||||||
|
parseConfig(config, apiUrl: trimmedVod, includeSources: true, includeLive: true)
|
||||||
|
} else {
|
||||||
|
let vodConfig = try await fetchConfig(from: trimmedVod)
|
||||||
|
parseConfig(vodConfig, apiUrl: trimmedVod, includeSources: true, includeLive: false)
|
||||||
|
|
||||||
|
let liveConfig = try await fetchConfig(from: resolvedLive)
|
||||||
|
parseConfig(liveConfig, apiUrl: resolvedLive, includeSources: false, includeLive: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchConfig(from apiUrl: String) async throws -> AppConfigData {
|
||||||
let jsonStr = try await network.getString(from: apiUrl)
|
let jsonStr = try await network.getString(from: apiUrl)
|
||||||
|
|
||||||
// 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理)
|
// 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理)
|
||||||
@@ -32,8 +61,7 @@ class ApiConfig: ObservableObject {
|
|||||||
throw ConfigError.parseError("无法解析配置数据")
|
throw ConfigError.parseError("无法解析配置数据")
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = try JSONDecoder().decode(AppConfigData.self, from: data)
|
return try JSONDecoder().decode(AppConfigData.self, from: data)
|
||||||
parseConfig(config, apiUrl: apiUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式
|
/// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式
|
||||||
@@ -97,59 +125,67 @@ class ApiConfig: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 解析配置数据
|
/// 解析配置数据
|
||||||
private func parseConfig(_ config: AppConfigData, apiUrl: String) {
|
private func parseConfig(
|
||||||
// 解析站点列表
|
_ config: AppConfigData,
|
||||||
var sources: [SourceBean] = []
|
apiUrl: String,
|
||||||
if let sites = config.sites {
|
includeSources: Bool,
|
||||||
for site in sites {
|
includeLive: Bool
|
||||||
let bean = SourceBean(
|
) {
|
||||||
key: site.key ?? UUID().uuidString,
|
if includeSources {
|
||||||
name: site.name ?? "未命名",
|
// 解析站点列表
|
||||||
api: site.api ?? "",
|
var sources: [SourceBean] = []
|
||||||
searchable: site.searchable?.value ?? 1,
|
if let sites = config.sites {
|
||||||
filterable: site.filterable?.value ?? 1,
|
for site in sites {
|
||||||
playerType: site.playerType?.value ?? 0,
|
let bean = SourceBean(
|
||||||
type: site.type?.value ?? 1,
|
key: site.key ?? UUID().uuidString,
|
||||||
ext: site.ext?.stringValue
|
name: site.name ?? "未命名",
|
||||||
)
|
api: site.api ?? "",
|
||||||
sources.append(bean)
|
searchable: site.searchable?.value ?? 1,
|
||||||
|
filterable: site.filterable?.value ?? 1,
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析直播列表
|
/// 解析直播列表
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import SwiftUI
|
|||||||
/// 设置 ViewModel
|
/// 设置 ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class SettingsViewModel: ObservableObject {
|
class SettingsViewModel: ObservableObject {
|
||||||
@Published var apiUrl: String = ""
|
@Published var vodApiUrl: String = ""
|
||||||
|
@Published var liveApiUrl: String = ""
|
||||||
@Published var isLoadingConfig = false
|
@Published var isLoadingConfig = false
|
||||||
@Published var configError: String?
|
@Published var configError: String?
|
||||||
@Published var configSuccess = false
|
@Published var configSuccess = false
|
||||||
@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
|
||||||
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
||||||
@Published var playTimeStep: Int = 10
|
@Published var playTimeStep: Int = 10
|
||||||
@@ -21,28 +23,51 @@ class SettingsViewModel: ObservableObject {
|
|||||||
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +76,14 @@ class SettingsViewModel: ObservableObject {
|
|||||||
configSuccess = false
|
configSuccess = false
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await ApiConfig.shared.loadConfig(from: trimmed)
|
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||||
UserDefaults.standard.set(trimmed, forKey: HawkConfig.API_URL)
|
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
|
||||||
addToApiHistory(trimmed)
|
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
|
||||||
@@ -97,11 +127,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置视频解码模式
|
/// 设置视频解码模式
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ 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
|
||||||
@StateObject private var settingsVM = SettingsViewModel()
|
@StateObject private var settingsVM = SettingsViewModel()
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@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 {
|
||||||
@@ -18,10 +24,12 @@ struct ContentView: View {
|
|||||||
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,24 +177,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 +234,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 +254,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 +269,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 +316,12 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func readPasteboardText() -> String? {
|
||||||
|
#if os(iOS)
|
||||||
|
UIPasteboard.general.string
|
||||||
|
#else
|
||||||
|
NSPasteboard.general.string(forType: .string)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ struct LiveView: View {
|
|||||||
@StateObject private var viewModel = LiveViewModel()
|
@StateObject private var viewModel = LiveViewModel()
|
||||||
@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
|
||||||
@@ -24,7 +25,18 @@ struct LiveView: View {
|
|||||||
@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 {
|
||||||
@@ -83,7 +95,7 @@ struct LiveView: View {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,7 +328,10 @@ struct SettingsView: View {
|
|||||||
Text("确认")
|
Text("确认")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty)
|
.disabled(
|
||||||
|
viewModel.isLoadingConfig
|
||||||
|
|| viewModel.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,6 +340,23 @@ struct SettingsView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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] {
|
||||||
|
|||||||
+16
-5
@@ -42,17 +42,28 @@ class AppState: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func loadConfig(url: String) async {
|
func loadConfig(url: String) async {
|
||||||
|
await loadConfig(vodUrl: url, liveUrl: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user