feat: 视频列表本地缓存机制,搜索/重置/换页不再调用 API,避免间歇性签名错误
CI / lint-and-build (push) Has been cancelled

This commit is contained in:
2026-06-30 11:45:42 +06:00
parent 7593e054b7
commit ba54db52e5
@@ -1,48 +1,63 @@
import SwiftUI import SwiftUI
struct VideoListView: View { struct VideoListView: View {
@State private var videos: [VideoItem] = [] @State private var allVideos: [VideoItem] = []
@State private var filteredVideos: [VideoItem] = []
@State private var displayVideos: [VideoItem] = []
@State private var selectedVideos: Set<String> = [] @State private var selectedVideos: Set<String> = []
@State private var keyword: String = "" @State private var keyword: String = ""
@State private var isLoading = false @State private var isLoading = false
@State private var total = 0 @State private var loadingStatus = ""
@State private var pageNo = 1
@State private var pageSize = 10
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showDownloadPanel = false @State private var showDownloadPanel = false
@State private var downloadResults: [(video: VideoItem, playInfos: [PlayInfo])] = [] @State private var downloadResults: [(video: VideoItem, playInfos: [PlayInfo])] = []
private let pageSizes = [20, 50, 100]
@State private var pageSizeIndex = 0
private var pageSize: Int { pageSizes[pageSizeIndex] }
@State private var pageNo = 1
private var totalPages: Int { max(1, (filteredVideos.count + pageSize - 1) / pageSize) }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 12) { HStack(spacing: 12) {
TextField("搜索标题 / 视频 ID", text: $keyword) TextField("搜索本地缓存标题", text: $keyword)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(minWidth: 200) .frame(minWidth: 200)
.onChange(of: keyword) { _, _ in
applyFilter()
}
Button("搜索") { Button("搜索") {
pageNo = 1 applyFilter()
Task { await fetch() }
} }
.keyboardShortcut(.return, modifiers: [.command]) .keyboardShortcut(.return, modifiers: [.command])
Button("重置") { Button("重置") {
keyword = "" keyword = ""
pageNo = 1 applyFilter()
Task { await fetch() }
} }
Spacer() Spacer()
Button {
Task { await refreshFromAPI() }
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(isLoading)
.help("从服务器刷新")
Button("批量下载") { Button("批量下载") {
Task { await prepareDownload() } Task { await prepareDownload() }
} }
.disabled(selectedVideos.isEmpty || isLoading) .disabled(selectedVideos.isEmpty || isLoading)
Button("全选") { Button("全选") {
if selectedVideos.count == videos.count { if selectedVideos.count == displayVideos.count {
selectedVideos.removeAll() selectedVideos.removeAll()
} else { } else {
selectedVideos = Set(videos.map(\.videoId)) selectedVideos = Set(displayVideos.map(\.videoId))
} }
} }
@@ -72,6 +87,18 @@ struct VideoListView: View {
.padding(.horizontal) .padding(.horizontal)
} }
if isLoading && !loadingStatus.isEmpty {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text(loadingStatus)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 6)
}
Table(of: VideoItem.self, selection: $selectedVideos) { Table(of: VideoItem.self, selection: $selectedVideos) {
TableColumn("") { video in TableColumn("") { video in
Image(systemName: selectedVideos.contains(video.videoId) ? "checkmark.circle.fill" : "circle") Image(systemName: selectedVideos.contains(video.videoId) ? "checkmark.circle.fill" : "circle")
@@ -129,51 +156,103 @@ struct VideoListView: View {
} }
.width(min: 120, ideal: 160) .width(min: 120, ideal: 160)
} rows: { } rows: {
ForEach(videos) { video in ForEach(displayVideos) { video in
TableRow(video) TableRow(video)
} }
} }
.frame(minHeight: 300) .frame(minHeight: 300)
HStack { HStack {
Text("\(total) 条,已选 \(selectedVideos.count)") Text("\(filteredVideos.count) 条,已选 \(selectedVideos.count)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
Picker("每页", selection: $pageSizeIndex) {
ForEach(0..<pageSizes.count, id: \.self) { i in
Text("\(pageSizes[i])").tag(i)
}
}
.frame(width: 80)
.onChange(of: pageSizeIndex) { _, _ in
pageNo = 1
updateDisplay()
}
Button("上一页") { Button("上一页") {
pageNo -= 1 pageNo -= 1
Task { await fetch() } updateDisplay()
} }
.disabled(pageNo <= 1) .disabled(pageNo <= 1)
Text("\(pageNo)") Text("\(pageNo)/\(totalPages)")
Button("下一页") { Button("下一页") {
pageNo += 1 pageNo += 1
Task { await fetch() } updateDisplay()
} }
.disabled(pageNo * pageSize >= total) .disabled(pageNo >= totalPages)
} }
.padding() .padding()
} }
.task { .task {
await fetch() if allVideos.isEmpty {
await refreshFromAPI()
}
} }
.sheet(isPresented: $showDownloadPanel) { .sheet(isPresented: $showDownloadPanel) {
DownloadPanelView(items: downloadResults) DownloadPanelView(items: downloadResults)
} }
} }
private func fetch() async { private func applyFilter() {
let kw = keyword.trimmingCharacters(in: .whitespaces)
if kw.isEmpty {
filteredVideos = allVideos
} else {
filteredVideos = allVideos.filter {
$0.title.localizedCaseInsensitiveContains(kw) ||
$0.videoId.localizedCaseInsensitiveContains(kw)
}
}
pageNo = 1
selectedVideos.removeAll()
updateDisplay()
}
private func updateDisplay() {
let start = (pageNo - 1) * pageSize
let end = min(start + pageSize, filteredVideos.count)
if start < filteredVideos.count {
displayVideos = Array(filteredVideos[start..<end])
} else {
displayVideos = []
}
}
private func refreshFromAPI() async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
loadingStatus = "正在加载视频列表..."
var collected: [VideoItem] = []
var page = 1
let size = 100
var total = 0
do { do {
let response = try await VODClient.shared.searchMedia( while true {
keyword: keyword.isEmpty ? nil : keyword, loadingStatus = "正在加载视频列表... (第 \(page) 页)"
pageNo: pageNo, let response = try await VODClient.shared.searchMedia(
pageSize: pageSize pageNo: page,
) pageSize: size
videos = response.videos )
total = response.Total ?? 0 let videos = response.videos
pageNo = response.PageNo ?? pageNo collected.append(contentsOf: videos)
pageSize = response.PageSize ?? pageSize total = response.Total ?? 0
if collected.count >= total || videos.isEmpty {
break
}
page += 1
}
allVideos = collected
applyFilter()
loadingStatus = ""
} catch { } catch {
errorMessage = "加载失败:\(error.localizedDescription)" errorMessage = "加载失败:\(error.localizedDescription)"
} }
@@ -185,7 +264,8 @@ struct VideoListView: View {
do { do {
try await VODClient.shared.deleteVideos(videoIds: ids) try await VODClient.shared.deleteVideos(videoIds: ids)
selectedVideos.removeAll() selectedVideos.removeAll()
await fetch() allVideos.removeAll { ids.contains($0.videoId) }
applyFilter()
} catch { } catch {
errorMessage = "删除失败:\(error.localizedDescription)" errorMessage = "删除失败:\(error.localizedDescription)"
} }
@@ -194,20 +274,19 @@ struct VideoListView: View {
private func prepareDownload() async { private func prepareDownload() async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
loadingStatus = ""
var results: [(video: VideoItem, playInfos: [PlayInfo])] = [] var results: [(video: VideoItem, playInfos: [PlayInfo])] = []
var errors: [String] = [] var errors: [String] = []
for video in videos where selectedVideos.contains(video.videoId) { let selected = displayVideos.filter { selectedVideos.contains($0.videoId) }
do { for (index, video) in selected.enumerated() {
let response = try await VODClient.shared.getPlayInfo(videoId: video.videoId) loadingStatus = "正在获取下载地址 (\(index + 1)/\(selected.count))「\(video.title)」..."
results.append((video, response.playInfos)) let playInfos = await fetchPlayInfoWithRetry(videoId: video.videoId, title: video.title, maxRetries: 3)
if response.playInfos.isEmpty { results.append((video, playInfos))
errors.append("\(video.title)」无可用播放地址") if playInfos.isEmpty {
} errors.append("\(video.title)」无可用播放地址")
} catch {
results.append((video, []))
errors.append("\(video.title)」获取播放信息失败:\(error.localizedDescription)")
} }
} }
loadingStatus = ""
if !errors.isEmpty { if !errors.isEmpty {
errorMessage = errors.joined(separator: "\n") errorMessage = errors.joined(separator: "\n")
} }
@@ -216,6 +295,29 @@ struct VideoListView: View {
isLoading = false isLoading = false
} }
private func fetchPlayInfoWithRetry(videoId: String, title: String, maxRetries: Int) async -> [PlayInfo] {
for attempt in 1...maxRetries {
do {
let response = try await VODClient.shared.getPlayInfo(videoId: videoId)
if !response.playInfos.isEmpty {
return response.playInfos
}
if attempt < maxRetries {
print("[Download]「\(title)」第 \(attempt) 次获取到空播放地址,\(attempt)s 后重试...")
try? await Task.sleep(for: .seconds(attempt))
}
} catch {
if attempt < maxRetries {
print("[Download]「\(title)」第 \(attempt) 次获取失败: \(error)\(attempt)s 后重试...")
try? await Task.sleep(for: .seconds(attempt))
} else {
print("[Download]「\(title)\(maxRetries) 次尝试均失败: \(error)")
}
}
}
return []
}
private func formatDuration(_ seconds: Double?) -> String { private func formatDuration(_ seconds: Double?) -> String {
guard let seconds = seconds else { return "-" } guard let seconds = seconds else { return "-" }
let m = Int(seconds) / 60 let m = Int(seconds) / 60