Files
2026-05-14 17:50:06 +08:00

404 lines
16 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航
struct ContentView: View {
/// 首次配置页点击“最近使用”时,当前要写入的输入框目标。
private enum ApiInputTarget {
case vod
case live
}
/// 全局状态(配置加载、分栏状态等)。
@EnvironmentObject var appState: AppState
/// 网络连接状态。
@EnvironmentObject var networkMonitor: NetworkMonitor
/// 设置页 ViewModel。根视图复用它处理首次配置与多仓库选择。
@StateObject private var settingsVM = SettingsViewModel()
/// 当前主标签索引。
@State private var selectedTab = 0
/// 预留:控制首次配置页显隐(当前逻辑由 `appState.isConfigLoaded` 驱动)。
@State private var showSetup = false
/// 首次配置页历史回填目标输入框。
@State private var setupInputTarget: ApiInputTarget = .vod
var body: some View {
Group {
if appState.isConfigLoaded {
mainTabView
} else {
setupView
}
}
.overlay(multiRepoSelectionOverlay)
.overlay(alignment: .top) {
networkStatusBanner
}
.preferredColorScheme(.dark)
.onAppear {
// 自动加载已保存的配置
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(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
}
}
}
}
@ViewBuilder
private var multiRepoSelectionOverlay: some View {
// 若配置地址解析出“多仓库入口”,在根层统一弹窗,避免被子页面导航遮挡。
if let pending = settingsVM.pendingMultiRepoSelection {
SelectionModal(
title: "选择\(pending.target.title)仓库",
icon: "list.bullet.rectangle.portrait.fill",
items: pending.options,
selectedItem: nil,
itemTitle: { $0.name },
onSelect: { option in
Task {
await settingsVM.selectPendingMultiRepoOption(option)
if settingsVM.configSuccess {
appState.applyLoadedConfigState()
}
}
},
onCancel: {
settingsVM.cancelPendingMultiRepoSelection()
}
)
}
}
// MARK: - 主界面
/// 主体导航容器:iOS 使用 TabViewmacOS 使用 NavigationSplitView。
private var mainTabView: some View {
#if os(iOS)
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("首页", systemImage: "house.fill")
}
.tag(0)
LiveView(onExit: {
selectedTab = 0
})
.tabItem {
Label("直播", systemImage: "tv.fill")
}
.tag(1)
SearchView()
.tabItem {
Label("搜索", systemImage: "magnifyingglass")
}
.tag(2)
ProfileView()
.tabItem {
Label("个人中心", systemImage: "person.fill")
}
.tag(3)
}
.tint(.orange)
.onChange(of: selectedTab) { _, _ in
HapticManager.shared.selection()
}
#else
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
List(selection: $selectedTab) {
Label("首页", systemImage: "house.fill")
.tag(0)
Label("直播", systemImage: "tv.fill")
.tag(1)
Label("搜索", systemImage: "magnifyingglass")
.tag(2)
Label("收藏", systemImage: "heart.fill")
.tag(3)
Label("历史", systemImage: "clock.fill")
.tag(5)
Label("设置", systemImage: "gearshape.fill")
.tag(4)
}
.navigationTitle("TVBox")
.listStyle(.sidebar)
} detail: {
switch selectedTab {
case 0: HomeView()
case 1: LiveView()
case 2: SearchView()
case 3:
NavigationStack {
FavoritesView()
}
case 4: SettingsView()
case 5:
NavigationStack {
HistoryView()
}
default: HomeView()
}
}
#endif
}
// MARK: - 首次配置页面
/// 首次启动或未加载配置时的引导页面。
private var setupView: some View {
ZStack {
// 背景装饰
AppTheme.primaryGradient
.ignoresSafeArea()
// 装饰性光晕
VStack {
HStack {
Circle()
.fill(Color.orange.opacity(0.15))
.frame(width: 300, height: 300)
.blur(radius: 80)
.offset(x: -100, y: -100)
Spacer()
}
Spacer()
HStack {
Spacer()
Circle()
.fill(Color.red.opacity(0.15))
.frame(width: 300, height: 300)
.blur(radius: 80)
.offset(x: 100, y: 100)
}
}
.ignoresSafeArea()
ScrollView {
VStack(spacing: 32) {
// Logo 区域
VStack(spacing: 20) {
ZStack {
Circle()
.fill(AppTheme.accentGradient)
.frame(width: 100, height: 100)
.blur(radius: 20)
.opacity(0.5)
Image(systemName: "play.tv.fill")
.font(.system(size: 80))
.foregroundStyle(
AppTheme.accentGradient
)
.shadow(color: .red.opacity(0.3), radius: 15, x: 0, y: 10)
}
VStack(spacing: 8) {
Text("TVBox")
.font(.system(size: 48, weight: .heavy, design: .rounded))
.foregroundColor(.white)
.tracking(2)
Text("极致视听 · 简洁至上")
.font(.subheadline)
.foregroundColor(.white.opacity(0.6))
.tracking(4)
}
}
.padding(.top, 60)
// 输入表单
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 12) {
Text("接口配置")
.font(.headline)
.foregroundColor(.white)
.padding(.leading, 4)
HStack {
Image(systemName: "link")
.foregroundColor(.orange)
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)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
Button {
if let text = readPasteboardText() {
settingsVM.liveApiUrl = text
}
} label: {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.orange)
}
.buttonStyle(.plain)
}
.padding()
.glassCard(cornerRadius: 15)
}
// 确认按钮
Button {
Task {
await settingsVM.loadConfig()
if settingsVM.configSuccess {
appState.applyLoadedConfigState()
}
}
} label: {
HStack {
if settingsVM.isLoadingConfig {
ProgressView()
.tint(.white)
.padding(.trailing, 8)
}
Text(settingsVM.isLoadingConfig ? "正在解析配置..." : "开启影音之旅")
.fontWeight(.bold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(AppTheme.accentGradient)
.foregroundColor(.white)
.clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
}
.buttonStyle(.plain)
.disabled(
settingsVM.isLoadingConfig
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
// 历史记录
if !settingsVM.apiHistory.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("最近使用")
.font(.caption)
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 4)
ForEach(settingsVM.apiHistory.prefix(3), id: \.self) { url in
Button {
switch setupInputTarget {
case .vod:
settingsVM.vodApiUrl = url
case .live:
settingsVM.liveApiUrl = url
}
} label: {
HStack {
Image(systemName: "clock.arrow.2.circlepath")
.font(.caption)
Text(url)
.font(.caption)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 8))
}
.padding(.vertical, 10)
.padding(.horizontal, 16)
.foregroundColor(.white.opacity(0.7))
.glassCard(cornerRadius: 10)
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal, 30)
// 错误提示
if let error = settingsVM.configError {
HStack {
Image(systemName: "exclamationmark.circle.fill")
Text(error)
}
.font(.caption)
.foregroundColor(.red)
.padding()
.glassCard(cornerRadius: 10)
.padding(.horizontal, 30)
}
Spacer(minLength: 50)
}
}
}
}
/// 网络断开时在顶部显示提示条。
@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
#else
// macOS 下通过 NSPasteboard 读取纯文本。
NSPasteboard.general.string(forType: .string)
#endif
}
}