This commit is contained in:
@@ -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 {
|
||||||
|
while true {
|
||||||
|
loadingStatus = "正在加载视频列表... (第 \(page) 页)"
|
||||||
let response = try await VODClient.shared.searchMedia(
|
let response = try await VODClient.shared.searchMedia(
|
||||||
keyword: keyword.isEmpty ? nil : keyword,
|
pageNo: page,
|
||||||
pageNo: pageNo,
|
pageSize: size
|
||||||
pageSize: pageSize
|
|
||||||
)
|
)
|
||||||
videos = response.videos
|
let videos = response.videos
|
||||||
|
collected.append(contentsOf: videos)
|
||||||
total = response.Total ?? 0
|
total = response.Total ?? 0
|
||||||
pageNo = response.PageNo ?? pageNo
|
if collected.count >= total || videos.isEmpty {
|
||||||
pageSize = response.PageSize ?? pageSize
|
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))
|
||||||
|
if playInfos.isEmpty {
|
||||||
errors.append("「\(video.title)」无可用播放地址")
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user