feat: 增加自动合集与多目录管理界面
This commit is contained in:
@@ -5,6 +5,7 @@ private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
|
||||
case all = "全部"
|
||||
case movies = "电影"
|
||||
case series = "剧集"
|
||||
case collections = "合集"
|
||||
case unmatched = "未识别"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@@ -20,11 +21,19 @@ struct MediaLibraryPage: View {
|
||||
@State private var errorText = ""
|
||||
@State private var showCreateLibrary = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var editingLibrary: MediaLibraryDefinition?
|
||||
@State private var activeCollectionID: String?
|
||||
|
||||
private var selectedLibrary: MediaLibraryDefinition? {
|
||||
model.mediaLibraries.first { $0.id == selectedLibraryID }
|
||||
}
|
||||
|
||||
private var activeProgress: MediaLibraryScanProgress? {
|
||||
if model.isScanningMediaLibrary { return model.mediaLibraryScanProgress }
|
||||
if model.isWritingMediaMetadata { return model.mediaMetadataWriteProgress }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var visibleItems: [MediaLibraryItem] {
|
||||
let filtered = items.filter { item in
|
||||
let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText)
|
||||
@@ -33,6 +42,7 @@ struct MediaLibraryPage: View {
|
||||
case .all: matchesFilter = true
|
||||
case .movies: matchesFilter = item.mediaKind == .movie
|
||||
case .series: matchesFilter = item.mediaKind == .tv
|
||||
case .collections: matchesFilter = activeCollection?.items.contains(where: { $0.id == item.id }) ?? false
|
||||
case .unmatched: matchesFilter = !item.isMatched
|
||||
}
|
||||
return matchesSearch && matchesFilter
|
||||
@@ -47,6 +57,29 @@ struct MediaLibraryPage: View {
|
||||
return order.compactMap { preferred[$0] }
|
||||
}
|
||||
|
||||
private var collectionGroups: [MediaCollectionGroup] {
|
||||
var grouped: [String: [MediaLibraryItem]] = [:]
|
||||
var names: [String: String] = [:]
|
||||
for item in items {
|
||||
guard let name = item.collectionName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { continue }
|
||||
let key = item.collectionID.map { "tmdb-\($0)" } ?? "nfo-\(name.lowercased())"
|
||||
grouped[key, default: []].append(item)
|
||||
names[key] = name
|
||||
}
|
||||
return grouped.compactMap { key, resources in
|
||||
var preferred: [String: MediaLibraryItem] = [:]
|
||||
for item in resources {
|
||||
let itemKey = item.tmdbID.map(String.init) ?? item.id
|
||||
if preferred[itemKey].map({ languageScore(item) > languageScore($0) }) ?? true { preferred[itemKey] = item }
|
||||
}
|
||||
let members = preferred.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
guard members.count >= 2, let name = names[key] else { return nil }
|
||||
return MediaCollectionGroup(id: key, name: name, items: members)
|
||||
}.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private var activeCollection: MediaCollectionGroup? { collectionGroups.first { $0.id == activeCollectionID } }
|
||||
|
||||
private var featuredItem: MediaLibraryItem? {
|
||||
selectedItemID.flatMap { id in items.first { $0.id == id } }
|
||||
?? visibleItems.first(where: { $0.backdropData != nil })
|
||||
@@ -61,7 +94,7 @@ struct MediaLibraryPage: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if let featuredItem { featuredHero(featuredItem) }
|
||||
if let featuredItem, filter != .collections || activeCollection != nil { featuredHero(featuredItem) }
|
||||
libraryControls
|
||||
libraryContent
|
||||
}
|
||||
@@ -70,14 +103,23 @@ struct MediaLibraryPage: View {
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.sheet(isPresented: $showCreateLibrary) {
|
||||
CreateMediaLibrarySheet(model: model) { name, rootID, rootPath, kind, recursive in
|
||||
CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive in
|
||||
Task {
|
||||
let library = await model.createMediaLibrary(name: name, rootID: rootID, rootPath: rootPath, kind: kind, recursive: recursive)
|
||||
let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive)
|
||||
selectedLibraryID = library.id
|
||||
await scan(library)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingLibrary) { library in
|
||||
CreateMediaLibrarySheet(model: model, initialLibrary: library) { name, sources, kind, recursive in
|
||||
Task {
|
||||
let updated = MediaLibraryDefinition(id: library.id, name: name, sources: sources, kind: kind, recursive: recursive, updatedAt: library.updatedAt)
|
||||
await model.updateMediaLibrary(updated)
|
||||
await scan(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("删除影视库?", isPresented: $showDeleteConfirmation) {
|
||||
Button("删除影视库", role: .destructive) {
|
||||
guard let library = selectedLibrary else { return }
|
||||
@@ -92,15 +134,21 @@ struct MediaLibraryPage: View {
|
||||
Text("只删除本机影视库配置和刮削缓存,不会删除云盘文件。")
|
||||
}
|
||||
.task { await initializeLibrary() }
|
||||
.onChange(of: selectedLibraryID) { _, _ in Task { await loadCachedItems() } }
|
||||
.onChange(of: selectedLibraryID) { _, _ in activeCollectionID = nil; Task { await loadCachedItems() } }
|
||||
.onChange(of: filter) { _, value in if value != .collections { activeCollectionID = nil } }
|
||||
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } }
|
||||
.onReceive(model.$mediaLibraryLiveItems) { liveItems in
|
||||
guard model.mediaLibraryLiveLibraryID == selectedLibraryID else { return }
|
||||
items = liveItems
|
||||
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyLibraryState: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "play.tv.fill").font(.system(size: 52, weight: .light)).foregroundStyle(.orange)
|
||||
Text("创建第一个影视库").font(.title2.weight(.bold))
|
||||
Text("选择云盘目录并指定内容类型,扫描后将在本机保存海报和刮削信息。")
|
||||
Text("添加一个或多个云盘目录,扫描后将在本机保存海报、合集和刮削信息。")
|
||||
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 440)
|
||||
Button { showCreateLibrary = true } label: { Label("新建影视库", systemImage: "plus") }
|
||||
.buttonStyle(.borderedProminent).tint(.orange)
|
||||
@@ -155,7 +203,7 @@ struct MediaLibraryPage: View {
|
||||
}
|
||||
.labelsHidden().frame(width: 220, alignment: .leading)
|
||||
if let library = selectedLibrary {
|
||||
Text("\(library.kind.title) · \(library.rootPath)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Text("\(library.kind.title) · \(library.sources.count) 个目录").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -167,25 +215,33 @@ struct MediaLibraryPage: View {
|
||||
.padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 7))
|
||||
Button { showCreateLibrary = true } label: { Image(systemName: "plus") }.buttonStyle(.bordered).help("新建影视库")
|
||||
Button { if let library = selectedLibrary { Task { await scan(library) } } } label: {
|
||||
if model.isScanningMediaLibrary { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.triangle.2.circlepath") }
|
||||
if model.isScanningMediaLibrary || model.isWritingMediaMetadata { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.triangle.2.circlepath") }
|
||||
}
|
||||
.buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || selectedLibrary == nil)
|
||||
Menu { Button("删除影视库", role: .destructive) { showDeleteConfirmation = true } } label: { Image(systemName: "ellipsis.circle") }
|
||||
.buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || model.isWritingMediaMetadata || selectedLibrary == nil)
|
||||
Menu {
|
||||
Button { editingLibrary = selectedLibrary } label: { Label("管理影视库", systemImage: "slider.horizontal.3") }
|
||||
Divider()
|
||||
Button("删除影视库", role: .destructive) { showDeleteConfirmation = true }
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
.menuStyle(.borderlessButton).help("影视库操作")
|
||||
}
|
||||
if model.isScanningMediaLibrary {
|
||||
if let progress = activeProgress {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView(value: Double(model.mediaLibraryScanProgress.completed), total: Double(max(1, model.mediaLibraryScanProgress.total))).frame(maxWidth: 260)
|
||||
Text(model.mediaLibraryScanProgress.phase).font(.caption.weight(.medium))
|
||||
Text("\(model.mediaLibraryScanProgress.completed)/\(model.mediaLibraryScanProgress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))).frame(maxWidth: 260)
|
||||
Text(progress.phase).font(.caption.weight(.medium))
|
||||
Text("\(progress.completed)/\(progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if let collection = activeCollection {
|
||||
Button { activeCollectionID = nil; selectedItemID = nil } label: { Image(systemName: "chevron.left") }.buttonStyle(.bordered).help("返回合集")
|
||||
Text(collection.name).font(.callout.weight(.semibold)).lineLimit(1)
|
||||
}
|
||||
Picker("分类", selection: $filter) { ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) } }
|
||||
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
|
||||
.pickerStyle(.segmented).labelsHidden().frame(width: 440)
|
||||
Spacer()
|
||||
Label("\(visibleItems.count) 个条目", systemImage: "rectangle.grid.2x2").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Label(filter == .collections && activeCollection == nil ? "\(collectionGroups.count) 个合集" : "\(visibleItems.count) 个条目", systemImage: filter == .collections ? "rectangle.stack" : "rectangle.grid.2x2").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Text("·").foregroundStyle(.tertiary)
|
||||
Label("\(items.count) 个资源", systemImage: "film.stack").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -210,6 +266,19 @@ struct MediaLibraryPage: View {
|
||||
.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 filter == .collections && activeCollection == nil && collectionGroups.isEmpty {
|
||||
ContentUnavailableView("暂无自动合集", systemImage: "rectangle.stack", description: Text("至少有两部本地影片属于同一 TMDB 或 NFO 合集时会自动显示。"))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
} else if filter == .collections && activeCollection == nil {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180, maximum: 230), spacing: 18)], alignment: .leading, spacing: 24) {
|
||||
ForEach(collectionGroups) { collection in
|
||||
MediaCollectionCard(collection: collection) {
|
||||
activeCollectionID = collection.id
|
||||
selectedItemID = collection.items.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.bottom, 36)
|
||||
} else if visibleItems.isEmpty {
|
||||
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
@@ -235,7 +304,7 @@ struct MediaLibraryPage: View {
|
||||
defer { isLoadingCache = false }
|
||||
await model.loadMediaLibraries()
|
||||
if let target = model.mediaLibraryTarget, target.isDirectory {
|
||||
if let existing = model.mediaLibraries.first(where: { $0.rootID == target.id }) {
|
||||
if let existing = model.mediaLibraries.first(where: { library in library.sources.contains { $0.rootID == target.id } }) {
|
||||
selectedLibraryID = existing.id
|
||||
items = await model.cachedMediaLibraryItems(libraryID: existing.id)
|
||||
} else {
|
||||
@@ -283,25 +352,34 @@ struct MediaLibraryPage: View {
|
||||
|
||||
private struct CreateMediaLibrarySheet: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let onCreate: (String, String?, String, MediaLibraryKind, Bool) -> Void
|
||||
let initialLibrary: MediaLibraryDefinition?
|
||||
let onSave: (String, [MediaLibrarySource], MediaLibraryKind, Bool) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var rootID: String?
|
||||
@State private var rootPath = "尚未选择"
|
||||
@State private var kind: MediaLibraryKind = .movies
|
||||
@State private var recursive = true
|
||||
@State private var name: String
|
||||
@State private var sources: [MediaLibrarySource]
|
||||
@State private var kind: MediaLibraryKind
|
||||
@State private var recursive: Bool
|
||||
@State private var showFolderPicker = false
|
||||
@State private var hasChosenRoot = false
|
||||
|
||||
private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
init(model: AppModel, initialLibrary: MediaLibraryDefinition? = nil, onSave: @escaping (String, [MediaLibrarySource], MediaLibraryKind, Bool) -> Void) {
|
||||
self.model = model
|
||||
self.initialLibrary = initialLibrary
|
||||
self.onSave = onSave
|
||||
_name = State(initialValue: initialLibrary?.name ?? "")
|
||||
_sources = State(initialValue: initialLibrary?.sources ?? [])
|
||||
_kind = State(initialValue: initialLibrary?.kind ?? .movies)
|
||||
_recursive = State(initialValue: initialLibrary?.recursive ?? true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(nsColor: .windowBackgroundColor).ignoresSafeArea()
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "play.tv.fill").font(.title2).foregroundStyle(.orange).frame(width: 46, height: 46).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
VStack(alignment: .leading, spacing: 3) { Text("新建影视库").font(.title2.weight(.bold)); Text("云端媒体目录").font(.caption).foregroundStyle(.secondary) }
|
||||
VStack(alignment: .leading, spacing: 3) { Text(initialLibrary == nil ? "新建影视库" : "管理影视库").font(.title2.weight(.bold)); Text("\(sources.count) 个云端媒体目录").font(.caption).foregroundStyle(.secondary) }
|
||||
Spacer()
|
||||
Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭")
|
||||
}
|
||||
@@ -322,30 +400,37 @@ private struct CreateMediaLibrarySheet: View {
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("媒体目录").font(.callout.weight(.semibold))
|
||||
Button { showFolderPicker = true } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: hasChosenRoot ? "folder.fill" : "folder.badge.plus").font(.title3).foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(hasChosenRoot ? rootPath : "选择云盘文件夹").font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(1)
|
||||
if hasChosenRoot { Text(rootID == nil ? "整个云盘" : "已选择媒体目录").font(.caption).foregroundStyle(.secondary) }
|
||||
if !sources.isEmpty {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(sources) { source in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "folder.fill").foregroundStyle(.orange).frame(width: 20)
|
||||
Text(source.path).font(.callout).lineLimit(1)
|
||||
Spacer()
|
||||
Button { sources.removeAll { $0.id == source.id } } label: { Image(systemName: "minus.circle") }
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary).help("移除目录")
|
||||
}
|
||||
.padding(.horizontal, 12).frame(height: 38)
|
||||
if source.id != sources.last?.id { Divider().padding(.leading, 42) }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(12).contentShape(Rectangle())
|
||||
.frame(maxHeight: 152)
|
||||
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain).background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8))
|
||||
Button { showFolderPicker = true } label: { Label("添加媒体目录", systemImage: "folder.badge.plus") }.buttonStyle(.bordered)
|
||||
}
|
||||
Toggle(isOn: $recursive) {
|
||||
VStack(alignment: .leading, spacing: 2) { Text("包含子文件夹").font(.callout.weight(.semibold)); Text("递归扫描目录中的电影、剧集和刮削文件").font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
if !hasChosenRoot { Label("请选择媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) }
|
||||
if sources.isEmpty { Label("请至少添加一个媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) }
|
||||
Spacer()
|
||||
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
Button { onCreate(trimmedName, rootID, rootPath, kind, recursive); dismiss() } label: { Label("创建并扫描", systemImage: "plus") }
|
||||
.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(trimmedName.isEmpty || !hasChosenRoot)
|
||||
Button { onSave(trimmedName, sources, kind, recursive); dismiss() } label: { Label(initialLibrary == nil ? "创建并扫描" : "保存并扫描", systemImage: initialLibrary == nil ? "plus" : "checkmark") }
|
||||
.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(trimmedName.isEmpty || sources.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
@@ -353,8 +438,12 @@ private struct CreateMediaLibrarySheet: View {
|
||||
}
|
||||
.frame(width: 590)
|
||||
.sheet(isPresented: $showFolderPicker) {
|
||||
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "选择此目录") { id, path in
|
||||
rootID = id; rootPath = path; hasChosenRoot = true
|
||||
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "添加此目录") { id, path in
|
||||
if id == nil { sources = [MediaLibrarySource(rootID: nil, path: path)] }
|
||||
else if !sources.contains(where: { $0.rootID == id }) {
|
||||
sources.removeAll { $0.rootID == nil }
|
||||
sources.append(MediaLibrarySource(rootID: id, path: path))
|
||||
}
|
||||
if trimmedName.isEmpty, path != "整个云盘" { name = path.split(separator: "/").last.map(String.init) ?? kind.title }
|
||||
else if trimmedName.isEmpty { name = kind.title }
|
||||
}
|
||||
@@ -381,6 +470,40 @@ private struct LibraryKindButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaCollectionGroup: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let items: [MediaLibraryItem]
|
||||
|
||||
var artwork: Data? { items.first(where: { $0.posterData != nil })?.posterData }
|
||||
}
|
||||
|
||||
private struct MediaCollectionCard: View {
|
||||
let collection: MediaCollectionGroup
|
||||
let onOpen: () -> Void
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onOpen) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
MediaArtwork(data: collection.artwork, icon: "rectangle.stack.fill", contentMode: .fill)
|
||||
.aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
Label("\(collection.items.count) 部", systemImage: "film.stack")
|
||||
.font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5)
|
||||
.foregroundStyle(.white).background(.black.opacity(0.76), in: Capsule()).padding(8)
|
||||
}
|
||||
.overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(isHovered ? 0.34 : 0.10), lineWidth: 1) }
|
||||
Text(collection.name).font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(1)
|
||||
Text(collection.items.map(\.year).filter { !$0.isEmpty }.sorted().first ?? "自动合集")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).onHover { isHovered = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaPosterCard: View {
|
||||
let item: MediaLibraryItem
|
||||
let resourceCount: Int
|
||||
|
||||
Reference in New Issue
Block a user