From 8f78b2531334ce993073d64bbfad32c2bbaa8605 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 14:03:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=90=88=E9=9B=86=E4=B8=8E=E5=A4=9A=E7=9B=AE=E5=BD=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac/MediaLibraryPage.swift | 201 +++++++++++++++++++++++------ 1 file changed, 162 insertions(+), 39 deletions(-) diff --git a/guangya_mac/MediaLibraryPage.swift b/guangya_mac/MediaLibraryPage.swift index e7413c1..dd1fcc1 100644 --- a/guangya_mac/MediaLibraryPage.swift +++ b/guangya_mac/MediaLibraryPage.swift @@ -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