diff --git a/tvbox/Services/ApiConfig.swift b/tvbox/Services/ApiConfig.swift index 18b96e4..64eb812 100644 --- a/tvbox/Services/ApiConfig.swift +++ b/tvbox/Services/ApiConfig.swift @@ -13,6 +13,7 @@ class ApiConfig: ObservableObject { @Published var dohList: [(name: String, url: String)] = [] @Published var isLoaded: Bool = false @Published var configUrl: String = "" + @Published var liveConfigUrl: String = "" @Published var wallpaper: String = "" private let network = NetworkManager.shared @@ -21,8 +22,36 @@ class ApiConfig: ObservableObject { /// 加载远程配置 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) // 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理) @@ -32,8 +61,7 @@ class ApiConfig: ObservableObject { throw ConfigError.parseError("无法解析配置数据") } - let config = try JSONDecoder().decode(AppConfigData.self, from: data) - parseConfig(config, apiUrl: apiUrl) + return try JSONDecoder().decode(AppConfigData.self, from: data) } /// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式 @@ -97,59 +125,67 @@ class ApiConfig: ObservableObject { } /// 解析配置数据 - private func parseConfig(_ config: AppConfigData, apiUrl: String) { - // 解析站点列表 - var sources: [SourceBean] = [] - if let sites = config.sites { - for site in sites { - let bean = SourceBean( - key: site.key ?? UUID().uuidString, - name: site.name ?? "未命名", - api: site.api ?? "", - searchable: site.searchable?.value ?? 1, - filterable: site.filterable?.value ?? 1, - playerType: site.playerType?.value ?? 0, - type: site.type?.value ?? 1, - ext: site.ext?.stringValue - ) - sources.append(bean) + private func parseConfig( + _ config: AppConfigData, + apiUrl: String, + includeSources: Bool, + includeLive: Bool + ) { + if includeSources { + // 解析站点列表 + var sources: [SourceBean] = [] + if let sites = config.sites { + for site in sites { + let bean = SourceBean( + key: site.key ?? UUID().uuidString, + name: site.name ?? "未命名", + api: site.api ?? "", + searchable: site.searchable?.value ?? 1, + filterable: site.filterable?.value ?? 1, + 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 } /// 解析直播列表 diff --git a/tvbox/Utils/Extensions.swift b/tvbox/Utils/Extensions.swift index f9e7849..3ed6a55 100644 --- a/tvbox/Utils/Extensions.swift +++ b/tvbox/Utils/Extensions.swift @@ -11,6 +11,8 @@ struct HawkConfig { static let HOME_API = "home_api" static let HOME_REC = "home_rec" static let PLAY_TYPE = "play_type" + static let PLAY_TYPE_VOD = "play_type_vod" + static let PLAY_TYPE_LIVE = "play_type_live" static let DOH_URL = "doh_url" static let SEARCH_VIEW = "search_view" static let LIVE_API_URL = "live_api_url" diff --git a/tvbox/ViewModels/SettingsViewModel.swift b/tvbox/ViewModels/SettingsViewModel.swift index 051331f..62f2aa1 100644 --- a/tvbox/ViewModels/SettingsViewModel.swift +++ b/tvbox/ViewModels/SettingsViewModel.swift @@ -4,12 +4,14 @@ import SwiftUI /// 设置 ViewModel @MainActor class SettingsViewModel: ObservableObject { - @Published var apiUrl: String = "" + @Published var vodApiUrl: String = "" + @Published var liveApiUrl: String = "" @Published var isLoadingConfig = false @Published var configError: String? @Published var configSuccess = false @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 vlcBufferMode: VLCBufferMode = .defaultMode @Published var playTimeStep: Int = 10 @@ -21,28 +23,51 @@ class SettingsViewModel: ObservableObject { let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases init() { - apiUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? "" + let defaults = UserDefaults.standard + let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? "" + vodApiUrl = savedVod + if let savedLive = defaults.string(forKey: HawkConfig.LIVE_API_URL) { + liveApiUrl = savedLive + } else { + liveApiUrl = savedVod + } loadApiHistory() - playerEngine = PlayerEngine.fromStoredValue( - UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TYPE) + let hasLegacyPlayer = defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil + let legacyPlayerRaw = defaults.integer(forKey: HawkConfig.PLAY_TYPE) + let defaultVodRaw = PlayerEngine.system.rawValue + let defaultLiveRaw = PlayerEngine.isVLCAvailable + ? PlayerEngine.vlc.rawValue + : PlayerEngine.system.rawValue + if defaults.object(forKey: HawkConfig.PLAY_TYPE_VOD) == nil { + defaults.set(hasLegacyPlayer ? legacyPlayerRaw : defaultVodRaw, forKey: HawkConfig.PLAY_TYPE_VOD) + } + if defaults.object(forKey: HawkConfig.PLAY_TYPE_LIVE) == nil { + defaults.set(hasLegacyPlayer ? legacyPlayerRaw : defaultLiveRaw, forKey: HawkConfig.PLAY_TYPE_LIVE) + } + vodPlayerEngine = PlayerEngine.fromStoredValue( + defaults.integer(forKey: HawkConfig.PLAY_TYPE_VOD) + ) + livePlayerEngine = PlayerEngine.fromStoredValue( + defaults.integer(forKey: HawkConfig.PLAY_TYPE_LIVE) ) decodeMode = VideoDecodeMode.fromStoredValue( - UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE) + defaults.integer(forKey: HawkConfig.PLAY_DECODE_MODE) ) vlcBufferMode = VLCBufferMode.fromStoredValue( - UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) + defaults.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) ) - let savedStep = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) + let savedStep = defaults.integer(forKey: HawkConfig.PLAY_TIME_STEP) playTimeStep = savedStep > 0 ? savedStep : 10 refreshCacheSize() } /// 加载配置 func loadConfig() async { - let trimmed = apiUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - configError = "请输入配置地址" + let trimmedVod = vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedLive = liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedVod.isEmpty else { + configError = "请输入点播接口地址" return } @@ -51,9 +76,14 @@ class SettingsViewModel: ObservableObject { configSuccess = false do { - try await ApiConfig.shared.loadConfig(from: trimmed) - UserDefaults.standard.set(trimmed, forKey: HawkConfig.API_URL) - addToApiHistory(trimmed) + let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive + try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) + UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL) + UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL) + vodApiUrl = trimmedVod + liveApiUrl = trimmedLive + addToApiHistory(trimmedVod) + addToApiHistory(resolvedLive) configSuccess = true } catch { configError = error.localizedDescription @@ -97,11 +127,18 @@ class SettingsViewModel: ObservableObject { UserDefaults.standard.set(step, forKey: HawkConfig.PLAY_TIME_STEP) } - /// 设置播放器内核 - func setPlayerEngine(_ engine: PlayerEngine) { + /// 设置点播播放器内核 + func setVodPlayerEngine(_ engine: PlayerEngine) { guard playerEngineOptions.contains(engine) else { return } - playerEngine = engine - UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE) + vodPlayerEngine = engine + UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE_VOD) + } + + /// 设置直播播放器内核 + func setLivePlayerEngine(_ engine: PlayerEngine) { + guard playerEngineOptions.contains(engine) else { return } + livePlayerEngine = engine + UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE_LIVE) } /// 设置视频解码模式 diff --git a/tvbox/Views/ContentView.swift b/tvbox/Views/ContentView.swift index 491dd07..86b5f4f 100644 --- a/tvbox/Views/ContentView.swift +++ b/tvbox/Views/ContentView.swift @@ -2,10 +2,16 @@ import SwiftUI /// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航 struct ContentView: View { + private enum ApiInputTarget { + case vod + case live + } + @EnvironmentObject var appState: AppState @StateObject private var settingsVM = SettingsViewModel() @State private var selectedTab = 0 @State private var showSetup = false + @State private var setupInputTarget: ApiInputTarget = .vod var body: some View { Group { @@ -18,10 +24,12 @@ struct ContentView: View { .preferredColorScheme(.dark) .onAppear { // 自动加载已保存的配置 - let savedUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? "" - if !savedUrl.isEmpty { + let defaults = UserDefaults.standard + let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? "" + let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? "" + if !savedVodUrl.isEmpty { Task { - await appState.loadConfig(url: savedUrl) + await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl) } } } @@ -169,24 +177,48 @@ struct ContentView: View { HStack { Image(systemName: "link") .foregroundColor(.orange) - TextField("请输入接口地址 (URL)", text: $settingsVM.apiUrl) + TextField("请输入点播接口地址 (URL)", text: $settingsVM.vodApiUrl) .textFieldStyle(.plain) .foregroundColor(.white) + .onTapGesture { + setupInputTarget = .vod + } #if os(iOS) .autocapitalization(.none) .keyboardType(.URL) #endif Button { + if let text = readPasteboardText() { + settingsVM.vodApiUrl = text + } + } label: { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.orange) + } + .buttonStyle(.plain) + } + .padding() + .glassCard(cornerRadius: 15) + + HStack { + Image(systemName: "tv") + .foregroundColor(.orange) + TextField("请输入直播接口地址 (URL,可留空跟随点播)", text: $settingsVM.liveApiUrl) + .textFieldStyle(.plain) + .foregroundColor(.white) + .onTapGesture { + setupInputTarget = .live + } #if os(iOS) - if let text = UIPasteboard.general.string { - settingsVM.apiUrl = text - } - #else - if let text = NSPasteboard.general.string(forType: .string) { - settingsVM.apiUrl = text - } + .autocapitalization(.none) + .keyboardType(.URL) #endif + + Button { + if let text = readPasteboardText() { + settingsVM.liveApiUrl = text + } } label: { Image(systemName: "doc.on.clipboard") .foregroundColor(.orange) @@ -202,7 +234,7 @@ struct ContentView: View { Task { await settingsVM.loadConfig() if settingsVM.configSuccess { - await appState.loadConfig(url: settingsVM.apiUrl) + appState.applyLoadedConfigState() } } } label: { @@ -222,7 +254,10 @@ struct ContentView: View { .clipShape(Capsule()) .shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6) } - .disabled(settingsVM.isLoadingConfig || settingsVM.apiUrl.isEmpty) + .disabled( + settingsVM.isLoadingConfig + || settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) // 历史记录 if !settingsVM.apiHistory.isEmpty { @@ -234,7 +269,12 @@ struct ContentView: View { ForEach(settingsVM.apiHistory.prefix(3), id: \.self) { url in Button { - settingsVM.apiUrl = url + switch setupInputTarget { + case .vod: + settingsVM.vodApiUrl = url + case .live: + settingsVM.liveApiUrl = url + } } label: { HStack { Image(systemName: "clock.arrow.2.circlepath") @@ -276,4 +316,12 @@ struct ContentView: View { } } } + + private func readPasteboardText() -> String? { + #if os(iOS) + UIPasteboard.general.string + #else + NSPasteboard.general.string(forType: .string) + #endif + } } diff --git a/tvbox/Views/Detail/DetailView.swift b/tvbox/Views/Detail/DetailView.swift index ac618a5..cebc22e 100644 --- a/tvbox/Views/Detail/DetailView.swift +++ b/tvbox/Views/Detail/DetailView.swift @@ -8,10 +8,14 @@ import AppKit struct DetailView: View { let video: Movie.Video @StateObject private var viewModel = DetailViewModel() + @StateObject private var sharedSystemController = SystemPlayerSessionController() @StateObject private var sharedVLCController = VLCPlayerController() @EnvironmentObject var appState: AppState @Environment(\.modelContext) private var modelContext @State private var showFullScreen = false + #if os(macOS) + @State private var pendingMacWindowFullScreen = false + #endif @State private var lastPersistedProgress: Double = 0 @State private var isCollected = false @@ -19,7 +23,7 @@ struct DetailView: View { ScrollView { VStack(spacing: 0) { // 播放器区域 - if viewModel.isPlaying, let url = viewModel.playUrl { + if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl { PlayerView( urlString: url, startPosition: viewModel.currentPlaybackSeconds(), @@ -30,6 +34,7 @@ struct DetailView: View { }, canPlayNext: canPlayNextEpisode, onPlayNext: playNextEpisodeIfNeeded, + systemController: sharedSystemController, vlcController: sharedVLCController ) .id("\(viewModel.selectedFlag)-\(viewModel.selectedEpisodeIndex)-\(url)") @@ -38,8 +43,6 @@ struct DetailView: View { .onTapGesture(count: 2) { openFullScreenPlayer() } - .opacity(showFullScreen ? 0 : 1) - .allowsHitTesting(!showFullScreen) } // 视频信息 @@ -79,7 +82,7 @@ struct DetailView: View { .background(AppTheme.primaryGradient) .navigationTitle(video.name) #if os(macOS) - .toolbar(showFullScreen ? .hidden : .visible, for: .windowToolbar) + .toolbar((showFullScreen || pendingMacWindowFullScreen) ? .hidden : .visible, for: .windowToolbar) #endif #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -93,8 +96,10 @@ struct DetailView: View { viewModel.commitPlaybackProgressSnapshot() persistHistoryIfNeeded(force: true) showFullScreen = false + sharedSystemController.stop() sharedVLCController.stop() #if os(macOS) + pendingMacWindowFullScreen = false appState.exitPlayerFullScreen() #endif } @@ -108,6 +113,7 @@ struct DetailView: View { onPlaybackEnded: playNextEpisodeIfNeeded, canPlayNext: canPlayNextEpisode, onPlayNext: playNextEpisodeIfNeeded, + systemController: sharedSystemController, vlcController: sharedVLCController, onCloseRequested: closeMacFullScreenOverlay ) @@ -116,7 +122,13 @@ struct DetailView: View { .zIndex(2) } } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in + guard pendingMacWindowFullScreen else { return } + pendingMacWindowFullScreen = false + showFullScreen = true + } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in + pendingMacWindowFullScreen = false if showFullScreen { showFullScreen = false } @@ -133,6 +145,7 @@ struct DetailView: View { onPlaybackEnded: playNextEpisodeIfNeeded, canPlayNext: canPlayNextEpisode, onPlayNext: playNextEpisodeIfNeeded, + systemController: sharedSystemController, vlcController: sharedVLCController ) } @@ -504,24 +517,33 @@ struct DetailView: View { showFullScreen = true #else guard viewModel.playUrl != nil else { return } - showFullScreen = true appState.enterPlayerFullScreen() - requestMacWindowFullScreen(enter: true) + + if let window = NSApp.keyWindow ?? NSApp.mainWindow, + window.styleMask.contains(.fullScreen) { + showFullScreen = true + return + } + + pendingMacWindowFullScreen = requestMacWindowFullScreen(enter: true) + if !pendingMacWindowFullScreen { + showFullScreen = true + } #endif } #if os(macOS) - private func requestMacWindowFullScreen(enter: Bool) { - DispatchQueue.main.async { - guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return } - let isFullScreen = window.styleMask.contains(.fullScreen) - if enter != isFullScreen { - window.toggleFullScreen(nil) - } - } + @discardableResult + private func requestMacWindowFullScreen(enter: Bool) -> Bool { + guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return false } + let isFullScreen = window.styleMask.contains(.fullScreen) + guard enter != isFullScreen else { return false } + window.toggleFullScreen(nil) + return true } private func closeMacFullScreenOverlay() { + pendingMacWindowFullScreen = false let window = NSApp.keyWindow ?? NSApp.mainWindow if window?.styleMask.contains(.fullScreen) == true { requestMacWindowFullScreen(enter: false) @@ -541,6 +563,7 @@ struct FullScreenPlayerView: View { var onPlaybackEnded: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil + var systemController: SystemPlayerSessionController? = nil var vlcController: VLCPlayerController? = nil var onCloseRequested: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss @@ -563,6 +586,7 @@ struct FullScreenPlayerView: View { }, canPlayNext: canPlayNext, onPlayNext: onPlayNext, + systemController: systemController, vlcController: vlcController ) .ignoresSafeArea() diff --git a/tvbox/Views/Live/LiveView.swift b/tvbox/Views/Live/LiveView.swift index 2c96f8f..4fdd69f 100644 --- a/tvbox/Views/Live/LiveView.swift +++ b/tvbox/Views/Live/LiveView.swift @@ -9,7 +9,8 @@ struct LiveView: View { @StateObject private var viewModel = LiveViewModel() @EnvironmentObject var appState: AppState @State private var avPlayer: AVPlayer? - @AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue + @AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1 + @AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue @State private var showChannelDrawer = true @State private var isWindowFullScreen = false private let currentChannelInfoMaxWidth: CGFloat = 600 @@ -24,7 +25,18 @@ struct LiveView: View { @State private var vlcInteractionToken = 0 private var selectedEngine: PlayerEngine { - PlayerEngine.fromStoredValue(playTypeRaw) + let defaults = UserDefaults.standard + let rawValue: Int + if defaults.object(forKey: HawkConfig.PLAY_TYPE_LIVE) != nil { + rawValue = livePlayTypeRaw + } else if defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil { + rawValue = legacyPlayTypeRaw + } else { + rawValue = PlayerEngine.isVLCAvailable + ? PlayerEngine.vlc.rawValue + : PlayerEngine.system.rawValue + } + return PlayerEngine.fromStoredValue(rawValue) } var body: some View { @@ -83,7 +95,7 @@ struct LiveView: View { resetFailureTracking(for: viewModel.currentChannel) wakeUpCurrentChannelInfo() } - .onChange(of: playTypeRaw) { _, _ in + .onChange(of: selectedEngine) { _, _ in if selectedEngine == .system { playChannel(url: viewModel.currentChannel?.currentUrl) } else { diff --git a/tvbox/Views/Player/PlayerView.swift b/tvbox/Views/Player/PlayerView.swift index 8db60d4..cc5f4c2 100644 --- a/tvbox/Views/Player/PlayerView.swift +++ b/tvbox/Views/Player/PlayerView.swift @@ -40,12 +40,34 @@ private struct MacOSPlayerView: NSViewRepresentable { } static func dismantleNSView(_ nsView: AVPlayerView, coordinator: ()) { - nsView.player?.pause() nsView.player = nil } } #endif +/// 系统播放器会话控制器:用于在页面内联与全屏视图间复用同一 AVPlayer,避免重复拉流 +@MainActor +final class SystemPlayerSessionController: ObservableObject { + fileprivate var player: AVPlayer? + fileprivate var mediaURLString: String? + + func setPlayer(_ newPlayer: AVPlayer, urlString: String) { + if player !== newPlayer { + player?.pause() + player?.replaceCurrentItem(with: nil) + } + player = newPlayer + mediaURLString = urlString + } + + func stop() { + player?.pause() + player?.replaceCurrentItem(with: nil) + player = nil + mediaURLString = nil + } +} + /// 视频播放器组件 - 对应 Android 版 PlayFragment struct PlayerView: View { let urlString: String @@ -55,11 +77,22 @@ struct PlayerView: View { var onToggleFullScreen: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil + var systemController: SystemPlayerSessionController? = nil var vlcController: VLCPlayerController? = nil - @AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue + @AppStorage(HawkConfig.PLAY_TYPE_VOD) private var vodPlayTypeRaw = -1 + @AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue private var selectedEngine: PlayerEngine { - PlayerEngine.fromStoredValue(playTypeRaw) + let defaults = UserDefaults.standard + let rawValue: Int + if defaults.object(forKey: HawkConfig.PLAY_TYPE_VOD) != nil { + rawValue = vodPlayTypeRaw + } else if defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil { + rawValue = legacyPlayTypeRaw + } else { + rawValue = PlayerEngine.system.rawValue + } + return PlayerEngine.fromStoredValue(rawValue) } var body: some View { @@ -73,7 +106,8 @@ struct PlayerView: View { onPlaybackEnded: onPlaybackEnded, onToggleFullScreen: onToggleFullScreen, canPlayNext: canPlayNext, - onPlayNext: onPlayNext + onPlayNext: onPlayNext, + sharedController: systemController ) case .vlc: VLCVodPlayerView( @@ -89,6 +123,22 @@ struct PlayerView: View { } } .id(selectedEngine.rawValue) + .onAppear { + if selectedEngine != .system { + systemController?.stop() + } + if selectedEngine != .vlc { + vlcController?.stop() + } + } + .onChange(of: selectedEngine) { _, newValue in + if newValue != .system { + systemController?.stop() + } + if newValue != .vlc { + vlcController?.stop() + } + } } } @@ -101,6 +151,7 @@ struct AVPlayerContentView: View { var onToggleFullScreen: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil + var sharedController: SystemPlayerSessionController? = nil @State private var player: AVPlayer? @State private var playbackEndObserver: NSObjectProtocol? @State private var timeObserverToken: Any? @@ -195,7 +246,7 @@ struct AVPlayerContentView: View { wakeUpControls() } .onDisappear { - cleanupPlayer() + cleanupPlayer(keepSharedPlayer: sharedController != nil) controlsTimer?.invalidate() osdTimer?.invalidate() } @@ -203,40 +254,60 @@ struct AVPlayerContentView: View { private func setupPlayer() { guard let url = URL(string: urlString) else { return } + let targetURLString = url.absoluteString + + if let sharedController, + sharedController.mediaURLString == targetURLString, + let sharedPlayer = sharedController.player { + cleanupPlayer(keepSharedPlayer: true) + player = sharedPlayer + bindPlayerObservers(for: sharedPlayer) + reportProgress(for: sharedPlayer) + return + } // 清理旧播放器 cleanupPlayer() let playerItem = AVPlayerItem(url: url) let newPlayer = AVPlayer(playerItem: playerItem) + if let sharedController { + sharedController.setPlayer(newPlayer, urlString: targetURLString) + } - // 设置监听 + player = newPlayer + bindPlayerObservers(for: newPlayer) + startPlayback(for: newPlayer) + } + + private func bindPlayerObservers(for player: AVPlayer) { playerObservers = [ - newPlayer.observe(\.timeControlStatus, options: [.new]) { p, _ in + player.observe(\.timeControlStatus, options: [.new]) { p, _ in let status = p.timeControlStatus DispatchQueue.main.async { isPlaying = status == .playing } }, - newPlayer.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in + player.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in let reason = p.reasonForWaitingToPlay DispatchQueue.main.async { isPreparing = reason != nil } }, - newPlayer.observe(\.volume, options: [.new]) { p, _ in + player.observe(\.volume, options: [.new]) { p, _ in let vol = Double(p.volume) DispatchQueue.main.async { volume = vol } }, - newPlayer.observe(\.rate, options: [.new]) { p, _ in + player.observe(\.rate, options: [.new]) { p, _ in let r = p.rate DispatchQueue.main.async { rate = r } } ] - - observePlaybackProgress(for: newPlayer) - observePlaybackEnd(for: newPlayer) - player = newPlayer - startPlayback(for: newPlayer) + observePlaybackProgress(for: player) + observePlaybackEnd(for: player) + isPlaying = player.timeControlStatus == .playing + isPreparing = player.reasonForWaitingToPlay != nil + volume = Double(player.volume) + rate = player.rate } - private func cleanupPlayer() { + private func detachPlayerObservers() { if let token = timeObserverToken { player?.removeTimeObserver(token) timeObserverToken = nil @@ -247,8 +318,24 @@ struct AVPlayerContentView: View { } playerObservers.forEach { $0.invalidate() } playerObservers.removeAll() - player?.pause() - player?.replaceCurrentItem(with: nil) + } + + private func cleanupPlayer(keepSharedPlayer: Bool = false) { + let currentPlayer = player + detachPlayerObservers() + + guard let currentPlayer else { return } + if keepSharedPlayer, sharedController?.player === currentPlayer { + player = nil + return + } + + currentPlayer.pause() + currentPlayer.replaceCurrentItem(with: nil) + if sharedController?.player === currentPlayer { + sharedController?.player = nil + sharedController?.mediaURLString = nil + } player = nil } diff --git a/tvbox/Views/Player/VLCPlayerView.swift b/tvbox/Views/Player/VLCPlayerView.swift index f141026..c9f8f6d 100644 --- a/tvbox/Views/Player/VLCPlayerView.swift +++ b/tvbox/Views/Player/VLCPlayerView.swift @@ -14,6 +14,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private static let maxVolume = 200 private static let drawableSizeChangeThreshold: CGFloat = 24 private static let drawableRebindMinimumInterval: TimeInterval = 1.2 + // 这些选项在播放器实例级别生效,优先约束 libvlc 的追时钟/丢帧行为。 + private static let stablePlaybackOptions: [String] = [ + "--no-drop-late-frames", + "--no-skip-frames", + "--clock-synchro=0", + "--clock-jitter=0" + ] private static let playerInstanceSelector = NSSelectorFromString("playerInstance") private static let libVLCStopAsync: LibVLCStopAsyncFunction? = { // RTLD_DEFAULT 在 Swift 中不可直接用常量名,-2 等价于 C 宏 RTLD_DEFAULT。 @@ -25,7 +32,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg }() private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void - let mediaPlayer = VLCMediaPlayer() + let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions) @Published var isPreparing = true @Published var isPlaying = false @Published var currentTimeSeconds: Double = 0 @@ -60,10 +67,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var bufferMode: VLCBufferMode = .defaultMode private var bufferingFallbackWorkItem: DispatchWorkItem? private var delayedPreparingWorkItem: DispatchWorkItem? - private var bufferingEventCountVod = 0 - private var lastVodBufferingCountedAt: Date = .distantPast - private var hasAttemptedVodCacheBoost = false - private var useVodCacheBoost = false private var currentMediaURLString: String? private var currentMediaIsLive = false private var currentMediaDecodeMode: VideoDecodeMode = .auto @@ -73,16 +76,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var onPlaybackFailed: (() -> Void)? private let progressUpdateIntervalVod: TimeInterval = 0.5 private let progressUpdateIntervalLive: TimeInterval = 1.0 - private let bufferingFallbackThresholdVod: TimeInterval = 6.0 private let bufferingFallbackThresholdLive: TimeInterval = 4.0 private let bufferingConfirmDelayVod: TimeInterval = 0.35 private let bufferingIndicatorDelayVod: TimeInterval = 1.2 - private let vodBufferingEventDebounceInterval: TimeInterval = 1.5 private let vodBufferingProgressAdvanceThreshold: Double = 0.25 - private let vodCacheBoostTriggerCount = 2 - private let vodCacheBoostExtraNetwork = 4500 - private let vodCacheBoostExtraLive = 3500 - private let vodCacheBoostExtraFile = 5500 private let progressPublishThreshold: Double = 0.25 private let durationPublishThreshold: Double = 0.5 private var lastNonZeroVolume = defaultVolume @@ -163,25 +160,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg mediaPlayer.stop() let media = VLCMedia(url: url) - var cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode) - if !isLive && useVodCacheBoost { - cacheConfig.network += vodCacheBoostExtraNetwork - cacheConfig.live += vodCacheBoostExtraLive - cacheConfig.file += vodCacheBoostExtraFile - } + let cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode) let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : false let enableSkipFrames = isLive && bufferMode.enableFrameDrop var mediaOptions: [String: Any] = [ "network-caching": cacheConfig.network, "live-caching": cacheConfig.live, "file-caching": cacheConfig.file, - "drop-late-frames": enableFrameDrop ? 1 : 0, - "skip-frames": enableSkipFrames ? 1 : 0, "http-reconnect": 1 ] if !isLive { mediaOptions["avcodec-hurry-up"] = 0 - mediaOptions["clock-jitter"] = 5000000 + mediaOptions["clock-synchro"] = 0 } if let hwOption = decodeMode.vlcHardwareDecodeOption { @@ -195,6 +185,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } media.addOptions(mediaOptions) + // 对布尔型选项使用显式 no- 前缀,避免 0/1 在不同 libvlc 版本下解释不一致。 + media.addOption(enableFrameDrop ? "drop-late-frames" : "no-drop-late-frames") + media.addOption(enableSkipFrames ? "skip-frames" : "no-skip-frames") mediaPlayer.media = media mediaPlayer.play() @@ -624,13 +617,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } private func scheduleBufferingFallbackIfNeeded() { + guard isLive else { return } guard bufferingFallbackWorkItem == nil else { return } guard !hasAttemptedSoftDecodeFallback else { return } - if !isLive && !hasAttemptedVodCacheBoost { return } guard decodeMode != .software else { return } guard mediaPlayer.media != nil else { return } - let delay = isLive ? bufferingFallbackThresholdLive : bufferingFallbackThresholdVod + let delay = bufferingFallbackThresholdLive let workItem = DispatchWorkItem { [weak self] in Task { @MainActor [weak self] in self?.attemptSoftDecodeFallbackIfNeeded() @@ -680,7 +673,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg isPlaying = false } scheduleDelayedPreparingIndicatorForVod() - scheduleBufferingFallbackIfNeeded() return } @@ -743,24 +735,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg guard !mediaPlayer.isPlaying else { return } if !isInBufferingState { isInBufferingState = true - markVodBufferingEventIfNeeded() - if shouldAttemptVodCacheBoost() { - attemptVodCacheBoostIfNeeded() - return - } } if isPlaying { isPlaying = false } scheduleDelayedPreparingIndicatorForVod() - scheduleBufferingFallbackIfNeeded() - } - - private func markVodBufferingEventIfNeeded() { - let now = Date() - guard now.timeIntervalSince(lastVodBufferingCountedAt) >= vodBufferingEventDebounceInterval else { return } - lastVodBufferingCountedAt = now - bufferingEventCountVod += 1 } private func isStillStalledSinceBufferingStartedVod() -> Bool { @@ -777,30 +756,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } } - private func shouldAttemptVodCacheBoost() -> Bool { - !isLive && !hasAttemptedVodCacheBoost && bufferingEventCountVod >= vodCacheBoostTriggerCount - } - - private func attemptVodCacheBoostIfNeeded() { - guard !isLive else { return } - guard !hasAttemptedVodCacheBoost else { return } - guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return } - guard mediaPlayer.media != nil else { return } - hasAttemptedVodCacheBoost = true - useVodCacheBoost = true - cancelDelayedPreparingIndicator() - cancelBufferingFallbackTimer() - let resumePosition = max(currentSeconds(), 0) - play( - url: url, - startPosition: resumePosition, - isLive: false, - onProgressChanged: onProgressChanged, - onPlaybackEnded: onPlaybackEnded, - onPlaybackFailed: onPlaybackFailed - ) - } - private func resetPlaybackRecoveryState() { cancelBufferingFallbackTimer() cancelDelayedPreparingIndicator() @@ -809,10 +764,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg decodeModeOverride = nil isInBufferingState = false bufferingBaselineSecondsVod = 0 - bufferingEventCountVod = 0 - lastVodBufferingCountedAt = .distantPast - hasAttemptedVodCacheBoost = false - useVodCacheBoost = false } private func setPlaybackStatus(preparing: Bool, playing: Bool) { diff --git a/tvbox/Views/Settings/SettingsView.swift b/tvbox/Views/Settings/SettingsView.swift index a8acf8e..cee25b3 100644 --- a/tvbox/Views/Settings/SettingsView.swift +++ b/tvbox/Views/Settings/SettingsView.swift @@ -2,17 +2,38 @@ import SwiftUI /// 设置页 - 对应 Android 版 SettingActivity + ModelSettingFragment struct SettingsView: View { + enum ApiInputType { + case vod + case live + + var title: String { + switch self { + case .vod: return "点播接口地址" + case .live: return "直播接口地址" + } + } + + var placeholder: String { + switch self { + case .vod: return "请输入点播接口地址" + case .live: return "请输入直播接口地址(可留空跟随点播)" + } + } + } + @StateObject private var viewModel = SettingsViewModel() @StateObject private var apiConfig = ApiConfig.shared @EnvironmentObject var appState: AppState @State private var showApiInput = false + @State private var editingApiType: ApiInputType = .vod @State private var showAbout = false @State private var sourceSearchText = "" @State private var showingPicker: PickerType = .none enum PickerType { case none - case player + case vodPlayer + case livePlayer case decode case vlcBuffer case playTimeStep @@ -24,7 +45,21 @@ struct SettingsView: View { VStack(spacing: 24) { // API 配置 SectionCard(title: "数据源") { - SettingsRow(icon: "link", title: "接口地址", value: viewModel.apiUrl.isEmpty ? "未配置" : viewModel.apiUrl) { + SettingsRow( + icon: "film", + title: "点播接口地址", + value: viewModel.vodApiUrl.isEmpty ? "未配置" : viewModel.vodApiUrl + ) { + editingApiType = .vod + showApiInput = true + } + Divider().background(Color.white.opacity(0.1)) + SettingsRow( + icon: "tv", + title: "直播接口地址", + value: viewModel.liveApiUrl.isEmpty ? "跟随点播接口" : viewModel.liveApiUrl + ) { + editingApiType = .live showApiInput = true } Divider().background(Color.white.opacity(0.1)) @@ -39,9 +74,15 @@ struct SettingsView: View { // 播放设置 SectionCard(title: "播放设置") { - SettingsRow(icon: "play.rectangle", title: "播放器", value: viewModel.playerEngine.title) { + SettingsRow(icon: "play.rectangle", title: "点播播放器", value: viewModel.vodPlayerEngine.title) { if viewModel.playerEngineOptions.count > 1 { - showingPicker = .player + showingPicker = .vodPlayer + } + } + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "dot.radiowaves.left.and.right", title: "直播播放器", value: viewModel.livePlayerEngine.title) { + if viewModel.playerEngineOptions.count > 1 { + showingPicker = .livePlayer } } Divider().background(Color.white.opacity(0.1)) @@ -115,15 +156,28 @@ struct SettingsView: View { @ViewBuilder private var pickerOverlay: some View { switch showingPicker { - case .player: + case .vodPlayer: SelectionModal( - title: "选择播放器", + title: "选择点播播放器", icon: "play.rectangle.fill", items: viewModel.playerEngineOptions, - selectedItem: viewModel.playerEngine, + selectedItem: viewModel.vodPlayerEngine, itemTitle: { $0.title }, onSelect: { engine in - viewModel.setPlayerEngine(engine) + viewModel.setVodPlayerEngine(engine) + showingPicker = .none + }, + onCancel: { showingPicker = .none } + ) + case .livePlayer: + SelectionModal( + title: "选择直播播放器", + icon: "dot.radiowaves.left.and.right", + items: viewModel.playerEngineOptions, + selectedItem: viewModel.livePlayerEngine, + itemTitle: { $0.title }, + onSelect: { engine in + viewModel.setLivePlayerEngine(engine) showingPicker = .none }, onCancel: { showingPicker = .none } @@ -180,7 +234,7 @@ struct SettingsView: View { HStack { Image(systemName: "link") .foregroundColor(.secondary) - TextField("请输入接口地址", text: $viewModel.apiUrl) + TextField(editingApiType.placeholder, text: currentApiBinding) .textFieldStyle(.plain) #if os(iOS) .autocapitalization(.none) @@ -194,15 +248,9 @@ struct SettingsView: View { // 粘贴按钮 HStack { Button { - #if os(iOS) - if let text = UIPasteboard.general.string { - viewModel.apiUrl = text + if let text = readPasteboardText() { + currentApiBinding.wrappedValue = text } - #else - if let text = NSPasteboard.general.string(forType: .string) { - viewModel.apiUrl = text - } - #endif } label: { Label("粘贴", systemImage: "doc.on.clipboard") .font(.subheadline) @@ -221,7 +269,7 @@ struct SettingsView: View { ForEach(viewModel.apiHistory, id: \.self) { url in HStack { Button { - viewModel.apiUrl = url + currentApiBinding.wrappedValue = url } label: { HStack { Image(systemName: "clock") @@ -256,7 +304,7 @@ struct SettingsView: View { Spacer() } .padding() - .navigationTitle("配置接口") + .navigationTitle(editingApiType.title) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -269,7 +317,7 @@ struct SettingsView: View { Task { await viewModel.loadConfig() if viewModel.configSuccess { - await appState.loadConfig(url: viewModel.apiUrl) + appState.applyLoadedConfigState() showApiInput = false } } @@ -280,7 +328,10 @@ struct SettingsView: View { Text("确认") } } - .disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty) + .disabled( + viewModel.isLoadingConfig + || viewModel.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) } } } @@ -289,6 +340,23 @@ struct SettingsView: View { #endif } + private var currentApiBinding: Binding { + switch editingApiType { + case .vod: + return $viewModel.vodApiUrl + case .live: + return $viewModel.liveApiUrl + } + } + + private func readPasteboardText() -> String? { + #if os(iOS) + UIPasteboard.general.string + #else + NSPasteboard.general.string(forType: .string) + #endif + } + // MARK: - 源选择 private var filteredSources: [SourceBean] { diff --git a/tvbox/tvboxApp.swift b/tvbox/tvboxApp.swift index ccbea4b..1aa1845 100644 --- a/tvbox/tvboxApp.swift +++ b/tvbox/tvboxApp.swift @@ -42,17 +42,28 @@ class AppState: ObservableObject { #endif 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 { - try await ApiConfig.shared.loadConfig(from: url) - await MainActor.run { - self.isConfigLoaded = true - self.currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" - } + try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) + applyLoadedConfigState() } catch { print("Failed to load config: \(error)") } } + func applyLoadedConfigState() { + isConfigLoaded = true + currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" + } + #if os(macOS) func enterPlayerFullScreen() { if splitViewVisibilityBeforePlayerFullScreen == nil {