fix network
This commit is contained in:
@@ -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<T: Decodable>(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<T: Decodable>(
|
||||
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..<totalAttempts {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
let error = NetworkError.httpError(httpResponse.statusCode)
|
||||
if Self.isRetryableHTTPStatus(httpResponse.statusCode) && attempt < totalAttempts - 1 {
|
||||
lastError = error
|
||||
try await retryDelay(attempt: attempt)
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return (data, httpResponse)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
lastError = error
|
||||
if Self.isRetryableError(error) && attempt < totalAttempts - 1 {
|
||||
try await retryDelay(attempt: attempt)
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/// 指数退避延迟 + 随机抖动,避免多请求同时重试造成惊群效应。
|
||||
private func retryDelay(attempt: Int) async throws {
|
||||
let base = Self.retryBaseDelay * pow(2.0, Double(attempt))
|
||||
let jitter = Double.random(in: 0...0.5)
|
||||
let delay = min(base + jitter, Self.retryMaxDelay)
|
||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
}
|
||||
|
||||
/// 判断 HTTP 状态码是否值得重试(仅服务端临时错误)。
|
||||
private static func isRetryableHTTPStatus(_ statusCode: Int) -> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,9 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: 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<Content: View, Placeholder: View>: View {
|
||||
}
|
||||
.onDisappear {
|
||||
loadedImage = nil
|
||||
loadFailed = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,9 +269,17 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: 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 {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let error = ImageLoadError.httpError(httpResponse.statusCode)
|
||||
if Self.isRetryableImageError(error, statusCode: httpResponse.statusCode),
|
||||
attempt < totalAttempts - 1 {
|
||||
lastError = error
|
||||
try await Task.sleep(nanoseconds: UInt64(Self.imageRetryDelay * 1_000_000_000))
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let maxPixelSize = thumbnailMaxPixelSize
|
||||
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else {
|
||||
throw ImageLoadError.invalidData
|
||||
}
|
||||
|
||||
return image
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
lastError = error
|
||||
if error.isNetworkConnectionError && attempt < totalAttempts - 1 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.imageRetryDelay * 1_000_000_000))
|
||||
continue
|
||||
}
|
||||
if attempt >= 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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+41
-2
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user