first commit
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user