Files
tvbox-Swift/tvbox/Views/Settings/SettingsView.swift
T
2026-03-01 14:04:44 +08:00

479 lines
19 KiB
Swift

import SwiftUI
/// 设置页 - 对应 Android 版 SettingActivity + ModelSettingFragment
struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@StateObject private var apiConfig = ApiConfig.shared
@EnvironmentObject var appState: AppState
@State private var showApiInput = false
@State private var showAbout = false
@State private var sourceSearchText = ""
@State private var showingPicker: PickerType = .none
enum PickerType {
case none
case player
case decode
case vlcBuffer
case playTimeStep
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// API 配置
SectionCard(title: "数据源") {
SettingsRow(icon: "link", title: "接口地址", value: viewModel.apiUrl.isEmpty ? "未配置" : viewModel.apiUrl) {
showApiInput = true
}
Divider().background(Color.white.opacity(0.1))
if !apiConfig.sourceBeanList.isEmpty {
NavigationLink {
sourcePickerView
} label: {
SettingsRow(icon: "server.rack", title: "主页数据源", value: apiConfig.homeSourceBean?.name ?? "", action: nil)
}
}
}
// 播放设置
SectionCard(title: "播放设置") {
SettingsRow(icon: "play.rectangle", title: "播放器", value: viewModel.playerEngine.title) {
if viewModel.playerEngineOptions.count > 1 {
showingPicker = .player
}
}
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "cpu", title: "视频解码", value: viewModel.decodeMode.title) {
showingPicker = .decode
}
if PlayerEngine.isVLCAvailable {
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "externaldrive.badge.wifi", title: "VLC缓冲", value: viewModel.vlcBufferMode.title) {
showingPicker = .vlcBuffer
}
}
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "forward", title: "快进步长", value: "\(viewModel.playTimeStep)秒") {
showingPicker = .playTimeStep
}
}
// 功能
SectionCard(title: "功能") {
NavigationLink {
HistoryView()
} label: {
SettingsRow(icon: "clock", title: "播放历史", value: "", action: nil)
}
Divider().background(Color.white.opacity(0.1))
NavigationLink {
FavoritesView()
} label: {
SettingsRow(icon: "heart", title: "我的收藏", value: "", action: nil)
}
}
// 缓存
SectionCard(title: "缓存") {
SettingsRow(icon: "trash", title: "清除缓存", value: viewModel.cacheSizeString) {
viewModel.clearCache()
}
}
// 关于
SectionCard(title: "关于") {
SettingsRow(icon: "info.circle", title: "版本", value: "1.0.0", action: nil)
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "globe", title: "站点数量", value: "\(apiConfig.sourceBeanList.count)", action: nil)
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "wand.and.stars", title: "解析数量", value: "\(apiConfig.parseBeanList.count)", action: nil)
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "tv", title: "直播分组", value: "\(apiConfig.liveChannelGroupList.count)", action: nil)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
.background(AppTheme.primaryGradient.ignoresSafeArea())
.navigationTitle("设置")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbarBackground(.hidden, for: .navigationBar)
#endif
.sheet(isPresented: $showApiInput) {
apiInputSheet
}
}
.overlay(pickerOverlay)
}
// MARK: - 选择器 Overlay
@ViewBuilder
private var pickerOverlay: some View {
switch showingPicker {
case .player:
SelectionModal(
title: "选择播放器",
icon: "play.rectangle.fill",
items: viewModel.playerEngineOptions,
selectedItem: viewModel.playerEngine,
itemTitle: { $0.title },
onSelect: { engine in
viewModel.setPlayerEngine(engine)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .decode:
SelectionModal(
title: "视频解码模式",
icon: "cpu.fill",
items: viewModel.decodeModeOptions,
selectedItem: viewModel.decodeMode,
itemTitle: { $0.title },
onSelect: { mode in
viewModel.setDecodeMode(mode)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .vlcBuffer:
SelectionModal(
title: "VLC 缓冲策略",
icon: "externaldrive.fill",
items: viewModel.vlcBufferModeOptions,
selectedItem: viewModel.vlcBufferMode,
itemTitle: { $0.title },
onSelect: { mode in
viewModel.setVLCBufferMode(mode)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .playTimeStep:
SelectionModal(
title: "快进步长",
icon: "forward.fill",
items: viewModel.playTimeStepOptions,
selectedItem: viewModel.playTimeStep,
itemTitle: { "\($0) 秒" },
onSelect: { step in
viewModel.setPlayTimeStep(step)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .none:
EmptyView()
}
}
// MARK: - API 输入弹窗
private var apiInputSheet: some View {
NavigationStack {
VStack(spacing: 16) {
HStack {
Image(systemName: "link")
.foregroundColor(.secondary)
TextField("请输入接口地址", text: $viewModel.apiUrl)
.textFieldStyle(.plain)
#if os(iOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
// 粘贴按钮
HStack {
Button {
#if os(iOS)
if let text = UIPasteboard.general.string {
viewModel.apiUrl = text
}
#else
if let text = NSPasteboard.general.string(forType: .string) {
viewModel.apiUrl = text
}
#endif
} label: {
Label("粘贴", systemImage: "doc.on.clipboard")
.font(.subheadline)
}
Spacer()
}
// 历史记录
if !viewModel.apiHistory.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("历史记录")
.font(.caption)
.foregroundColor(.secondary)
ForEach(viewModel.apiHistory, id: \.self) { url in
HStack {
Button {
viewModel.apiUrl = url
} label: {
HStack {
Image(systemName: "clock")
.font(.caption)
Text(url)
.font(.caption)
.lineLimit(1)
}
.foregroundColor(.secondary)
}
Spacer()
Button {
viewModel.removeApiHistory(url)
} label: {
Image(systemName: "xmark.circle")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
}
if let error = viewModel.configError {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
Spacer()
}
.padding()
.navigationTitle("配置接口")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { showApiInput = false }
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await viewModel.loadConfig()
if viewModel.configSuccess {
await appState.loadConfig(url: viewModel.apiUrl)
showApiInput = false
}
}
} label: {
if viewModel.isLoadingConfig {
ProgressView()
} else {
Text("确认")
}
}
.disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty)
}
}
}
#if os(iOS)
.presentationDetents([.medium, .large])
#endif
}
// MARK: - 源选择
private var filteredSources: [SourceBean] {
let sources = apiConfig.sourceBeanList
if sourceSearchText.isEmpty {
return sources
} else {
return sources.filter { $0.name.localizedCaseInsensitiveContains(sourceSearchText) || $0.api.localizedCaseInsensitiveContains(sourceSearchText) }
}
}
private var sourcePickerView: some View {
VStack(spacing: 0) {
// 搜索栏
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("搜索数据源", text: $sourceSearchText)
.textFieldStyle(.plain)
if !sourceSearchText.isEmpty {
Button(action: { sourceSearchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(12)
.glassCard(cornerRadius: 12)
.padding(.horizontal, 20)
.padding(.vertical, 12)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(filteredSources) { source in
Button {
apiConfig.setHomeSource(source)
appState.currentSourceKey = source.key
} label: {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(source.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(source.isSupportedInSwift ? .white : .white.opacity(0.5))
// 类型标签
Text(source.typeDescription)
.font(.system(size: 10, weight: .bold))
.foregroundColor(source.isSupportedInSwift ? .orange : .gray)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Capsule().fill(
source.isSupportedInSwift ? Color.orange.opacity(0.2) : Color.gray.opacity(0.2)
)
)
if !source.isSupportedInSwift {
Text("暂不支持")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.red.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Capsule().fill(Color.red.opacity(0.15)))
}
}
Text(source.api)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
Spacer()
HStack(spacing: 12) {
if source.isSearchable {
Image(systemName: "magnifyingglass")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green.opacity(0.8))
}
if source.key == apiConfig.homeSourceBean?.key {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.orange)
} else {
Circle()
.strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
.frame(width: 20, height: 20)
}
}
}
.padding(16)
.glassCard(cornerRadius: 16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(
source.key == apiConfig.homeSourceBean?.key ? Color.orange.opacity(0.5) : Color.clear,
lineWidth: 1
)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
.background(AppTheme.primaryGradient.ignoresSafeArea())
.navigationTitle("选择数据源")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
// MARK: - 辅助组件
struct SectionCard<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.white.opacity(0.6))
.padding(.leading, 8)
VStack(spacing: 0) {
content()
}
.glassCard(cornerRadius: 16)
}
}
}
struct SettingsRow: View {
let icon: String
let title: String
let value: String
let action: (() -> Void)?
var body: some View {
Group {
if let action = action {
Button(action: action) {
rowContent
}
.buttonStyle(.plain)
} else {
rowContent
}
}
}
private var rowContent: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(.orange)
.frame(width: 24)
Text(title)
.font(.body)
.foregroundColor(.white.opacity(0.9))
Spacer()
Text(value)
.font(.subheadline)
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(Rectangle())
}
}