feat: 新增 Emby 风格云端影视库
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B472C40000100A10001 /* AppTheme.swift */; };
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */; };
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */; };
|
||||
8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */; };
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -35,6 +36,7 @@
|
||||
8C0E0B472C40000100A10001 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||
8C0E0B482C40000100A10001 /* WorkspaceShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShell.swift; sourceTree = "<group>"; };
|
||||
8C0E0B612C40000100A10001 /* BatchRenameTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRenameTool.swift; sourceTree = "<group>"; };
|
||||
8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryPage.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -72,6 +74,7 @@
|
||||
8C0E0B472C40000100A10001 /* AppTheme.swift */,
|
||||
8C0E0B482C40000100A10001 /* WorkspaceShell.swift */,
|
||||
8C0E0B612C40000100A10001 /* BatchRenameTool.swift */,
|
||||
8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */,
|
||||
8C0E0B422C40000100A10001 /* Models.swift */,
|
||||
8C0E0B432C40000100A10001 /* GuangyaAPI.swift */,
|
||||
8C0E0B442C40000100A10001 /* AppModel.swift */,
|
||||
@@ -171,6 +174,7 @@
|
||||
8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */,
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */,
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */,
|
||||
8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ final class AppModel: ObservableObject {
|
||||
@Published var tmdbTarget: CloudFile?
|
||||
@Published var batchRenameTarget: CloudFile?
|
||||
@Published var batchRenameSelection: [CloudFile] = []
|
||||
@Published var mediaLibraryTarget: CloudFile?
|
||||
@Published var isRunningTMDBJob = false
|
||||
@Published var isAnalyzing = false
|
||||
@Published var activeScanRequest: ScanRequest?
|
||||
@@ -376,6 +377,66 @@ final class AppModel: ObservableObject {
|
||||
return BatchRenameExecutionResult(succeeded: succeeded, failed: failed)
|
||||
}
|
||||
|
||||
func loadMediaLibrary(parentID: String?) async throws -> [MediaLibraryItem] {
|
||||
let source = try await allFiles(parentID: parentID)
|
||||
.filter { $0.isDirectory || $0.fileType == 2 }
|
||||
.prefix(60)
|
||||
let files = Array(source)
|
||||
guard !tmdbAPIKey.isEmpty else {
|
||||
return files.map { file in
|
||||
let parsed = parseMediaName(file.name)
|
||||
return MediaLibraryItem(file: file, title: parsed.title, originalTitle: file.name, mediaKind: nil, releaseDate: parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil)
|
||||
}
|
||||
}
|
||||
|
||||
let requests = files.enumerated().map { index, file in
|
||||
(index: index, file: file, parsed: parseMediaName(file.name))
|
||||
}
|
||||
let api = self.api
|
||||
let apiKey = tmdbAPIKey
|
||||
let proxyHost = tmdbProxyHost
|
||||
let proxyPort = tmdbProxyPort
|
||||
var iterator = requests.makeIterator()
|
||||
let results = await withTaskGroup(of: (Int, MediaLibraryItem).self, returning: [(Int, MediaLibraryItem)].self) { group in
|
||||
func addNext() {
|
||||
guard let request = iterator.next() else { return }
|
||||
group.addTask {
|
||||
let fallback = MediaLibraryItem(file: request.file, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil)
|
||||
do {
|
||||
let preferredKind: TMDBMediaKind = request.parsed.isEpisode ? .tv : .automatic
|
||||
let response = try await api.tmdbSearch(query: request.parsed.title, apiKey: apiKey, mediaKind: preferredKind, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
guard let candidate = (response["results"]?.arrayValue ?? []).compactMap({ TMDBCandidate(json: $0, forcedKind: preferredKind == .automatic ? nil : preferredKind) }).first else {
|
||||
return (request.index, fallback)
|
||||
}
|
||||
let posterData: Data?
|
||||
if let path = candidate.posterPath {
|
||||
posterData = try? await api.tmdbImage(path: path, size: "w500", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
} else {
|
||||
posterData = nil
|
||||
}
|
||||
let backdropData: Data?
|
||||
if request.index < 8, let path = candidate.backdropPath {
|
||||
backdropData = try? await api.tmdbImage(path: path, size: "w1280", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
} else {
|
||||
backdropData = nil
|
||||
}
|
||||
return (request.index, MediaLibraryItem(file: request.file, title: candidate.title, originalTitle: request.file.name, mediaKind: candidate.mediaType, releaseDate: candidate.releaseDate, overview: candidate.overview, posterData: posterData, backdropData: backdropData))
|
||||
} catch {
|
||||
return (request.index, fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _ in 0..<min(4, requests.count) { addNext() }
|
||||
var values: [(Int, MediaLibraryItem)] = []
|
||||
while let value = await group.next() {
|
||||
values.append(value)
|
||||
addNext()
|
||||
}
|
||||
return values
|
||||
}
|
||||
return results.sorted { $0.0 < $1.0 }.map(\.1)
|
||||
}
|
||||
|
||||
func copy(_ file: CloudFile, to parentID: String?) async {
|
||||
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ struct WorkspaceSidebar: View {
|
||||
model.batchRenameTarget = nil
|
||||
model.batchRenameSelection = []
|
||||
}
|
||||
if tool == .media { model.mediaLibraryTarget = nil }
|
||||
selectedTool = tool
|
||||
}
|
||||
}
|
||||
@@ -764,6 +765,10 @@ struct FilesBrowser: View {
|
||||
Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") }
|
||||
if file.isDirectory {
|
||||
Menu("文件夹工具") {
|
||||
Button {
|
||||
model.mediaLibraryTarget = file
|
||||
onOpenTool(.media)
|
||||
} label: { Label("打开为影视库", systemImage: "play.tv.fill") }
|
||||
Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") }
|
||||
Button { openScanTool(.emptyFolders, for: file) } label: { Label("扫描空文件夹", systemImage: "folder.badge.minus") }
|
||||
Button { openScanTool(.duplicates, for: file) } label: { Label("扫描重复文件", systemImage: "square.on.square") }
|
||||
@@ -1133,7 +1138,10 @@ struct ToolWorkspacePage: View {
|
||||
let tool: WorkspaceTool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if tool == .media {
|
||||
MediaLibraryPage(model: model)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: tool.icon).font(.title2.weight(.semibold)).foregroundStyle(.orange).frame(width: 52, height: 52).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 4) { Text(tool.rawValue).font(.system(size: 28, weight: .bold, design: .rounded)); Text(tool.subtitle).foregroundStyle(.secondary) }
|
||||
@@ -1142,15 +1150,17 @@ struct ToolWorkspacePage: View {
|
||||
.padding(20).softCard(radius: 24)
|
||||
|
||||
switch tool {
|
||||
case .media: EmptyView()
|
||||
case .scan: UnifiedScanToolPage(model: model)
|
||||
case .rename: BatchRenameToolPage(model: model)
|
||||
case .tmdb: TMDBToolPage(model: model)
|
||||
case .settings: ToolSettingsPage(model: model)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.clear)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.clear)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,8 +314,8 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
}
|
||||
|
||||
func tmdbImage(path: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> Data {
|
||||
guard let url = URL(string: "https://image.tmdb.org/t/p/original\(path)") else { throw GuangyaAPIError.invalidResponse }
|
||||
func tmdbImage(path: String, size: String = "original", apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> Data {
|
||||
guard let url = URL(string: "https://image.tmdb.org/t/p/\(size)\(path)") else { throw GuangyaAPIError.invalidResponse }
|
||||
return try await tmdbDataRequest(url, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
}
|
||||
|
||||
@@ -516,4 +516,3 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) }
|
||||
private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
|
||||
case all = "全部"
|
||||
case movies = "电影"
|
||||
case series = "剧集"
|
||||
case unmatched = "未识别"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct MediaLibraryPage: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var sourceID: String?
|
||||
@State private var sourceName = "云盘根目录"
|
||||
@State private var items: [MediaLibraryItem] = []
|
||||
@State private var selectedID: String?
|
||||
@State private var filter: MediaLibraryFilter = .all
|
||||
@State private var searchText = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorText = ""
|
||||
@State private var showFolderPicker = false
|
||||
|
||||
private var filteredItems: [MediaLibraryItem] {
|
||||
items.filter { item in
|
||||
let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText)
|
||||
let matchesFilter: Bool
|
||||
switch filter {
|
||||
case .all: matchesFilter = true
|
||||
case .movies: matchesFilter = item.mediaKind == .movie
|
||||
case .series: matchesFilter = item.mediaKind == .tv
|
||||
case .unmatched: matchesFilter = !item.isMatched
|
||||
}
|
||||
return matchesSearch && matchesFilter
|
||||
}
|
||||
}
|
||||
|
||||
private var featuredItem: MediaLibraryItem? {
|
||||
selectedID.flatMap { id in items.first { $0.id == id } }
|
||||
?? items.first(where: { $0.backdropData != nil })
|
||||
?? items.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.055, green: 0.06, blue: 0.06).ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if let featuredItem { featuredHero(featuredItem) }
|
||||
libraryControls
|
||||
libraryContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.sheet(isPresented: $showFolderPicker) {
|
||||
ScanFolderPickerSheet(model: model, title: "选择影视库目录", selectionLabel: "设为影视库") { id, name in
|
||||
model.mediaLibraryTarget = nil
|
||||
sourceID = id
|
||||
sourceName = name
|
||||
Task { await loadLibrary() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
syncTarget()
|
||||
await loadLibrary()
|
||||
}
|
||||
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in
|
||||
syncTarget()
|
||||
Task { await loadLibrary() }
|
||||
}
|
||||
}
|
||||
|
||||
private func featuredHero(_ item: MediaLibraryItem) -> some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
MediaArtwork(data: item.backdropData ?? item.posterData, icon: item.file.isDirectory ? "folder.fill" : "film.fill", contentMode: .fill)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 330)
|
||||
.clipped()
|
||||
LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.92)], startPoint: .top, endPoint: .bottom)
|
||||
LinearGradient(colors: [Color.black.opacity(0.76), .clear], startPoint: .leading, endPoint: .trailing)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
if let kind = item.mediaKind {
|
||||
Text(kind == .movie ? "电影" : "剧集")
|
||||
} else {
|
||||
Text(item.file.isDirectory ? "媒体文件夹" : "云盘视频")
|
||||
}
|
||||
if !item.year.isEmpty { Text(item.year) }
|
||||
}
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.72))
|
||||
Text(item.title).font(.system(size: 34, weight: .bold)).foregroundStyle(.white).lineLimit(2)
|
||||
if !item.overview.isEmpty {
|
||||
Text(item.overview).font(.callout).foregroundStyle(.white.opacity(0.78)).lineLimit(3).frame(maxWidth: 620, alignment: .leading)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Button { open(item) } label: {
|
||||
Label(item.file.isDirectory ? "浏览内容" : "播放", systemImage: item.file.isDirectory ? "rectangle.grid.2x2" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black)
|
||||
if !item.file.isDirectory {
|
||||
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 34).padding(.bottom, 30)
|
||||
}
|
||||
.frame(height: 330)
|
||||
}
|
||||
|
||||
private var libraryControls: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("影视库").font(.title2.weight(.bold))
|
||||
Text(sourceName).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
TextField("搜索影片、剧集", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(.horizontal, 12).frame(width: 230, height: 34)
|
||||
.background(Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 6))
|
||||
Button { showFolderPicker = true } label: { Image(systemName: "folder") }.buttonStyle(.bordered).help("选择影视库目录")
|
||||
Button { Task { await loadLibrary() } } label: {
|
||||
if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") }
|
||||
}
|
||||
.buttonStyle(.bordered).help("刷新影视库").disabled(isLoading)
|
||||
}
|
||||
HStack {
|
||||
Picker("分类", selection: $filter) {
|
||||
ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
|
||||
Spacer()
|
||||
Text("\(filteredItems.count) 项").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.vertical, 20)
|
||||
}
|
||||
|
||||
@ViewBuilder private var libraryContent: some View {
|
||||
if isLoading && items.isEmpty {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text("正在构建影视库…").font(.callout.weight(.medium))
|
||||
Text("正在匹配媒体信息与海报").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 90)
|
||||
} else if !errorText.isEmpty && items.isEmpty {
|
||||
ContentUnavailableView("影视库加载失败", systemImage: "exclamationmark.triangle", description: Text(errorText))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
} else if filteredItems.isEmpty {
|
||||
ContentUnavailableView("没有匹配的媒体", systemImage: "film.stack", description: Text("调整搜索或分类筛选。"))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 145, maximum: 190), spacing: 18)], alignment: .leading, spacing: 24) {
|
||||
ForEach(filteredItems) { item in
|
||||
MediaPosterCard(item: item, isSelected: selectedID == item.id) {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { selectedID = item.id }
|
||||
} onOpen: {
|
||||
open(item)
|
||||
}
|
||||
.contextMenu {
|
||||
Button { open(item) } label: { Label(item.file.isDirectory ? "浏览内容" : "播放", systemImage: item.file.isDirectory ? "folder" : "play.fill") }
|
||||
if !item.file.isDirectory { Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") } }
|
||||
Button { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.bottom, 36)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncTarget() {
|
||||
if let target = model.mediaLibraryTarget, target.isDirectory {
|
||||
sourceID = target.id
|
||||
sourceName = target.name
|
||||
} else {
|
||||
sourceID = model.currentScanRootID
|
||||
sourceName = model.scanScopeLocationName
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func loadLibrary() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
errorText = ""
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let loaded = try await model.loadMediaLibrary(parentID: sourceID)
|
||||
items = loaded
|
||||
if !loaded.contains(where: { $0.id == selectedID }) { selectedID = loaded.first?.id }
|
||||
} catch is CancellationError { }
|
||||
catch { errorText = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func open(_ item: MediaLibraryItem) {
|
||||
if item.file.isDirectory {
|
||||
sourceID = item.file.id
|
||||
sourceName = item.title
|
||||
model.mediaLibraryTarget = item.file
|
||||
Task { await loadLibrary() }
|
||||
} else {
|
||||
Task { await model.playWithExternalPlayer(item.file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaPosterCard: View {
|
||||
let item: MediaLibraryItem
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onOpen: () -> Void
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
MediaArtwork(data: item.posterData, icon: item.file.isDirectory ? "folder.fill" : "film.fill", contentMode: .fill)
|
||||
.aspectRatio(2 / 3, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
if item.file.isDirectory {
|
||||
Image(systemName: "rectangle.grid.2x2.fill").font(.caption.weight(.bold)).padding(7).background(.black.opacity(0.68), in: Circle()).padding(8)
|
||||
} else if isHovered {
|
||||
Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10)
|
||||
}
|
||||
}
|
||||
.overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? Color.orange : Color.white.opacity(isHovered ? 0.38 : 0.10), lineWidth: isSelected ? 2 : 1) }
|
||||
Text(item.title).font(.callout.weight(.semibold)).lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(item.year.isEmpty ? (item.file.isDirectory ? "文件夹" : "视频") : item.year)
|
||||
if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") }
|
||||
}
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { isHovered = $0 }
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded(onOpen))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaArtwork: View {
|
||||
let data: Data?
|
||||
let icon: String
|
||||
let contentMode: ContentMode
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let data, let image = NSImage(data: data) {
|
||||
Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode)
|
||||
} else {
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(red: 0.19, green: 0.22, blue: 0.20), Color(red: 0.08, green: 0.09, blue: 0.09)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.white.opacity(0.28))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,7 @@ struct TMDBCandidate: Identifiable, Sendable {
|
||||
let releaseDate: String
|
||||
let overview: String
|
||||
let posterPath: String?
|
||||
let backdropPath: String?
|
||||
|
||||
init?(json: JSONValue, forcedKind: TMDBMediaKind? = nil) {
|
||||
guard let id = json.firstInt(["id"]), let title = json.firstString(["title", "name"]) else { return nil }
|
||||
@@ -470,12 +471,27 @@ struct TMDBCandidate: Identifiable, Sendable {
|
||||
self.id = id; self.title = title; originalTitle = json.firstString(["original_title", "original_name"]) ?? title
|
||||
mediaType = rawType == "tv" ? .tv : .movie
|
||||
releaseDate = json.firstString(["release_date", "first_air_date"]) ?? ""
|
||||
overview = json.firstString(["overview"]) ?? ""; posterPath = json.firstString(["poster_path"])
|
||||
overview = json.firstString(["overview"]) ?? ""; posterPath = json.firstString(["poster_path"]); backdropPath = json.firstString(["backdrop_path"])
|
||||
}
|
||||
|
||||
var match: TMDBMatch { TMDBMatch(id: id, title: title, mediaType: mediaType.rawValue, releaseDate: releaseDate, overview: overview) }
|
||||
}
|
||||
|
||||
struct MediaLibraryItem: Identifiable, Sendable {
|
||||
let file: CloudFile
|
||||
let title: String
|
||||
let originalTitle: String
|
||||
let mediaKind: TMDBMediaKind?
|
||||
let releaseDate: String
|
||||
let overview: String
|
||||
let posterData: Data?
|
||||
let backdropData: Data?
|
||||
|
||||
var id: String { file.id }
|
||||
var year: String { String(releaseDate.prefix(4)) }
|
||||
var isMatched: Bool { mediaKind != nil }
|
||||
}
|
||||
|
||||
struct ParsedMediaName: Sendable {
|
||||
let title: String
|
||||
let year: Int?
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
case media = "影视库"
|
||||
case scan = "文件扫描与清理"
|
||||
case rename = "批量重命名"
|
||||
case tmdb = "TMDB 整理"
|
||||
@@ -10,6 +11,7 @@ enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .media: return "play.tv.fill"
|
||||
case .scan: return "magnifyingglass.circle.fill"
|
||||
case .rename: return "character.cursor.ibeam"
|
||||
case .tmdb: return "film.stack"
|
||||
@@ -18,6 +20,7 @@ enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
}
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .media: return "海报墙、媒体详情与播放入口"
|
||||
case .scan: return "空文件夹、重复文件与相似文件夹统一扫描"
|
||||
case .rename: return "按字符、正则和组合规则批量修改名称"
|
||||
case .tmdb: return "配置并执行媒体识别整理"
|
||||
|
||||
Reference in New Issue
Block a user