Files
2026-03-01 21:43:02 +08:00

573 lines
23 KiB
Swift

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 vodPlayer
case livePlayer
case decode
case vlcBuffer
case playTimeStep
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// API 配置
SectionCard(title: "数据源") {
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))
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.vodPlayerEngine.title) {
if viewModel.playerEngineOptions.count > 1 {
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))
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 .vodPlayer:
SelectionModal(
title: "选择点播播放器",
icon: "play.rectangle.fill",
items: viewModel.playerEngineOptions,
selectedItem: viewModel.vodPlayerEngine,
itemTitle: { $0.title },
onSelect: { engine in
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 }
)
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(editingApiType.placeholder, text: currentApiBinding)
.textFieldStyle(.plain)
#if os(iOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
// 粘贴按钮
HStack {
Button {
if let text = readPasteboardText() {
currentApiBinding.wrappedValue = text
}
} 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 {
currentApiBinding.wrappedValue = 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(editingApiType.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { showApiInput = false }
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await viewModel.loadConfig()
if viewModel.configSuccess {
appState.applyLoadedConfigState()
showApiInput = false
}
}
} label: {
if viewModel.isLoadingConfig {
ProgressView()
} else {
Text("确认")
}
}
.disabled(
viewModel.isLoadingConfig
|| viewModel.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
}
.overlay(multiRepoSelectionOverlay)
#if os(iOS)
.presentationDetents([.medium, .large])
#endif
}
@ViewBuilder
private var multiRepoSelectionOverlay: some View {
if let pending = viewModel.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 viewModel.selectPendingMultiRepoOption(option)
if viewModel.configSuccess {
appState.applyLoadedConfigState()
showApiInput = false
}
}
},
onCancel: {
viewModel.cancelPendingMultiRepoSelection()
}
)
}
}
private var currentApiBinding: Binding<String> {
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] {
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())
}
}