From fac70d7496a4a48dff72dcc3c6e3ffe4cabdc24b Mon Sep 17 00:00:00 2001 From: JinJiangHuang Date: Tue, 10 Mar 2026 21:37:51 +0800 Subject: [PATCH] fix network --- tvbox.xcodeproj/project.pbxproj | 6 + tvbox/Services/NetworkManager.swift | 171 ++++++++++++++++++++++++--- tvbox/Services/NetworkMonitor.swift | 66 +++++++++++ tvbox/Utils/Extensions.swift | 69 +++++++++-- tvbox/ViewModels/HomeViewModel.swift | 31 ++++- tvbox/Views/ContentView.swift | 34 ++++++ tvbox/tvboxApp.swift | 43 ++++++- 7 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 tvbox/Services/NetworkMonitor.swift diff --git a/tvbox.xcodeproj/project.pbxproj b/tvbox.xcodeproj/project.pbxproj index e90135c..e1e6fc3 100644 --- a/tvbox.xcodeproj/project.pbxproj +++ b/tvbox.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; }; B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; }; B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; }; + B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; }; BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; }; BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; }; BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; }; @@ -71,6 +72,7 @@ D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; }; DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; }; E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; }; + F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; }; F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; }; F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; }; F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; }; @@ -93,6 +95,7 @@ 6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = ""; }; 6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "tvbox-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; 90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -148,6 +151,7 @@ children = ( D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */, D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */, + 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */, D5DFDD8C597739AD4B443C03 /* SourceService.swift */, ); path = Services; @@ -441,6 +445,7 @@ 0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */, 68A7BF3D3ECDA73637B644C8 /* MovieSort.swift in Sources */, 5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */, + F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */, 2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */, 00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */, 073BA70E29280BDF914166CF /* SearchView.swift in Sources */, @@ -480,6 +485,7 @@ 485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */, 7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */, 5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */, + B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */, 100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */, F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */, 59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */, diff --git a/tvbox/Services/NetworkManager.swift b/tvbox/Services/NetworkManager.swift index ff1e7f3..b3616fe 100644 --- a/tvbox/Services/NetworkManager.swift +++ b/tvbox/Services/NetworkManager.swift @@ -5,6 +5,13 @@ class NetworkManager { /// 全局共享实例。 static let shared = NetworkManager() + /// 默认最大重试次数(不含首次请求)。 + static let defaultMaxRetries = 2 + /// 重试基础延迟(秒),实际延迟 = base * 2^attempt + jitter。 + private static let retryBaseDelay: TimeInterval = 0.5 + /// 重试最大延迟上限(秒)。 + private static let retryMaxDelay: TimeInterval = 8.0 + /// 兜底字符集名称列表(按常见中文资源站编码优先级排序)。 private static let fallbackCharsetNames: [String] = [ "utf-8", @@ -43,11 +50,16 @@ class NetworkManager { config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 config.httpMaximumConnectionsPerHost = 5 + config.waitsForConnectivity = true self.session = URLSession(configuration: config) } - /// GET 请求获取字符串 - func getString(from urlString: String, headers: [String: String]? = nil) async throws -> String { + /// GET 请求获取字符串,支持自动重试。 + func getString( + from urlString: String, + headers: [String: String]? = nil, + maxRetries: Int = NetworkManager.defaultMaxRetries + ) async throws -> String { guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw NetworkError.invalidURL(urlString) } @@ -56,17 +68,8 @@ class NetworkManager { request.httpMethod = "GET" headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) } - let (data, response) = try await session.data(for: request) + let (data, httpResponse) = try await performRequest(request, maxRetries: maxRetries) - guard let httpResponse = response as? HTTPURLResponse else { - throw NetworkError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw NetworkError.httpError(httpResponse.statusCode) - } - - // 统一走字符集探测 + 多编码兜底,降低跨源乱码概率。 guard let str = Self.decodeString(data: data, response: httpResponse) else { throw NetworkError.decodingError("文本解码失败") } @@ -74,24 +77,127 @@ class NetworkManager { return str } - /// GET 请求解码 JSON - func getJSON(from urlString: String, type: T.Type, headers: [String: String]? = nil) async throws -> T { - let str = try await getString(from: urlString, headers: headers) + /// GET 请求解码 JSON,支持自动重试。 + func getJSON( + from urlString: String, + type: T.Type, + headers: [String: String]? = nil, + maxRetries: Int = NetworkManager.defaultMaxRetries + ) async throws -> T { + let str = try await getString(from: urlString, headers: headers, maxRetries: maxRetries) guard let data = str.data(using: .utf8) else { throw NetworkError.decodingError("字符串转 Data 失败") } return try decoder.decode(T.self, from: data) } - /// GET 请求获取原始 Data - func getData(from urlString: String) async throws -> Data { + /// GET 请求获取原始 Data,支持自动重试和 HTTP 状态码校验。 + func getData( + from urlString: String, + maxRetries: Int = NetworkManager.defaultMaxRetries + ) async throws -> Data { guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw NetworkError.invalidURL(urlString) } - let (data, _) = try await session.data(from: url) + let request = URLRequest(url: url) + let (data, _) = try await performRequest(request, maxRetries: maxRetries) return data } + // MARK: - 带重试的核心请求方法 + + /// 执行 HTTP 请求,遇到可重试错误时自动指数退避重试。 + private func performRequest( + _ request: URLRequest, + maxRetries: Int + ) async throws -> (Data, HTTPURLResponse) { + var lastError: Error = NetworkError.invalidResponse + let totalAttempts = max(1, maxRetries + 1) + + for attempt in 0.. Bool { + switch statusCode { + case 408, 429, 500, 502, 503, 504: return true + default: return false + } + } + + /// 判断错误是否为可重试的瞬态网络错误。 + private static func isRetryableError(_ error: Error) -> Bool { + if error is CancellationError { return false } + + if let networkError = error as? NetworkError { + if case .httpError(let code) = networkError { + return isRetryableHTTPStatus(code) + } + if case .invalidURL = networkError { return false } + if case .decodingError = networkError { return false } + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorNetworkConnectionLost, + NSURLErrorDNSLookupFailed, + NSURLErrorNotConnectedToInternet, + NSURLErrorSecureConnectionFailed, + NSURLErrorDataNotAllowed: + return true + default: + return false + } + } + + return false + } + /// 文本解码策略: /// 1) 先用响应头声明字符集; /// 2) 再按常见字符集与编码顺序尝试; @@ -152,4 +258,33 @@ enum NetworkError: LocalizedError { case .decodingError(let msg): return "解码错误: \(msg)" } } + + /// 是否为网络不可用导致的错误(供 UI 层判断是否显示网络提示)。 + var isNetworkUnavailable: Bool { + if case .httpError = self { return false } + return true + } +} + +extension Error { + /// 判断是否为网络连接类错误(超时、断网、DNS 失败等)。 + var isNetworkConnectionError: Bool { + if self is CancellationError { return false } + let nsError = self as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorNetworkConnectionLost, + NSURLErrorDNSLookupFailed, + NSURLErrorNotConnectedToInternet, + NSURLErrorDataNotAllowed: + return true + default: + return false + } + } + return false + } } diff --git a/tvbox/Services/NetworkMonitor.swift b/tvbox/Services/NetworkMonitor.swift new file mode 100644 index 0000000..75d5b48 --- /dev/null +++ b/tvbox/Services/NetworkMonitor.swift @@ -0,0 +1,66 @@ +import Foundation +import Network +import Combine + +/// 网络连接状态监控器,基于 NWPathMonitor 实时跟踪设备网络可达性。 +/// 供 UI 层展示离线提示、以及在网络恢复时触发自动重试。 +@MainActor +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + /// 当前是否有可用网络连接。 + @Published private(set) var isConnected: Bool = true + /// 当前连接类型描述(Wi-Fi / 蜂窝 / 有线 / 未知)。 + @Published private(set) var connectionType: ConnectionType = .unknown + + /// 网络从断开恢复到连接时发送信号,用于触发自动重试。 + let networkRestoredPublisher = PassthroughSubject() + + enum ConnectionType: String { + case wifi = "Wi-Fi" + case cellular = "蜂窝" + case wiredEthernet = "有线" + case unknown = "未知" + } + + private let monitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "com.tvbox.networkmonitor", qos: .utility) + private var wasDisconnected = false + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + guard let self else { return } + let connected = path.status == .satisfied + let previouslyConnected = self.isConnected + + self.isConnected = connected + self.connectionType = Self.resolveConnectionType(path) + + if connected && !previouslyConnected { + self.networkRestoredPublisher.send() + } + + if !connected { + self.wasDisconnected = true + } + } + } + monitor.start(queue: monitorQueue) + } + + private static func resolveConnectionType(_ path: NWPath) -> ConnectionType { + if path.usesInterfaceType(.wifi) { return .wifi } + if path.usesInterfaceType(.cellular) { return .cellular } + if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet } + return .unknown + } + + deinit { + monitor.cancel() + } +} diff --git a/tvbox/Utils/Extensions.swift b/tvbox/Utils/Extensions.swift index 3ed6a55..f7f4561 100644 --- a/tvbox/Utils/Extensions.swift +++ b/tvbox/Utils/Extensions.swift @@ -218,6 +218,9 @@ struct CachedAsyncImage: View { @ViewBuilder let placeholder: () -> Placeholder @State private var loadedImage: PlatformImage? + @State private var loadFailed = false + + private static var delayedRetryDelay: TimeInterval { 3.0 } init( url: URL?, @@ -246,6 +249,7 @@ struct CachedAsyncImage: View { } .onDisappear { loadedImage = nil + loadFailed = false } } @@ -265,9 +269,17 @@ struct CachedAsyncImage: View { guard !Task.isCancelled else { return } ImageCache.shared.set(image, for: url) loadedImage = image + loadFailed = false } catch { guard !Task.isCancelled else { return } loadedImage = nil + + if error.isNetworkConnectionError && !loadFailed { + loadFailed = true + try? await Task.sleep(nanoseconds: UInt64(Self.delayedRetryDelay * 1_000_000_000)) + guard !Task.isCancelled else { return } + await loadImage() + } } } } @@ -307,33 +319,64 @@ final class ImageLoader { self.session = URLSession(configuration: config) } + private static let maxImageRetries = 1 + private static let imageRetryDelay: TimeInterval = 1.0 + func load(url: URL) async throws -> PlatformImage { var request = URLRequest(url: url) request.httpMethod = "GET" - // 设置常见的浏览器 User-Agent,避免部分服务器拒绝请求 request.setValue( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent" ) - // 部分图片服务器检查 Referer if let host = url.host { request.setValue("https://\(host)/", forHTTPHeaderField: "Referer") } - let (data, response) = try await session.data(for: request) + var lastError: Error = ImageLoadError.invalidData + let totalAttempts = Self.maxImageRetries + 1 - // 检查 HTTP 状态码 - if let httpResponse = response as? HTTPURLResponse, - !(200...299).contains(httpResponse.statusCode) { - throw ImageLoadError.httpError(httpResponse.statusCode) + for attempt in 0..= totalAttempts - 1 { throw error } + } } - let maxPixelSize = thumbnailMaxPixelSize - guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else { - throw ImageLoadError.invalidData - } - - return image + throw lastError + } + + private static func isRetryableImageError(_ error: Error, statusCode: Int) -> Bool { + [408, 429, 500, 502, 503, 504].contains(statusCode) } var cacheUsage: (memory: Int, disk: Int) { diff --git a/tvbox/ViewModels/HomeViewModel.swift b/tvbox/ViewModels/HomeViewModel.swift index 24ef988..c3f62e4 100644 --- a/tvbox/ViewModels/HomeViewModel.swift +++ b/tvbox/ViewModels/HomeViewModel.swift @@ -1,14 +1,15 @@ import Foundation import SwiftUI +import Combine /// 首页 ViewModel @MainActor class HomeViewModel: ObservableObject { - /// 分类列表(包含手动注入的“推荐”分类)。 + /// 分类列表(包含手动注入的"推荐"分类)。 @Published var sorts: [MovieSort.SortData] = [] /// 当前选中的分类。 @Published var selectedSort: MovieSort.SortData? - /// 首页推荐内容(对应“推荐”分类)。 + /// 首页推荐内容(对应"推荐"分类)。 @Published var homeVideos: [Movie.Video] = [] /// 普通分类的视频列表(分页加载)。 @Published var categoryVideos: [Movie.Video] = [] @@ -23,6 +24,13 @@ class HomeViewModel: ObservableObject { /// 源数据访问服务。 private let sourceService = SourceService.shared + /// 标记上次加载是否因网络错误失败(用于网络恢复自动重试)。 + private var lastLoadFailedDueToNetwork = false + private var networkRestoredCancellable: AnyCancellable? + + init() { + setupNetworkRestoredAutoRetry() + } /// 加载分类列表 func loadSorts() async { @@ -33,23 +41,38 @@ class HomeViewModel: ObservableObject { do { let result = try await sourceService.getSort(sourceBean: source) - // 插入本地“推荐”分类,保持 UI 与 Android 版本习惯一致。 + // 插入本地"推荐"分类,保持 UI 与 Android 版本习惯一致。 var allSorts = [MovieSort.SortData.home()] allSorts.append(contentsOf: result.sorts) self.sorts = allSorts self.homeVideos = result.homeVideos + lastLoadFailedDueToNetwork = false if selectedSort == nil { selectedSort = allSorts.first } } catch { errorMessage = error.localizedDescription + lastLoadFailedDueToNetwork = error.isNetworkConnectionError } isLoading = false } + /// 网络恢复时,若上次因网络错误导致首页为空,自动重新加载。 + private func setupNetworkRestoredAutoRetry() { + networkRestoredCancellable = NetworkMonitor.shared.networkRestoredPublisher + .sink { [weak self] in + guard let self else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.lastLoadFailedDueToNetwork || (self.sorts.isEmpty && self.homeVideos.isEmpty) else { return } + await self.refresh() + } + } + } + /// 选择分类 func selectSort(_ sort: MovieSort.SortData) { // 切分类时先重置分页状态,避免旧分类残留数据闪烁。 @@ -89,7 +112,7 @@ class HomeViewModel: ObservableObject { } else { categoryVideos.append(contentsOf: videos) } - // 以“返回非空”作为是否继续分页的轻量判断。 + // 以"返回非空"作为是否继续分页的轻量判断。 currentPage = page hasMore = !videos.isEmpty } catch { diff --git a/tvbox/Views/ContentView.swift b/tvbox/Views/ContentView.swift index 432ea67..f576e98 100644 --- a/tvbox/Views/ContentView.swift +++ b/tvbox/Views/ContentView.swift @@ -10,6 +10,8 @@ struct ContentView: View { /// 全局状态(配置加载、分栏状态等)。 @EnvironmentObject var appState: AppState + /// 网络连接状态。 + @EnvironmentObject var networkMonitor: NetworkMonitor /// 设置页 ViewModel。根视图复用它处理首次配置与多仓库选择。 @StateObject private var settingsVM = SettingsViewModel() /// 当前主标签索引。 @@ -28,6 +30,9 @@ struct ContentView: View { } } .overlay(multiRepoSelectionOverlay) + .overlay(alignment: .top) { + networkStatusBanner + } .preferredColorScheme(.dark) .onAppear { // 自动加载已保存的配置 @@ -353,6 +358,35 @@ struct ContentView: View { } } + /// 网络断开时在顶部显示提示条。 + @ViewBuilder + private var networkStatusBanner: some View { + if !networkMonitor.isConnected { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .font(.system(size: 13, weight: .semibold)) + Text("网络连接已断开") + .font(.system(size: 13, weight: .medium)) + if appState.isRetryingConfig { + ProgressView() + .scaleEffect(0.7) + .tint(.white) + } + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.red.opacity(0.85)) + ) + .shadow(color: .black.opacity(0.3), radius: 8, y: 4) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: networkMonitor.isConnected) + } + } + private func readPasteboardText() -> String? { #if os(iOS) UIPasteboard.general.string diff --git a/tvbox/tvboxApp.swift b/tvbox/tvboxApp.swift index 94be97b..2c1fccc 100644 --- a/tvbox/tvboxApp.swift +++ b/tvbox/tvboxApp.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import Combine /// 应用入口。 /// 负责初始化 SwiftData 容器,并将全局状态 `AppState` 注入到根视图。 @@ -7,6 +8,8 @@ import SwiftData struct tvboxApp: App { /// 全局运行时状态(配置加载状态、当前源、分栏布局状态等)。 @StateObject private var appState = AppState() + /// 网络状态监控。 + @StateObject private var networkMonitor = NetworkMonitor.shared /// 全局共享的 SwiftData 容器。 /// 这里显式声明 Schema,确保收藏/历史/缓存三类数据使用同一持久化存储。 @@ -29,6 +32,7 @@ struct tvboxApp: App { WindowGroup { ContentView() .environmentObject(appState) + .environmentObject(networkMonitor) } .modelContainer(sharedModelContainer) #if os(macOS) @@ -47,6 +51,11 @@ class AppState: ObservableObject { @Published var isConfigLoaded = false /// 当前首页选中的视频源 key(用于跨页面同步)。 @Published var currentSourceKey: String = "" + /// 配置加载错误信息,供 UI 展示。 + @Published var configLoadError: String? + /// 是否正在重试加载配置。 + @Published var isRetryingConfig = false + #if os(macOS) /// macOS 三栏布局可见性(侧栏/内容/详情)。 @Published var splitViewVisibility: NavigationSplitViewVisibility = .all @@ -54,6 +63,15 @@ class AppState: ObservableObject { private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility? #endif + /// 上次尝试加载的配置地址(用于自动重试)。 + private var lastVodUrl: String = "" + private var lastLiveUrl: String = "" + private var networkRestoredCancellable: AnyCancellable? + + init() { + setupNetworkRestoredAutoRetry() + } + /// 仅提供点播地址时的快捷加载入口(直播地址默认与点播一致)。 func loadConfig(url: String) async { await loadConfig(vodUrl: url, liveUrl: nil) @@ -69,21 +87,42 @@ class AppState: ObservableObject { guard !trimmedVod.isEmpty else { return } let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive + lastVodUrl = trimmedVod + lastLiveUrl = resolvedLive + configLoadError = nil + do { try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) applyLoadedConfigState() } catch { - print("Failed to load config: \(error)") + if !(error is CancellationError) { + configLoadError = error.localizedDescription + } } } - /// 将“配置已加载”的统一状态写回全局。 + /// 将"配置已加载"的统一状态写回全局。 /// 该方法会在设置页和启动自动加载两个入口中复用。 func applyLoadedConfigState() { isConfigLoaded = true + configLoadError = nil currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" } + /// 网络恢复时,若配置未成功加载过,自动重试一次。 + private func setupNetworkRestoredAutoRetry() { + networkRestoredCancellable = NetworkMonitor.shared.networkRestoredPublisher + .sink { [weak self] in + guard let self else { return } + Task { @MainActor [weak self] in + guard let self, !self.isConfigLoaded, !self.lastVodUrl.isEmpty else { return } + self.isRetryingConfig = true + await self.loadConfig(vodUrl: self.lastVodUrl, liveUrl: self.lastLiveUrl) + self.isRetryingConfig = false + } + } + } + #if os(macOS) /// 进入播放器全屏时隐藏侧栏,减少播放器可视区域干扰。 func enterPlayerFullScreen() {