This commit is contained in:
@@ -1,48 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
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 keyword: String = ""
|
||||
@State private var isLoading = false
|
||||
@State private var total = 0
|
||||
@State private var pageNo = 1
|
||||
@State private var pageSize = 10
|
||||
@State private var loadingStatus = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var showDownloadPanel = false
|
||||
@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 {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
TextField("搜索标题 / 视频 ID", text: $keyword)
|
||||
TextField("搜索本地缓存标题", text: $keyword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(minWidth: 200)
|
||||
.onChange(of: keyword) { _, _ in
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
Button("搜索") {
|
||||
pageNo = 1
|
||||
Task { await fetch() }
|
||||
applyFilter()
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
|
||||
Button("重置") {
|
||||
keyword = ""
|
||||
pageNo = 1
|
||||
Task { await fetch() }
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await refreshFromAPI() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.help("从服务器刷新")
|
||||
|
||||
Button("批量下载") {
|
||||
Task { await prepareDownload() }
|
||||
}
|
||||
.disabled(selectedVideos.isEmpty || isLoading)
|
||||
|
||||
Button("全选") {
|
||||
if selectedVideos.count == videos.count {
|
||||
if selectedVideos.count == displayVideos.count {
|
||||
selectedVideos.removeAll()
|
||||
} else {
|
||||
selectedVideos = Set(videos.map(\.videoId))
|
||||
selectedVideos = Set(displayVideos.map(\.videoId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +87,18 @@ struct VideoListView: View {
|
||||
.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) {
|
||||
TableColumn("") { video in
|
||||
Image(systemName: selectedVideos.contains(video.videoId) ? "checkmark.circle.fill" : "circle")
|
||||
@@ -129,51 +156,103 @@ struct VideoListView: View {
|
||||
}
|
||||
.width(min: 120, ideal: 160)
|
||||
} rows: {
|
||||
ForEach(videos) { video in
|
||||
ForEach(displayVideos) { video in
|
||||
TableRow(video)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 300)
|
||||
|
||||
HStack {
|
||||
Text("共 \(total) 条,已选 \(selectedVideos.count) 个")
|
||||
Text("共 \(filteredVideos.count) 条,已选 \(selectedVideos.count) 个")
|
||||
.foregroundStyle(.secondary)
|
||||
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("上一页") {
|
||||
pageNo -= 1
|
||||
Task { await fetch() }
|
||||
updateDisplay()
|
||||
}
|
||||
.disabled(pageNo <= 1)
|
||||
Text("第 \(pageNo) 页")
|
||||
Text("第 \(pageNo)/\(totalPages) 页")
|
||||
Button("下一页") {
|
||||
pageNo += 1
|
||||
Task { await fetch() }
|
||||
updateDisplay()
|
||||
}
|
||||
.disabled(pageNo * pageSize >= total)
|
||||
.disabled(pageNo >= totalPages)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.task {
|
||||
await fetch()
|
||||
if allVideos.isEmpty {
|
||||
await refreshFromAPI()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showDownloadPanel) {
|
||||
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
|
||||
errorMessage = nil
|
||||
loadingStatus = "正在加载视频列表..."
|
||||
var collected: [VideoItem] = []
|
||||
var page = 1
|
||||
let size = 100
|
||||
var total = 0
|
||||
do {
|
||||
let response = try await VODClient.shared.searchMedia(
|
||||
keyword: keyword.isEmpty ? nil : keyword,
|
||||
pageNo: pageNo,
|
||||
pageSize: pageSize
|
||||
)
|
||||
videos = response.videos
|
||||
total = response.Total ?? 0
|
||||
pageNo = response.PageNo ?? pageNo
|
||||
pageSize = response.PageSize ?? pageSize
|
||||
while true {
|
||||
loadingStatus = "正在加载视频列表... (第 \(page) 页)"
|
||||
let response = try await VODClient.shared.searchMedia(
|
||||
pageNo: page,
|
||||
pageSize: size
|
||||
)
|
||||
let videos = response.videos
|
||||
collected.append(contentsOf: videos)
|
||||
total = response.Total ?? 0
|
||||
if collected.count >= total || videos.isEmpty {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
allVideos = collected
|
||||
applyFilter()
|
||||
loadingStatus = ""
|
||||
} catch {
|
||||
errorMessage = "加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
@@ -185,7 +264,8 @@ struct VideoListView: View {
|
||||
do {
|
||||
try await VODClient.shared.deleteVideos(videoIds: ids)
|
||||
selectedVideos.removeAll()
|
||||
await fetch()
|
||||
allVideos.removeAll { ids.contains($0.videoId) }
|
||||
applyFilter()
|
||||
} catch {
|
||||
errorMessage = "删除失败:\(error.localizedDescription)"
|
||||
}
|
||||
@@ -194,20 +274,19 @@ struct VideoListView: View {
|
||||
private func prepareDownload() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
loadingStatus = ""
|
||||
var results: [(video: VideoItem, playInfos: [PlayInfo])] = []
|
||||
var errors: [String] = []
|
||||
for video in videos where selectedVideos.contains(video.videoId) {
|
||||
do {
|
||||
let response = try await VODClient.shared.getPlayInfo(videoId: video.videoId)
|
||||
results.append((video, response.playInfos))
|
||||
if response.playInfos.isEmpty {
|
||||
errors.append("「\(video.title)」无可用播放地址")
|
||||
}
|
||||
} catch {
|
||||
results.append((video, []))
|
||||
errors.append("「\(video.title)」获取播放信息失败:\(error.localizedDescription)")
|
||||
let selected = displayVideos.filter { selectedVideos.contains($0.videoId) }
|
||||
for (index, video) in selected.enumerated() {
|
||||
loadingStatus = "正在获取下载地址 (\(index + 1)/\(selected.count))「\(video.title)」..."
|
||||
let playInfos = await fetchPlayInfoWithRetry(videoId: video.videoId, title: video.title, maxRetries: 3)
|
||||
results.append((video, playInfos))
|
||||
if playInfos.isEmpty {
|
||||
errors.append("「\(video.title)」无可用播放地址")
|
||||
}
|
||||
}
|
||||
loadingStatus = ""
|
||||
if !errors.isEmpty {
|
||||
errorMessage = errors.joined(separator: "\n")
|
||||
}
|
||||
@@ -216,6 +295,29 @@ struct VideoListView: View {
|
||||
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 {
|
||||
guard let seconds = seconds else { return "-" }
|
||||
let m = Int(seconds) / 60
|
||||
|
||||
Reference in New Issue
Block a user