3 Commits
Author SHA1 Message Date
JinJiangHuang 54de47deb1 fix bug 2026-03-01 20:34:43 +08:00
JinJiangHuang 3f302f83b3 fix bugs 2026-03-01 18:40:03 +08:00
JinJiangHuang a89ce6eb69 add iOS package shell 2026-03-01 18:16:39 +08:00
14 changed files with 577 additions and 212 deletions
+16
View File
@@ -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>&lt;none&gt;</string>
</dict>
</plist>
+17
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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)
} }
} }
} }
+45 -9
View File
@@ -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)
// JSONAndroid Gson Swift // JSONAndroid 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,7 +125,13 @@ class ApiConfig: ObservableObject {
} }
/// ///
private func parseConfig(_ config: AppConfigData, apiUrl: String) { private func parseConfig(
_ config: AppConfigData,
apiUrl: String,
includeSources: Bool,
includeLive: Bool
) {
if includeSources {
// //
var sources: [SourceBean] = [] var sources: [SourceBean] = []
if let sites = config.sites { if let sites = config.sites {
@@ -143,13 +177,15 @@ class ApiConfig: ObservableObject {
// //
self.wallpaper = config.wallpaper ?? "" self.wallpaper = config.wallpaper ?? ""
//
if let lives = config.lives {
parseLives(lives, apiUrl: apiUrl)
} }
self.isLoaded = true if includeLive {
if let lives = config.lives {
parseLives(lives, apiUrl: apiUrl)
} else {
liveChannelGroupList = []
}
}
} }
/// ///
+2
View File
@@ -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"
+55 -18
View File
@@ -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)
} }
/// ///
+62 -14
View File
@@ -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
}
} }
+36 -10
View File
@@ -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()
+15 -3
View File
@@ -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 {
+105 -18
View File
@@ -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
} }
+23 -65
View File
@@ -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) {
+89 -21
View File
@@ -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
View File
@@ -42,17 +42,28 @@ class AppState: ObservableObject {
#endif #endif
func loadConfig(url: String) async { func loadConfig(url: String) async {
do { await loadConfig(vodUrl: url, liveUrl: nil)
try await ApiConfig.shared.loadConfig(from: url)
await MainActor.run {
self.isConfigLoaded = true
self.currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
} }
func loadConfig(vodUrl: String, liveUrl: String?) async {
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedVod.isEmpty else { return }
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
do {
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
applyLoadedConfigState()
} 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 {