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

350 lines
14 KiB
Swift

import SwiftUI
/// 首页 - 对应 Android 版 HomeActivity + UserFragment
struct HomeView: View {
@StateObject private var viewModel = HomeViewModel()
@EnvironmentObject var appState: AppState
@State private var categoryScrollAnchorId: String?
@State private var categoryDragTranslation: CGFloat = 0
// 网格布局
#if os(iOS)
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
#endif
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// 顶部栏
headerBar
// 分类标签栏
if !viewModel.sorts.isEmpty {
categoryTabBar
}
// 内容区
contentArea
}
.background(AppTheme.primaryGradient)
}
.task {
await viewModel.loadSorts()
if let first = viewModel.sorts.first {
viewModel.selectSort(first)
}
}
}
// MARK: - 顶部栏
private var headerBar: some View {
HStack(spacing: 15) {
// 应用名(可切换源)
Menu {
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
Button {
ApiConfig.shared.setHomeSource(source)
Task { await viewModel.refresh() }
} label: {
HStack {
Text(source.name)
if source.key == ApiConfig.shared.homeSourceBean?.key {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.foregroundColor(.orange)
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
.font(.system(size: 15, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.white.opacity(0.5))
.padding(.leading, 2)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.white.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.white.opacity(0.1), lineWidth: 0.5))
}
.menuStyle(.borderlessButton)
.fixedSize()
Spacer()
// 日期时间
HomeClockView()
}
.padding(.horizontal, 20)
.padding(.top, 15)
.padding(.bottom, 10)
}
// MARK: - 分类标签栏
private var categoryTabBar: some View {
ScrollViewReader { proxy in
HStack(spacing: 8) {
categoryMoveButton(
systemName: "chevron.left",
enabled: canMoveCategory(by: -1)
) {
moveCategoryTabs(by: -3, proxy: proxy)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.sorts) { sort in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
viewModel.selectSort(sort)
}
categoryScrollAnchorId = sort.id
scrollCategoryBar(to: sort.id, proxy: proxy)
} label: {
Text(sort.name)
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .medium))
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .orange : .white.opacity(0.8))
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedSort?.id == sort.id {
RoundedRectangle(cornerRadius: 12)
.fill(Color.orange.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.orange.opacity(0.5), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.05))
}
}
)
}
.buttonStyle(.plain)
.id(sort.id)
}
}
.padding(.horizontal, 8)
}
.simultaneousGesture(categoryDragGesture(proxy: proxy))
.onAppear {
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.selectedSort?.id) { oldId, newId in
guard let newId else { return }
categoryScrollAnchorId = newId
scrollCategoryBar(to: newId, proxy: proxy)
}
categoryMoveButton(
systemName: "chevron.right",
enabled: canMoveCategory(by: 1)
) {
moveCategoryTabs(by: 3, proxy: proxy)
}
}
.padding(.horizontal, 14)
}
.padding(.vertical, 8)
}
private func categoryMoveButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white.opacity(enabled ? 0.9 : 0.35))
.frame(width: 26, height: 26)
.background(Color.white.opacity(0.08))
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
private func canMoveCategory(by direction: Int) -> Bool {
guard !viewModel.sorts.isEmpty else { return false }
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
if direction < 0 {
return currentIndex > 0
}
return currentIndex < viewModel.sorts.count - 1
}
private func categoryIndex(for id: String?) -> Int? {
guard let id else { return nil }
return viewModel.sorts.firstIndex(where: { $0.id == id })
}
private func syncCategoryScrollAnchorIfNeeded() {
guard !viewModel.sorts.isEmpty else {
categoryScrollAnchorId = nil
return
}
if let selectedId = viewModel.selectedSort?.id,
viewModel.sorts.contains(where: { $0.id == selectedId }) {
categoryScrollAnchorId = selectedId
return
}
if let anchorId = categoryScrollAnchorId,
viewModel.sorts.contains(where: { $0.id == anchorId }) {
return
}
categoryScrollAnchorId = viewModel.sorts.first?.id
}
private func moveCategoryTabs(by delta: Int, proxy: ScrollViewProxy) {
guard !viewModel.sorts.isEmpty else { return }
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
let newIndex = min(max(0, currentIndex + delta), viewModel.sorts.count - 1)
guard newIndex != currentIndex else { return }
let targetId = viewModel.sorts[newIndex].id
categoryScrollAnchorId = targetId
scrollCategoryBar(to: targetId, proxy: proxy)
}
private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) {
guard let id else { return }
if animated {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(id, anchor: .center)
}
} else {
proxy.scrollTo(id, anchor: .center)
}
}
private func categoryDragGesture(proxy: ScrollViewProxy) -> some Gesture {
DragGesture(minimumDistance: 12)
.onChanged { value in
let delta = value.translation.width - categoryDragTranslation
if delta <= -28 {
moveCategoryTabs(by: 1, proxy: proxy)
categoryDragTranslation = value.translation.width
} else if delta >= 28 {
moveCategoryTabs(by: -1, proxy: proxy)
categoryDragTranslation = value.translation.width
}
}
.onEnded { _ in
categoryDragTranslation = 0
}
}
// MARK: - 内容区
private var contentArea: some View {
Group {
if viewModel.isLoading && viewModel.categoryVideos.isEmpty && viewModel.homeVideos.isEmpty {
VStack {
Spacer()
ProgressView()
.scaleEffect(1.5)
.tint(.orange)
Text("加载中...")
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.top, 12)
Spacer()
}
} else if let error = viewModel.errorMessage, viewModel.categoryVideos.isEmpty && viewModel.homeVideos.isEmpty {
VStack(spacing: 12) {
Spacer()
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
// 如果是不支持的源类型,显示类型信息
if let source = ApiConfig.shared.homeSourceBean, !source.isSupportedInSwift {
Text("当前源类型: \(source.typeDescription)")
.font(.caption)
.foregroundColor(.secondary)
}
Button("重试") {
Task { await viewModel.refresh() }
}
.buttonStyle(.borderedProminent)
.tint(.orange)
Spacer()
}
} else {
let videos = viewModel.selectedSort?.id == "home"
? viewModel.homeVideos
: viewModel.categoryVideos
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(videos) { video in
NavigationLink(value: video) {
VodCardView(video: video)
}
.buttonStyle(.plain)
.onAppear {
Task { await viewModel.loadMoreIfNeeded(currentItem: video) }
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
// 加载更多
if viewModel.selectedSort?.id != "home" && viewModel.hasMore {
ProgressView()
.padding()
}
}
.refreshable {
await viewModel.refresh()
}
}
}
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
}
private struct HomeClockView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(timeline.date.homeDateString)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.glassCard(cornerRadius: 12)
}
}
}