迁移:SPM → Xcode 构建,String Catalog 替代 .strings,清理 web/server/docker 废弃代码
- 项目从 SPM (Sources/VODManager/) 迁移到 Xcode 构建系统 (VODManager.xcodeproj) - 源码目录从 Sources/VODManager/ 迁移至 VODManager/VODManager/ - 用 Localizable.xcstrings 替代旧的 .lproj/Localizable.strings 文件 - 删除已废弃的 apps/web、apps/server、docker 目录及相关文档 - RTL 侧边栏修复:切语言时用 .id() 强制重建 NavigationSplitView - 视频库无账号时不发起 API 请求和重试 - 无账号时显示 ContentUnavailableView 提示
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/
|
||||
@@ -1,18 +0,0 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VODManager",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.executable(name: "VODManager", targets: ["VODManager"]),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "VODManager",
|
||||
path: "Sources/VODManager"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# 火炬 VOD 管理器 - macOS 原生版
|
||||
|
||||
基于 SwiftUI + Swift Package Manager 的阿里云 VOD 桌面管理客户端。
|
||||
|
||||
## 特性
|
||||
|
||||
- 原生 macOS SwiftUI 界面
|
||||
- 直接调用阿里云 VOD OpenAPI,无需 Node 后端
|
||||
- 多账号管理、切换
|
||||
- 视频列表、搜索、详情
|
||||
- 批量下载到用户指定目录
|
||||
- 自定义下载文件名(自动清理非法字符)
|
||||
- 下载任务历史
|
||||
- 操作日志
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Swift 6 + SwiftUI
|
||||
- GRDB(SQLite)
|
||||
- CryptoKit(阿里云 POP 签名)
|
||||
- URLSession(网络请求与下载)
|
||||
|
||||
## 开发要求
|
||||
|
||||
- macOS 14+
|
||||
- Xcode 15+
|
||||
- Swift 5.9+
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
cd apps/macos/VODManager
|
||||
swift build
|
||||
swift run
|
||||
```
|
||||
|
||||
或在 Xcode 中打开 `Package.swift`。
|
||||
|
||||
## App Store 上架准备
|
||||
|
||||
1. 在 Xcode 中创建正式的 macOS App 工程(或打开 Swift Package)。
|
||||
2. 配置 Signing & Capabilities:
|
||||
- App Sandbox 开启
|
||||
- 启用 entitlements 文件 `VODManager.entitlements`
|
||||
- Team 与 Bundle Identifier
|
||||
3. 提交 Mac App Store 审核。
|
||||
|
||||
### Entitlements 说明
|
||||
|
||||
- `com.apple.security.app-sandbox`:沙盒必须开启
|
||||
- `com.apple.security.network.client`:访问阿里云 API
|
||||
- `com.apple.security.files.user-selected.read-write`:用户选择下载目录
|
||||
- `com.apple.security.files.downloads.read-write`:写入 Downloads 目录
|
||||
|
||||
## 与 Web 版的关系
|
||||
|
||||
Web 版(`apps/web` + `apps/server`)已废弃,不再维护。所有新功能在 macOS 原生版中实现。
|
||||
@@ -1,114 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
class DownloadManager: ObservableObject {
|
||||
static let shared = DownloadManager()
|
||||
|
||||
@Published var tasks: [DownloadTask] = []
|
||||
|
||||
private var session: URLSession!
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 86400
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
func loadTasks() async {
|
||||
tasks = await DatabaseManager.shared.allDownloadTasks()
|
||||
}
|
||||
|
||||
func download(
|
||||
video: VideoItem,
|
||||
playInfo: PlayInfo,
|
||||
to directory: URL,
|
||||
filename: String? = nil
|
||||
) async -> DownloadTask {
|
||||
let safeTitle = sanitizeFilename(video.title)
|
||||
let ext = (playInfo.format ?? "mp4").lowercased()
|
||||
let name = filename ?? "\(safeTitle)_\(playInfo.definition).\(ext)"
|
||||
let destination = directory.appendingPathComponent(name)
|
||||
|
||||
let task = DownloadTask(
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
definition: playInfo.definition,
|
||||
sourceURL: playInfo.playURL,
|
||||
localPath: destination.path,
|
||||
status: .pending,
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
createdAt: Date()
|
||||
)
|
||||
|
||||
await DatabaseManager.shared.insert(task)
|
||||
await loadTasks()
|
||||
|
||||
guard let url = URL(string: playInfo.playURL) else {
|
||||
task.status = .failed
|
||||
task.errorMessage = "Invalid URL"
|
||||
await DatabaseManager.shared.save(task)
|
||||
await loadTasks()
|
||||
return task
|
||||
}
|
||||
|
||||
task.status = .downloading
|
||||
await DatabaseManager.shared.save(task)
|
||||
|
||||
do {
|
||||
let (tempURL, _) = try await session.download(from: url)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempURL, to: destination)
|
||||
task.status = .completed
|
||||
task.progress = 1
|
||||
} catch {
|
||||
task.status = .failed
|
||||
task.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
await DatabaseManager.shared.save(task)
|
||||
await loadTasks()
|
||||
return task
|
||||
}
|
||||
|
||||
func batchDownload(
|
||||
items: [(video: VideoItem, playInfo: PlayInfo)],
|
||||
to directory: URL,
|
||||
maxConcurrent: Int = 2
|
||||
) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
var iterator = items.makeIterator()
|
||||
var running = 0
|
||||
|
||||
while let item = iterator.next() {
|
||||
group.addTask {
|
||||
_ = await self.download(video: item.video, playInfo: item.playInfo, to: directory)
|
||||
}
|
||||
running += 1
|
||||
|
||||
if running >= maxConcurrent {
|
||||
await group.next()
|
||||
running -= 1
|
||||
}
|
||||
}
|
||||
|
||||
while running > 0 {
|
||||
await group.next()
|
||||
running -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizeFilename(_ filename: String) -> String {
|
||||
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
|
||||
return filename
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "..", with: ".")
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var accountStore = AccountStore.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
.frame(minWidth: 180)
|
||||
} detail: {
|
||||
Text("请在左侧选择功能")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.environmentObject(accountStore)
|
||||
.task {
|
||||
await accountStore.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@State private var showAccountPicker = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
List {
|
||||
Section("媒体库") {
|
||||
NavigationLink(destination: VideoListView()) {
|
||||
Label("视频库", systemImage: "film")
|
||||
}
|
||||
}
|
||||
|
||||
Section("下载") {
|
||||
NavigationLink(destination: DownloadTaskListView()) {
|
||||
Label("下载任务", systemImage: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Section("管理") {
|
||||
NavigationLink(destination: AccountListView()) {
|
||||
Label("账号配置", systemImage: "key")
|
||||
}
|
||||
NavigationLink(destination: LogListView()) {
|
||||
Label("操作日志", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let account = accountStore.activeAccount {
|
||||
Divider()
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(account.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Text(account.region)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if accountStore.accounts.count > 1 {
|
||||
Menu {
|
||||
ForEach(accountStore.accounts) { acct in
|
||||
Button {
|
||||
Task { await accountStore.switchActive(acct) }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(acct.name)
|
||||
if acct.id == account.id {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("切换账号", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.navigationTitle("火炬VOD管理器")
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct DownloadTaskListView: View {
|
||||
@Query(sort: \DownloadTask.createdAt, order: .reverse) private var tasks: [DownloadTask]
|
||||
|
||||
var body: some View {
|
||||
List(tasks) { task in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(task.title)
|
||||
.lineLimit(1)
|
||||
Text("\(task.definition) · \(task.status.rawValue)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if task.status == .downloading {
|
||||
ProgressView(value: task.progress)
|
||||
.frame(width: 120)
|
||||
}
|
||||
|
||||
if let path = task.localPath {
|
||||
Button("打开") {
|
||||
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3DBB047B2FF3BBD70086E8DC /* VODManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VODManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
3DBB047D2FF3BBD70086E8DC /* VODManager */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VODManager;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
3DBB04782FF3BBD70086E8DC /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
3DBB04722FF3BBD70086E8DC = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3DBB047D2FF3BBD70086E8DC /* VODManager */,
|
||||
3DBB047C2FF3BBD70086E8DC /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3DBB047C2FF3BBD70086E8DC /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3DBB047B2FF3BBD70086E8DC /* VODManager.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
3DBB047A2FF3BBD70086E8DC /* VODManager */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3DBB04862FF3BBD80086E8DC /* Build configuration list for PBXNativeTarget "VODManager" */;
|
||||
buildPhases = (
|
||||
3DBB04772FF3BBD70086E8DC /* Sources */,
|
||||
3DBB04782FF3BBD70086E8DC /* Frameworks */,
|
||||
3DBB04792FF3BBD70086E8DC /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3DBB047D2FF3BBD70086E8DC /* VODManager */,
|
||||
);
|
||||
name = VODManager;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = VODManager;
|
||||
productReference = 3DBB047B2FF3BBD70086E8DC /* VODManager.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
3DBB04732FF3BBD70086E8DC /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2660;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
3DBB047A2FF3BBD70086E8DC = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 3DBB04762FF3BBD70086E8DC /* Build configuration list for PBXProject "VODManager" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hans",
|
||||
ug,
|
||||
);
|
||||
mainGroup = 3DBB04722FF3BBD70086E8DC;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 3DBB047C2FF3BBD70086E8DC /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
3DBB047A2FF3BBD70086E8DC /* VODManager */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3DBB04792FF3BBD70086E8DC /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
3DBB04772FF3BBD70086E8DC /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
3DBB04842FF3BBD80086E8DC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A4XHABX9V7;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3DBB04852FF3BBD80086E8DC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A4XHABX9V7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3DBB04872FF3BBD80086E8DC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = VODManager/VODManager.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = A4XHABX9V7;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.meshel.VODManager;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3DBB04882FF3BBD80086E8DC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = VODManager/VODManager.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = A4XHABX9V7;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.meshel.VODManager;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3DBB04762FF3BBD70086E8DC /* Build configuration list for PBXProject "VODManager" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3DBB04842FF3BBD80086E8DC /* Debug */,
|
||||
3DBB04852FF3BBD80086E8DC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
3DBB04862FF3BBD80086E8DC /* Build configuration list for PBXNativeTarget "VODManager" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3DBB04872FF3BBD80086E8DC /* Debug */,
|
||||
3DBB04882FF3BBD80086E8DC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 3DBB04732FF3BBD70086E8DC /* Project object */;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 905 KiB |
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -118,9 +118,9 @@ enum FileConflictStrategy: String, CaseIterable, Identifiable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .skip: return "跳过已存在文件"
|
||||
case .overwrite: return "覆盖已有文件"
|
||||
case .rename: return "自动重命名(加序号)"
|
||||
case .skip: return String(localized: "conflict.skip")
|
||||
case .overwrite: return String(localized: "conflict.overwrite")
|
||||
case .rename: return String(localized: "conflict.rename")
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
+25
-13
@@ -6,6 +6,7 @@ class DatabaseManager {
|
||||
static let shared = DatabaseManager()
|
||||
|
||||
let container: ModelContainer
|
||||
let context: ModelContext
|
||||
|
||||
private init() {
|
||||
let schema = Schema([Account.self, OperationLog.self, DownloadTask.self])
|
||||
@@ -18,28 +19,22 @@ class DatabaseManager {
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var context: ModelContext {
|
||||
ModelContext(container)
|
||||
context = ModelContext(container)
|
||||
}
|
||||
|
||||
func insert(_ model: any PersistentModel) async {
|
||||
let ctx = context
|
||||
ctx.insert(model)
|
||||
try? ctx.save()
|
||||
context.insert(model)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func delete(_ model: any PersistentModel) async {
|
||||
let ctx = context
|
||||
ctx.delete(model)
|
||||
try? ctx.save()
|
||||
context.delete(model)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func save(_ model: any PersistentModel) async {
|
||||
let ctx = context
|
||||
ctx.insert(model)
|
||||
try? ctx.save()
|
||||
context.insert(model)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func allAccounts() async -> [Account] {
|
||||
@@ -69,4 +64,21 @@ class DatabaseManager {
|
||||
let descriptor = FetchDescriptor<DownloadTask>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func deleteDownloadTask(_ task: DownloadTask) async {
|
||||
context.delete(task)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func clearCompletedDownloadTasks() async {
|
||||
let descriptor = FetchDescriptor<DownloadTask>(
|
||||
predicate: #Predicate { $0.statusRaw == "completed" || $0.statusRaw == "failed" }
|
||||
)
|
||||
if let tasks = try? context.fetch(descriptor) {
|
||||
for task in tasks {
|
||||
context.delete(task)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - 下载进度代理
|
||||
|
||||
private class DownloadHelper: NSObject, URLSessionDownloadDelegate {
|
||||
private var continuation: CheckedContinuation<URL, Error>?
|
||||
private let progressHandler: @Sendable (Double) -> Void
|
||||
private var didComplete = false
|
||||
|
||||
init(progressHandler: @escaping @Sendable (Double) -> Void) {
|
||||
self.progressHandler = progressHandler
|
||||
}
|
||||
|
||||
func download(from url: URL) async throws -> URL {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 86400
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
|
||||
self.continuation = continuation
|
||||
let task = session.downloadTask(with: url)
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
guard !didComplete else { return }
|
||||
didComplete = true
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("vod_dl_\(UUID().uuidString)")
|
||||
do {
|
||||
try FileManager.default.moveItem(at: location, to: tempURL)
|
||||
continuation?.resume(returning: tempURL)
|
||||
} catch {
|
||||
continuation?.resume(throwing: error)
|
||||
}
|
||||
continuation = nil
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
progressHandler(progress)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard !didComplete else { return }
|
||||
if let error {
|
||||
didComplete = true
|
||||
continuation?.resume(throwing: error)
|
||||
continuation = nil
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DownloadManager
|
||||
|
||||
@MainActor
|
||||
class DownloadManager: ObservableObject {
|
||||
static let shared = DownloadManager()
|
||||
|
||||
@Published var tasks: [DownloadTask] = []
|
||||
|
||||
private var session: URLSession!
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 86400
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
func loadTasks() async {
|
||||
tasks = await DatabaseManager.shared.allDownloadTasks()
|
||||
}
|
||||
|
||||
func batchDownload(
|
||||
items: [(video: VideoItem, playInfo: PlayInfo)],
|
||||
to directory: URL,
|
||||
conflictStrategy: FileConflictStrategy = .skip,
|
||||
maxConcurrent: Int = 2
|
||||
) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for item in items {
|
||||
group.addTask {
|
||||
_ = await DownloadManager.downloadSingle(
|
||||
video: item.video,
|
||||
playInfo: item.playInfo,
|
||||
to: directory,
|
||||
conflictStrategy: conflictStrategy
|
||||
)
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
func cancelAll() async {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
session.getTasksWithCompletionHandler { _, _, downloadTasks in
|
||||
downloadTasks.forEach { $0.cancel() }
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
// MARK: - 单个下载(非 MainActor,支持实时进度)
|
||||
|
||||
nonisolated private static func downloadSingle(
|
||||
video: VideoItem,
|
||||
playInfo: PlayInfo,
|
||||
to directory: URL,
|
||||
conflictStrategy: FileConflictStrategy = .skip
|
||||
) async -> DownloadTask {
|
||||
let safeTitle = sanitizeFilename(video.title)
|
||||
let ext = (playInfo.format ?? "mp4").lowercased()
|
||||
let baseName = "\(safeTitle)_\(playInfo.definition)"
|
||||
var name = "\(baseName).\(ext)"
|
||||
var destination = directory.appendingPathComponent(name)
|
||||
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
switch conflictStrategy {
|
||||
case .skip:
|
||||
let task = DownloadTask(
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
definition: playInfo.definition,
|
||||
sourceURL: playInfo.playURL,
|
||||
localPath: destination.path,
|
||||
status: .completed,
|
||||
progress: 1,
|
||||
errorMessage: nil,
|
||||
createdAt: Date()
|
||||
)
|
||||
await DatabaseManager.shared.insert(task)
|
||||
return task
|
||||
case .overwrite:
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
case .rename:
|
||||
var counter = 1
|
||||
while FileManager.default.fileExists(atPath: destination.path) {
|
||||
name = "\(baseName)_\(counter).\(ext)"
|
||||
destination = directory.appendingPathComponent(name)
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let task = DownloadTask(
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
definition: playInfo.definition,
|
||||
sourceURL: playInfo.playURL,
|
||||
localPath: destination.path,
|
||||
status: .pending,
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
createdAt: Date()
|
||||
)
|
||||
|
||||
await DatabaseManager.shared.insert(task)
|
||||
|
||||
guard let url = URL(string: playInfo.playURL) else {
|
||||
task.status = .failed
|
||||
task.errorMessage = "无效的下载链接"
|
||||
await DatabaseManager.shared.save(task)
|
||||
return task
|
||||
}
|
||||
|
||||
task.status = .downloading
|
||||
await DatabaseManager.shared.save(task)
|
||||
|
||||
let helper = DownloadHelper { [task] progress in
|
||||
let clamped = min(max(progress, 0), 1)
|
||||
Task { @MainActor in
|
||||
task.progress = clamped
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let tempURL = try await helper.download(from: url)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempURL, to: destination)
|
||||
task.status = .completed
|
||||
task.progress = 1
|
||||
} catch {
|
||||
task.status = .failed
|
||||
task.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
await DatabaseManager.shared.save(task)
|
||||
return task
|
||||
}
|
||||
|
||||
nonisolated private static func sanitizeFilename(_ filename: String) -> String {
|
||||
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
|
||||
return filename
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "..", with: ".")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppLanguage: String, CaseIterable, Identifiable {
|
||||
case zhHans = "zh-Hans"
|
||||
case en = "en"
|
||||
case ug = "ug"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .zhHans: return "简体中文"
|
||||
case .en: return "English"
|
||||
case .ug: return "ئۇيغۇرچە"
|
||||
}
|
||||
}
|
||||
|
||||
var layoutDirection: LayoutDirection {
|
||||
switch self {
|
||||
case .ug: return .rightToLeft
|
||||
default: return .leftToRight
|
||||
}
|
||||
}
|
||||
|
||||
var locale: Locale {
|
||||
Locale(identifier: rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppLanguage {
|
||||
static var current: AppLanguage {
|
||||
let raw = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
|
||||
return AppLanguage(rawValue: raw) ?? Self.systemDefault
|
||||
}
|
||||
|
||||
static var systemDefault: AppLanguage {
|
||||
let lang = Locale.current.language.languageCode?.identifier ?? "zh-Hans"
|
||||
return AppLanguage(rawValue: lang) ?? .zhHans
|
||||
}
|
||||
|
||||
static func setCurrent(_ language: AppLanguage) {
|
||||
UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+1
-1
@@ -11,7 +11,7 @@ struct VODManagerApp: App {
|
||||
.frame(minWidth: 1000, minHeight: 700)
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.modelContainer(DatabaseManager.shared.container)
|
||||
.modelContext(DatabaseManager.shared.context)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
}
|
||||
+19
-19
@@ -12,7 +12,7 @@ struct AccountListView: View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("添加账号") {
|
||||
Button("account.add") {
|
||||
showAddForm = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -30,7 +30,7 @@ struct AccountListView: View {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("复制错误信息")
|
||||
.help("error.copy_help")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -49,22 +49,22 @@ struct AccountListView: View {
|
||||
Spacer()
|
||||
|
||||
if account.isActive {
|
||||
Text("当前使用")
|
||||
Text("account.active")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Button("切换") {
|
||||
Button("btn.switch") {
|
||||
Task { await accountStore.switchActive(account) }
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
Button("编辑") {
|
||||
Button("btn.edit") {
|
||||
editingAccount = account
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Button("删除") {
|
||||
Button("btn.delete") {
|
||||
Task { await accountStore.deleteAccount(account) }
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
@@ -97,37 +97,37 @@ struct AccountFormView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(account == nil ? "添加账号" : "编辑账号")
|
||||
Text(account == nil ? "account.add_title" : "account.edit_title")
|
||||
.font(.title2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("名称")
|
||||
TextField("用于区分不同账号", text: $name)
|
||||
Text("account.name")
|
||||
TextField("account.name_placeholder", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("AccessKey ID")
|
||||
TextField("如 LTAI...", text: $accessKeyId)
|
||||
Text("account.access_key_id")
|
||||
TextField("account.access_key_id_placeholder", text: $accessKeyId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text(account == nil ? "AccessKey Secret" : "AccessKey Secret(留空则不修改)")
|
||||
SecureField("如 8fKx...", text: $accessKeySecret)
|
||||
Text(account == nil ? "account.access_key_secret" : "account.access_key_secret_edit")
|
||||
SecureField("account.access_key_secret_placeholder", text: $accessKeySecret)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("Region")
|
||||
TextField("如 cn-shanghai", text: $region)
|
||||
Text("account.region")
|
||||
TextField("account.region_placeholder", text: $region)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("Endpoint(可选)")
|
||||
TextField("默认 vod.{region}.aliyuncs.com", text: $endpoint)
|
||||
Text("account.endpoint")
|
||||
TextField("account.endpoint_placeholder", text: $endpoint)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("取消") {
|
||||
Button("btn.cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button("保存") {
|
||||
Button("btn.save") {
|
||||
Task { await save() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var accountStore = AccountStore.shared
|
||||
@AppStorage("appLanguage") private var appLanguage: String = AppLanguage.systemDefault.rawValue
|
||||
|
||||
private var currentLanguage: AppLanguage {
|
||||
AppLanguage(rawValue: appLanguage) ?? .zhHans
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
SidebarView()
|
||||
.environmentObject(accountStore)
|
||||
.environment(\.locale, currentLanguage.locale)
|
||||
.environment(\.layoutDirection, currentLanguage.layoutDirection)
|
||||
.id(appLanguage)
|
||||
.task {
|
||||
await accountStore.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@AppStorage("appLanguage") private var appLanguage: String = AppLanguage.systemDefault.rawValue
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
VStack(spacing: 0) {
|
||||
List {
|
||||
Section("sidebar.media") {
|
||||
NavigationLink(destination: VideoListView()) {
|
||||
Label("nav.video_library", systemImage: "film")
|
||||
}
|
||||
}
|
||||
|
||||
Section("sidebar.download") {
|
||||
NavigationLink(destination: DownloadTaskListView()) {
|
||||
Label("nav.download_tasks", systemImage: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Section("sidebar.manage") {
|
||||
NavigationLink(destination: AccountListView()) {
|
||||
Label("nav.account_config", systemImage: "key")
|
||||
}
|
||||
NavigationLink(destination: LogListView()) {
|
||||
Label("nav.operation_logs", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("🌐")
|
||||
Picker("", selection: $appLanguage) {
|
||||
ForEach(AppLanguage.allCases) { lang in
|
||||
Text(lang.displayName).tag(lang.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 120)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if let account = accountStore.activeAccount {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(account.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Text(account.region)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if accountStore.accounts.count > 1 {
|
||||
Menu {
|
||||
ForEach(accountStore.accounts) { acct in
|
||||
Button {
|
||||
Task { await accountStore.switchActive(acct) }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(acct.name)
|
||||
if acct.id == account.id {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("account.switch_title", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.navigationTitle("app.title")
|
||||
} detail: {
|
||||
Text("detail.hint")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-13
@@ -9,24 +9,27 @@ struct DownloadPanelView: View {
|
||||
@State private var downloadDirectory: URL?
|
||||
@State private var isDownloading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var conflictStrategy: FileConflictStrategy = .skip
|
||||
@State private var showConflictAlert = false
|
||||
@State private var conflictingFiles: [String] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("批量下载")
|
||||
Text("download.title")
|
||||
.font(.title2)
|
||||
|
||||
HStack {
|
||||
Text("下载目录:")
|
||||
Text("download.directory")
|
||||
if let dir = downloadDirectory {
|
||||
Text(dir.path)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("未选择")
|
||||
Text("download.not_selected")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("选择目录…") {
|
||||
Button("download.select_directory") {
|
||||
selectDirectory()
|
||||
}
|
||||
}
|
||||
@@ -38,10 +41,10 @@ struct DownloadPanelView: View {
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if item.playInfos.isEmpty {
|
||||
Text("无可用播放地址")
|
||||
Text("download.no_play_url")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Picker("清晰度", selection: Binding(
|
||||
Picker("download.definition", selection: Binding(
|
||||
get: { selectedDefinitions[item.video.videoId] ?? item.playInfos.first?.definition ?? "" },
|
||||
set: { selectedDefinitions[item.video.videoId] = $0 }
|
||||
)) {
|
||||
@@ -66,20 +69,29 @@ struct DownloadPanelView: View {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("复制错误信息")
|
||||
.help("error.copy_help")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Picker("download.conflict_strategy", selection: $conflictStrategy) {
|
||||
ForEach(FileConflictStrategy.allCases) { strategy in
|
||||
Text(strategy.label).tag(strategy)
|
||||
}
|
||||
}
|
||||
.frame(width: 220)
|
||||
.disabled(isDownloading)
|
||||
|
||||
Spacer()
|
||||
Button("取消") {
|
||||
|
||||
Button("btn.cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.disabled(isDownloading)
|
||||
|
||||
Button("开始下载") {
|
||||
Button("download.start") {
|
||||
Task { await startDownloads() }
|
||||
}
|
||||
.disabled(downloadDirectory == nil || isDownloading)
|
||||
@@ -97,6 +109,23 @@ struct DownloadPanelView: View {
|
||||
}
|
||||
selectedDefinitions = defs
|
||||
}
|
||||
.alert("download.conflict_title", isPresented: $showConflictAlert) {
|
||||
Button("download.conflict_skip") {
|
||||
conflictStrategy = .skip
|
||||
executeDownload()
|
||||
}
|
||||
Button("download.conflict_overwrite") {
|
||||
conflictStrategy = .overwrite
|
||||
executeDownload()
|
||||
}
|
||||
Button("download.conflict_rename") {
|
||||
conflictStrategy = .rename
|
||||
executeDownload()
|
||||
}
|
||||
Button("btn.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(verbatim: String(format: String(localized: "download.conflict_message"), conflictingFiles.joined(separator: "\n")))
|
||||
}
|
||||
}
|
||||
|
||||
private func selectDirectory() {
|
||||
@@ -104,7 +133,7 @@ struct DownloadPanelView: View {
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "选择"
|
||||
panel.prompt = String(localized: "picker.choose")
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
downloadDirectory = url
|
||||
}
|
||||
@@ -112,8 +141,37 @@ struct DownloadPanelView: View {
|
||||
|
||||
private func startDownloads() async {
|
||||
guard let directory = downloadDirectory else { return }
|
||||
|
||||
let tasks = buildTaskList()
|
||||
guard !tasks.isEmpty else { return }
|
||||
|
||||
let conflicts = findConflictingFiles(tasks: tasks, directory: directory)
|
||||
if !conflicts.isEmpty {
|
||||
conflictingFiles = conflicts
|
||||
showConflictAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
executeDownload()
|
||||
}
|
||||
|
||||
private func executeDownload() {
|
||||
guard let directory = downloadDirectory else { return }
|
||||
isDownloading = true
|
||||
|
||||
let tasks = buildTaskList()
|
||||
dismiss()
|
||||
|
||||
Task {
|
||||
await downloadManager.batchDownload(
|
||||
items: tasks,
|
||||
to: directory,
|
||||
conflictStrategy: conflictStrategy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildTaskList() -> [(video: VideoItem, playInfo: PlayInfo)] {
|
||||
var tasks: [(video: VideoItem, playInfo: PlayInfo)] = []
|
||||
for item in items {
|
||||
guard let definition = selectedDefinitions[item.video.videoId],
|
||||
@@ -122,10 +180,33 @@ struct DownloadPanelView: View {
|
||||
}
|
||||
tasks.append((item.video, playInfo))
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
await downloadManager.batchDownload(items: tasks, to: directory)
|
||||
isDownloading = false
|
||||
dismiss()
|
||||
private func findConflictingFiles(
|
||||
tasks: [(video: VideoItem, playInfo: PlayInfo)],
|
||||
directory: URL
|
||||
) -> [String] {
|
||||
var conflicts: [String] = []
|
||||
for (video, playInfo) in tasks {
|
||||
let safeTitle = sanitizeFilename(video.title)
|
||||
let ext = (playInfo.format ?? "mp4").lowercased()
|
||||
let name = "\(safeTitle)_\(playInfo.definition).\(ext)"
|
||||
let path = directory.appendingPathComponent(name)
|
||||
if FileManager.default.fileExists(atPath: path.path) {
|
||||
conflicts.append(name)
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
private func sanitizeFilename(_ filename: String) -> String {
|
||||
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
|
||||
return filename
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "..", with: ".")
|
||||
}
|
||||
|
||||
private func formatSize(_ bytes: Int64?) -> String {
|
||||
@@ -0,0 +1,119 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct DownloadTaskListView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@Query(sort: \DownloadTask.createdAt, order: .reverse) private var tasks: [DownloadTask]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if tasks.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"download_task.empty_title",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("download_task.empty_desc")
|
||||
)
|
||||
} else {
|
||||
HStack {
|
||||
Text(verbatim: String(format: String(localized: "download_task.total"), tasks.count))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("download_task.clear_completed") {
|
||||
Task { await DatabaseManager.shared.clearCompletedDownloadTasks() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
List {
|
||||
ForEach(tasks) { task in
|
||||
DownloadTaskRow(task: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("nav.download_tasks")
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadTaskRow: View {
|
||||
let task: DownloadTask
|
||||
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
statusIcon
|
||||
VStack(alignment: .leading) {
|
||||
Text(task.title)
|
||||
.font(.headline)
|
||||
}
|
||||
Spacer()
|
||||
statusText
|
||||
if task.status == .completed {
|
||||
Button("btn.open") {
|
||||
if let path = task.localPath {
|
||||
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Label("btn.delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.alert("download_task.delete_confirm_title", isPresented: $showDeleteConfirm) {
|
||||
Button("btn.cancel", role: .cancel) {}
|
||||
Button("btn.delete", role: .destructive) {
|
||||
Task { await DatabaseManager.shared.deleteDownloadTask(task) }
|
||||
}
|
||||
} message: {
|
||||
Text(verbatim: String(format: String(localized: "download_task.delete_confirm_msg"), task.title))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch task.status {
|
||||
case .pending:
|
||||
Image(systemName: "clock.fill")
|
||||
.foregroundColor(.gray)
|
||||
case .downloading:
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusText: some View {
|
||||
switch task.status {
|
||||
case .pending:
|
||||
Text("download_task.status.pending")
|
||||
case .downloading:
|
||||
ProgressView(value: task.progress)
|
||||
.frame(width: 80)
|
||||
case .completed:
|
||||
Text("download_task.status.completed")
|
||||
case .failed:
|
||||
VStack(alignment: .trailing) {
|
||||
Text("download_task.status.failed")
|
||||
Text(task.errorMessage ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ struct LogListView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("目标:\(log.targetIds)")
|
||||
Text(verbatim: String(format: String(localized: "log.target"), log.targetIds))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let details = log.details {
|
||||
+43
-37
@@ -18,27 +18,33 @@ struct VideoListView: View {
|
||||
private var pageSize: Int { pageSizes[pageSizeIndex] }
|
||||
@State private var pageNo = 1
|
||||
private var totalPages: Int { max(1, (filteredVideos.count + pageSize - 1) / pageSize) }
|
||||
private var currentPageSelectedCount: Int {
|
||||
let currentPageIds = Set(displayVideos.map(\.videoId))
|
||||
return selectedVideos.intersection(currentPageIds).count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if accountStore.activeAccount == nil {
|
||||
ContentUnavailableView(
|
||||
"error.no_active_account",
|
||||
systemImage: "person.slash",
|
||||
description: Text("account.add")
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
TextField("搜索本地缓存标题", text: $keyword)
|
||||
TextField("video.search_placeholder", text: $keyword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(minWidth: 200)
|
||||
.onChange(of: keyword) { _, _ in
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
Button("搜索") {
|
||||
Button("btn.search") {
|
||||
applyFilter()
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
|
||||
Button("重置") {
|
||||
Button("btn.reset") {
|
||||
keyword = ""
|
||||
applyFilter()
|
||||
}
|
||||
@@ -51,14 +57,14 @@ struct VideoListView: View {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.help("从服务器刷新")
|
||||
.help("video.refresh_help")
|
||||
|
||||
Button("批量下载") {
|
||||
Button("video.batch_download") {
|
||||
Task { await prepareDownload() }
|
||||
}
|
||||
.disabled(selectedVideos.isEmpty || isLoading)
|
||||
|
||||
Button("全选") {
|
||||
Button("video.select_all") {
|
||||
if selectedVideos.count == filteredVideos.count {
|
||||
selectedVideos.removeAll()
|
||||
} else {
|
||||
@@ -66,7 +72,7 @@ struct VideoListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Button("批量删除") {
|
||||
Button("video.batch_delete") {
|
||||
Task { await batchDelete() }
|
||||
}
|
||||
.disabled(selectedVideos.isEmpty)
|
||||
@@ -86,7 +92,7 @@ struct VideoListView: View {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("复制错误信息")
|
||||
.help("error.copy_help")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -118,7 +124,7 @@ struct VideoListView: View {
|
||||
}
|
||||
.width(min: 30, ideal: 30, max: 30)
|
||||
|
||||
TableColumn("标题") { video in
|
||||
TableColumn("video.table.title") { video in
|
||||
HStack {
|
||||
AsyncImage(url: video.coverURL.flatMap(URL.init)) { image in
|
||||
image.resizable().scaledToFill()
|
||||
@@ -134,29 +140,29 @@ struct VideoListView: View {
|
||||
}
|
||||
.width(min: 200, ideal: 300)
|
||||
|
||||
TableColumn("视频 ID") { video in
|
||||
TableColumn("video.table.video_id") { video in
|
||||
Text(video.videoId)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.width(min: 120, ideal: 180)
|
||||
|
||||
TableColumn("时长") { video in
|
||||
TableColumn("video.table.duration") { video in
|
||||
Text(formatDuration(video.duration))
|
||||
}
|
||||
.width(min: 60, ideal: 80)
|
||||
|
||||
TableColumn("大小") { video in
|
||||
TableColumn("video.table.size") { video in
|
||||
Text(formatSize(video.size))
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("状态") { video in
|
||||
TableColumn("video.table.status") { video in
|
||||
Text(video.status ?? "-")
|
||||
}
|
||||
.width(min: 60, ideal: 80)
|
||||
|
||||
TableColumn("创建时间") { video in
|
||||
TableColumn("video.table.created_at") { video in
|
||||
Text(video.creationTime ?? "-")
|
||||
}
|
||||
.width(min: 120, ideal: 160)
|
||||
@@ -168,11 +174,11 @@ struct VideoListView: View {
|
||||
.frame(minHeight: 300)
|
||||
|
||||
HStack {
|
||||
Text("共 \(filteredVideos.count) 条,已选 \(selectedVideos.count) 个")
|
||||
Text(verbatim: String(format: String(localized: "video.total_selected"), filteredVideos.count, selectedVideos.count))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !selectedVideos.isEmpty {
|
||||
Button("取消选择") {
|
||||
Button("video.deselect_all") {
|
||||
selectedVideos.removeAll()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
@@ -181,7 +187,7 @@ struct VideoListView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("每页", selection: $pageSizeIndex) {
|
||||
Picker("video.page_size", selection: $pageSizeIndex) {
|
||||
ForEach(0..<pageSizes.count, id: \.self) { i in
|
||||
Text("\(pageSizes[i])").tag(i)
|
||||
}
|
||||
@@ -192,13 +198,13 @@ struct VideoListView: View {
|
||||
updateDisplay()
|
||||
}
|
||||
|
||||
Button("上一页") {
|
||||
Button("video.prev_page") {
|
||||
pageNo -= 1
|
||||
updateDisplay()
|
||||
}
|
||||
.disabled(pageNo <= 1)
|
||||
Text("第 \(pageNo)/\(totalPages) 页")
|
||||
Button("下一页") {
|
||||
Text(verbatim: String(format: String(localized: "video.page_info"), pageNo, totalPages))
|
||||
Button("video.next_page") {
|
||||
pageNo += 1
|
||||
updateDisplay()
|
||||
}
|
||||
@@ -207,6 +213,7 @@ struct VideoListView: View {
|
||||
.padding()
|
||||
}
|
||||
.task {
|
||||
guard accountStore.activeAccount != nil else { return }
|
||||
if allVideos.isEmpty {
|
||||
await refreshFromAPI()
|
||||
}
|
||||
@@ -217,6 +224,7 @@ struct VideoListView: View {
|
||||
displayVideos = []
|
||||
selectedVideos = []
|
||||
errorMessage = nil
|
||||
guard newId != nil else { return }
|
||||
Task { await refreshFromAPI() }
|
||||
}
|
||||
.sheet(isPresented: $showDownloadPanel) {
|
||||
@@ -254,14 +262,16 @@ struct VideoListView: View {
|
||||
errorMessage = nil
|
||||
let maxRetries = 3
|
||||
for attempt in 1...maxRetries {
|
||||
loadingStatus = attempt == 1 ? "正在加载视频列表..." : "重试中... (第 \(attempt)/\(maxRetries) 次)"
|
||||
loadingStatus = attempt == 1
|
||||
? String(localized: "loading.video_list")
|
||||
: String(format: String(localized: "loading.retry"), attempt, maxRetries)
|
||||
var collected: [VideoItem] = []
|
||||
var page = 1
|
||||
let size = 100
|
||||
var total = 0
|
||||
do {
|
||||
while true {
|
||||
loadingStatus = "正在加载视频列表... (第 \(page) 页)"
|
||||
loadingStatus = String(format: String(localized: "loading.video_list_page"), page)
|
||||
let response = try await VODClient.shared.searchMedia(
|
||||
pageNo: page,
|
||||
pageSize: size
|
||||
@@ -285,7 +295,7 @@ struct VideoListView: View {
|
||||
let delay = UInt64(attempt) * 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
} else {
|
||||
errorMessage = "加载失败(已重试 \(maxRetries) 次):\(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "loading.failed_retry"), maxRetries, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,7 +310,7 @@ struct VideoListView: View {
|
||||
allVideos.removeAll { ids.contains($0.videoId) }
|
||||
applyFilter()
|
||||
} catch {
|
||||
errorMessage = "删除失败:\(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "loading.delete_failed"), error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,11 +322,11 @@ struct VideoListView: View {
|
||||
var errors: [String] = []
|
||||
let selected = filteredVideos.filter { selectedVideos.contains($0.videoId) }
|
||||
for (index, video) in selected.enumerated() {
|
||||
loadingStatus = "正在获取下载地址 (\(index + 1)/\(selected.count))「\(video.title)」..."
|
||||
loadingStatus = String(format: String(localized: "loading.fetch_play_url"), 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)」无可用播放地址")
|
||||
errors.append(String(format: String(localized: "error.no_play_url"), video.title))
|
||||
}
|
||||
}
|
||||
loadingStatus = ""
|
||||
@@ -336,15 +346,11 @@ struct VideoListView: View {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user