增加注解
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 通用空状态组件。
|
||||
/// 在收藏、历史等页面复用,统一空页面视觉风格。
|
||||
struct EmptyStateView: View {
|
||||
/// SF Symbol 图标名。
|
||||
let icon: String
|
||||
/// 主标题。
|
||||
let title: String
|
||||
/// 可选说明文字,为空时不渲染副文案区域。
|
||||
var message: String? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// 标题强调当前页面状态,例如“暂无收藏”“暂无播放记录”。
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.tracking(1)
|
||||
|
||||
// 副文案用于提供下一步引导,不参与核心逻辑判断。
|
||||
if let message = message {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
|
||||
@@ -2,15 +2,21 @@ import SwiftUI
|
||||
|
||||
/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航
|
||||
struct ContentView: View {
|
||||
/// 首次配置页点击“最近使用”时,当前要写入的输入框目标。
|
||||
private enum ApiInputTarget {
|
||||
case vod
|
||||
case live
|
||||
}
|
||||
|
||||
/// 全局状态(配置加载、分栏状态等)。
|
||||
@EnvironmentObject var appState: AppState
|
||||
/// 设置页 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 {
|
||||
@@ -29,6 +35,7 @@ struct ContentView: View {
|
||||
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)
|
||||
}
|
||||
@@ -38,6 +45,7 @@ struct ContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var multiRepoSelectionOverlay: some View {
|
||||
// 若配置地址解析出“多仓库入口”,在根层统一弹窗,避免被子页面导航遮挡。
|
||||
if let pending = settingsVM.pendingMultiRepoSelection {
|
||||
SelectionModal(
|
||||
title: "选择\(pending.target.title)仓库",
|
||||
@@ -62,6 +70,7 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - 主界面
|
||||
|
||||
/// 主体导航容器:iOS 使用 TabView,macOS 使用 NavigationSplitView。
|
||||
private var mainTabView: some View {
|
||||
#if os(iOS)
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -130,6 +139,7 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - 首次配置页面
|
||||
|
||||
/// 首次启动或未加载配置时的引导页面。
|
||||
private var setupView: some View {
|
||||
ZStack {
|
||||
// 背景装饰
|
||||
@@ -346,6 +356,7 @@ struct ContentView: View {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string
|
||||
#else
|
||||
// macOS 下通过 NSPasteboard 读取纯文本。
|
||||
NSPasteboard.general.string(forType: .string)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
|
||||
// 该文件已迁移至 `tvbox/Utils/Extensions.swift`。
|
||||
// 保留此占位文件仅为兼容历史引用,避免旧路径导入时报错。
|
||||
|
||||
@@ -2,17 +2,24 @@ import SwiftUI
|
||||
|
||||
/// 剧集列表组件 - 对应 Android 版 SeriesAdapter
|
||||
struct EpisodeListView: View {
|
||||
/// 当前线路下的全部剧集。
|
||||
let episodes: [VodInfo.Episode]
|
||||
/// 外部传入的当前选中集索引(绝对索引)。
|
||||
let selectedIndex: Int
|
||||
/// 点击某一集后的回调(返回绝对索引)。
|
||||
let onSelect: (Int) -> Void
|
||||
|
||||
/// 当前分组索引(每 50 集一个分组,避免超长列表影响渲染与选择体验)。
|
||||
@State private var currentGroup = 0
|
||||
/// 每个分组展示的剧集数量。
|
||||
private let groupSize = 50
|
||||
|
||||
/// 分组总数,至少为 1,避免空数组时出现 0 组的边界问题。
|
||||
private var groupCount: Int {
|
||||
max(1, (episodes.count + groupSize - 1) / groupSize)
|
||||
}
|
||||
|
||||
/// 当前分组对应的切片数据。
|
||||
private var currentEpisodes: [VodInfo.Episode] {
|
||||
let start = currentGroup * groupSize
|
||||
let end = min(start + groupSize, episodes.count)
|
||||
@@ -62,6 +69,7 @@ struct EpisodeListView: View {
|
||||
GridItem(.fixed(44)),
|
||||
GridItem(.fixed(44))
|
||||
], spacing: 10) {
|
||||
// 这里使用当前组内索引 + 组偏移,换算成全局索引以便外部状态一致。
|
||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||
let actualIndex = currentGroup * groupSize + index
|
||||
Button {
|
||||
|
||||
@@ -3,15 +3,19 @@ import SwiftData
|
||||
|
||||
/// 收藏页 - 对应 Android 版 CollectActivity
|
||||
struct FavoritesView: View {
|
||||
/// 按更新时间倒序展示收藏,最近收藏/更新的内容靠前。
|
||||
@Query(sort: \VodCollect.updateTime, order: .reverse)
|
||||
private var favorites: [VodCollect]
|
||||
/// SwiftData 上下文,用于删除收藏并持久化。
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 下卡片尺寸更紧凑,适配手机竖屏。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 下卡片适度放大,提升桌面端可读性。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -25,6 +29,7 @@ struct FavoritesView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
||||
ForEach(favorites) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
favoriteCard(item)
|
||||
@@ -50,12 +55,14 @@ struct FavoritesView: View {
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
// 通过 Movie.Video 作为路由载体,保持与首页/搜索页一致的详情入口。
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 空列表占位。
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "heart.text.square",
|
||||
@@ -65,6 +72,8 @@ struct FavoritesView: View {
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
/// 收藏卡片。
|
||||
/// 只展示海报与标题,保持网格信息密度一致。
|
||||
private func favoriteCard(_ item: VodCollect) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
|
||||
@@ -83,6 +92,7 @@ struct FavoritesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将收藏记录映射成详情页可识别的视频对象。
|
||||
private func movieVideo(from item: VodCollect) -> Movie.Video {
|
||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ import SwiftData
|
||||
|
||||
/// 历史记录页 - 对应 Android 版 HistoryActivity
|
||||
struct HistoryView: View {
|
||||
/// 按最近播放时间倒序展示历史记录。
|
||||
@Query(sort: \VodRecord.updateTime, order: .reverse)
|
||||
private var records: [VodRecord]
|
||||
/// SwiftData 上下文,用于删除单条记录或清空历史。
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 网格配置。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 网格配置。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -25,6 +29,7 @@ struct HistoryView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 记录卡片支持跳转详情与右键删除。
|
||||
ForEach(records) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
recordCard(item)
|
||||
@@ -53,6 +58,7 @@ struct HistoryView: View {
|
||||
.toolbar {
|
||||
if !records.isEmpty {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
// 清空历史使用统一缓存服务,确保行为与其他入口一致。
|
||||
Button {
|
||||
Task {
|
||||
CacheStore.shared.clearHistory(context: modelContext)
|
||||
@@ -64,12 +70,14 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 与首页/搜索/收藏共用同一种详情路由模型。
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 无历史时的占位视图。
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "clock.arrow.circlepath",
|
||||
@@ -79,6 +87,8 @@ struct HistoryView: View {
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
/// 历史卡片。
|
||||
/// 除海报和标题外,额外显示播放进度与更新时间,便于快速续播。
|
||||
private func recordCard(_ item: VodRecord) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
@@ -115,6 +125,7 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将历史记录转换为详情页的入参模型。
|
||||
private func movieVideo(from item: VodRecord) -> Movie.Video {
|
||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import SwiftUI
|
||||
|
||||
/// 视频卡片组件
|
||||
struct VodCardView: View {
|
||||
/// 卡片对应的视频数据。
|
||||
let video: Movie.Video
|
||||
/// 悬停状态(主要用于 macOS 悬停放大动效)。
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +46,7 @@ struct VodCardView: View {
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
// 悬停缩放只增强视觉反馈,不影响点击命中区域。
|
||||
.scaleEffect(isHovered ? 1.05 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
|
||||
.onHover { hovering in
|
||||
@@ -67,6 +70,7 @@ struct VodCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
||||
private var placeholderImage: some View {
|
||||
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
|
||||
@@ -6,24 +6,43 @@ import AppKit
|
||||
|
||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||
struct LiveView: View {
|
||||
/// 直播频道与选中状态管理。
|
||||
@StateObject private var viewModel = LiveViewModel()
|
||||
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
||||
@EnvironmentObject var appState: AppState
|
||||
/// 系统播放器实例(仅在选择系统内核时使用)。
|
||||
@State private var avPlayer: AVPlayer?
|
||||
/// 直播播放器内核配置(新字段)。
|
||||
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
|
||||
/// 兼容旧版本单播放器字段。
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||
/// 是否展示左侧频道抽屉。
|
||||
@State private var showChannelDrawer = true
|
||||
/// 当前窗口是否处于全屏。
|
||||
@State private var isWindowFullScreen = false
|
||||
/// 底部频道信息卡最大宽度。
|
||||
private let currentChannelInfoMaxWidth: CGFloat = 600
|
||||
/// AVPlayer 状态观察者。
|
||||
@State private var itemStatusObserver: NSKeyValueObservation?
|
||||
/// 播放失败通知观察者。
|
||||
@State private var playbackFailedObserver: NSObjectProtocol?
|
||||
/// 播放卡顿通知观察者。
|
||||
@State private var playbackStalledObserver: NSObjectProtocol?
|
||||
/// 当前频道已失败的线路索引,用于自动切线去重。
|
||||
@State private var failedSourceIndices: Set<Int> = []
|
||||
/// 当前跟踪的频道 ID(频道切换时重置失败状态)。
|
||||
@State private var trackedChannelId: String = ""
|
||||
/// 底部频道信息是否显示。
|
||||
@State private var showCurrentChannelInfo = true
|
||||
/// 自动隐藏频道信息的定时器。
|
||||
@State private var channelInfoTimer: Timer?
|
||||
/// 频道信息自动隐藏延迟(秒)。
|
||||
private let channelInfoAutoHideDelay: TimeInterval = 3.0
|
||||
/// 用户交互令牌,递增后可通知 VLC 子视图重置自动隐藏逻辑。
|
||||
@State private var vlcInteractionToken = 0
|
||||
|
||||
/// 当前实际生效的播放器内核。
|
||||
/// 优先读取直播专用字段,再回退老字段,最后使用默认值。
|
||||
private var selectedEngine: PlayerEngine {
|
||||
let defaults = UserDefaults.standard
|
||||
let rawValue: Int
|
||||
@@ -80,6 +99,7 @@ struct LiveView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
// 首次进入时加载频道并展示频道信息卡。
|
||||
viewModel.loadChannels()
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
@@ -92,6 +112,7 @@ struct LiveView: View {
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: viewModel.currentChannel?.id) { _, _ in
|
||||
// 切台后清空失败线路记录,避免复用上个频道的失败状态。
|
||||
resetFailureTracking(for: viewModel.currentChannel)
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
@@ -364,6 +385,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func reportUserActivity() {
|
||||
// 任意交互都刷新显示时间,并触发 VLC 子层同步交互状态。
|
||||
wakeUpCurrentChannelInfo()
|
||||
vlcInteractionToken &+= 1
|
||||
}
|
||||
@@ -459,6 +481,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func cleanupPlayer() {
|
||||
// 先移除观察者再释放播放器,避免悬空回调。
|
||||
if let observer = playbackFailedObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
playbackFailedObserver = nil
|
||||
@@ -475,6 +498,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func observePlaybackFailure(for item: AVPlayerItem) {
|
||||
// KVO 监听 item 状态失败。
|
||||
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||
if observedItem.status == .failed {
|
||||
DispatchQueue.main.async {
|
||||
@@ -483,6 +507,7 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 播放到结尾失败回调。
|
||||
playbackFailedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: item,
|
||||
@@ -491,6 +516,7 @@ struct LiveView: View {
|
||||
handlePlaybackFailure(trigger: "item_failed")
|
||||
}
|
||||
|
||||
// 播放卡顿回调。
|
||||
playbackStalledObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: item,
|
||||
@@ -526,6 +552,7 @@ struct LiveView: View {
|
||||
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
|
||||
guard failedSourceIndices.count < totalSources else { return false }
|
||||
|
||||
// 最多轮询 `totalSources` 次,找到一条尚未失败的线路即返回。
|
||||
for _ in 0..<totalSources {
|
||||
viewModel.switchSource()
|
||||
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
|
||||
|
||||
@@ -2,13 +2,16 @@ import SwiftUI
|
||||
|
||||
/// 搜索页 - 对应 Android 版 SearchActivity
|
||||
struct SearchView: View {
|
||||
/// 搜索状态与结果管理。
|
||||
@StateObject private var viewModel = SearchViewModel()
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 卡片网格参数。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 卡片网格参数。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -29,6 +32,7 @@ struct SearchView: View {
|
||||
} else if !viewModel.results.isEmpty {
|
||||
searchResults
|
||||
} else if viewModel.keyword.isEmpty {
|
||||
// 输入为空时显示历史;输入非空但无结果时显示提示文案。
|
||||
searchHistorySection
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
@@ -53,6 +57,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索栏
|
||||
|
||||
/// 顶部搜索输入区。
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -114,6 +119,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索结果
|
||||
|
||||
/// 搜索结果网格。
|
||||
private var searchResults: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
@@ -134,6 +140,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索历史
|
||||
|
||||
/// 搜索历史区域,支持复用历史关键词与一键清空。
|
||||
private var searchHistorySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !viewModel.searchHistory.isEmpty {
|
||||
@@ -183,13 +190,16 @@ struct SearchView: View {
|
||||
|
||||
/// 流式布局
|
||||
struct FlowLayout: Layout {
|
||||
/// 子项间距。
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
/// 计算整体尺寸。
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = arrangement(proposal: proposal, subviews: subviews)
|
||||
return result.size
|
||||
}
|
||||
|
||||
/// 按计算结果放置子视图。
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
|
||||
for (index, position) in result.positions.enumerated() {
|
||||
@@ -197,6 +207,7 @@ struct FlowLayout: Layout {
|
||||
}
|
||||
}
|
||||
|
||||
/// 核心排版算法:按最大宽度逐个放置,超宽后自动换行。
|
||||
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var positions: [CGPoint] = []
|
||||
|
||||
Reference in New Issue
Block a user