From d56af3c4e14973657d02c0b505f5a850cea66a8b Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 14:59:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20Emby=20=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=E5=BD=B1=E8=A7=86=E5=BA=93=E5=AF=BC=E8=88=AA=E4=B8=8E?= =?UTF-8?q?=E6=B5=B7=E6=8A=A5=E5=A2=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac/ContentView.swift | 59 ++++++- guangya_mac/MediaLibraryPage.swift | 272 +++++++++++++++++++---------- 2 files changed, 235 insertions(+), 96 deletions(-) diff --git a/guangya_mac/ContentView.swift b/guangya_mac/ContentView.swift index bc07020..e28c64e 100644 --- a/guangya_mac/ContentView.swift +++ b/guangya_mac/ContentView.swift @@ -316,6 +316,7 @@ struct WorkspaceSidebar: View { @Binding var columnVisibility: NavigationSplitViewVisibility @State private var toolsExpanded = true @State private var categoriesExpanded = false + @State private var mediaExpanded = true var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -333,9 +334,37 @@ struct WorkspaceSidebar: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 18) { - ToolSidebarItem(tool: .media, selected: selectedTool == .media) { - model.mediaLibraryTarget = nil - selectedTool = .media + VStack(spacing: 5) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { mediaExpanded = true } + model.mediaLibraryTarget = nil + model.mediaLibraryDestination = .all + selectedTool = .media + } label: { + HStack(spacing: 10) { + Image(systemName: "play.tv.fill").foregroundStyle(AppTheme.orange).frame(width: 26) + Text("影视库").font(.callout.weight(.semibold)) + Spacer() + } + .padding(10).background(selectedTool == .media && model.mediaLibraryDestination == .all ? AppTheme.orange.opacity(0.14) : Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + if mediaExpanded { + VStack(spacing: 3) { + MediaLibrarySidebarRow(title: "所有影视", icon: "rectangle.grid.2x2", isSelected: selectedTool == .media && model.mediaLibraryDestination == .all) { + model.mediaLibraryDestination = .all; selectedTool = .media + } + ForEach(model.mediaLibraries) { library in + MediaLibrarySidebarRow(title: library.name, icon: library.kind.icon, isSelected: selectedTool == .media && model.mediaLibraryDestination == .library(library.id)) { + model.mediaLibraryDestination = .library(library.id); selectedTool = .media + } + } + MediaLibrarySidebarRow(title: "刮削任务", icon: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "arrow.triangle.2.circlepath" : "checklist", isSelected: selectedTool == .media && model.mediaLibraryDestination == .tasks, status: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "进行中" : nil) { + model.mediaLibraryDestination = .tasks; selectedTool = .media + } + } + .padding(.leading, 18) + } } VStack(alignment: .leading, spacing: 8) { Text("文件空间").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) @@ -419,6 +448,30 @@ struct WorkspaceSidebar: View { .padding(16) .softCard(radius: 24) .padding(10) + .task { await model.loadMediaLibraries() } + } +} + +private struct MediaLibrarySidebarRow: View { + let title: String + let icon: String + let isSelected: Bool + var status: String? = nil + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 9) { + Image(systemName: icon).frame(width: 20).foregroundStyle(isSelected ? AppTheme.orange : .secondary) + Text(title).font(.callout).lineLimit(1) + Spacer() + if let status { Text(status).font(.caption2.weight(.semibold)).foregroundStyle(AppTheme.orange) } + } + .padding(.horizontal, 9).frame(height: 32) + .background(isSelected ? AppTheme.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } } diff --git a/guangya_mac/MediaLibraryPage.swift b/guangya_mac/MediaLibraryPage.swift index dd1fcc1..53e6191 100644 --- a/guangya_mac/MediaLibraryPage.swift +++ b/guangya_mac/MediaLibraryPage.swift @@ -23,6 +23,7 @@ struct MediaLibraryPage: View { @State private var showDeleteConfirmation = false @State private var editingLibrary: MediaLibraryDefinition? @State private var activeCollectionID: String? + @State private var mediaScanTask: Task? private var selectedLibrary: MediaLibraryDefinition? { model.mediaLibraries.first { $0.id == selectedLibraryID } @@ -80,34 +81,42 @@ struct MediaLibraryPage: View { 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 }) - ?? visibleItems.first + private var posterColumns: [GridItem] { [GridItem(.adaptive(minimum: 132, maximum: 172), spacing: 16)] } + + private var wallTitle: String { + if let activeCollection { return activeCollection.name } + switch filter { + case .all: return "全部影视" + case .movies: return "电影" + case .series: return "剧集" + case .collections: return "自动合集" + case .unmatched: return "未识别资源" + } } var body: some View { ZStack { - LinearGradient(colors: [Color(red: 0.07, green: 0.075, blue: 0.075), Color(red: 0.035, green: 0.038, blue: 0.04)], startPoint: .top, endPoint: .bottom).ignoresSafeArea() - if model.mediaLibraries.isEmpty && !isLoadingCache { + AppBackdrop() + if model.mediaLibraryDestination == .tasks { + mediaTasksView + } else if model.mediaLibraries.isEmpty && !isLoadingCache { emptyLibraryState } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - if let featuredItem, filter != .collections || activeCollection != nil { featuredHero(featuredItem) } - libraryControls + VStack(spacing: 0) { + libraryControls + ScrollView { libraryContent } } } } - .preferredColorScheme(.dark) .sheet(isPresented: $showCreateLibrary) { CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive in Task { let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive) + model.mediaLibraryDestination = .library(library.id) selectedLibraryID = library.id - await scan(library) + startScan(library) } } } @@ -116,7 +125,7 @@ struct MediaLibraryPage: View { 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) + startScan(updated) } } } @@ -135,11 +144,18 @@ struct MediaLibraryPage: View { } .task { await initializeLibrary() } .onChange(of: selectedLibraryID) { _, _ in activeCollectionID = nil; Task { await loadCachedItems() } } + .onChange(of: model.mediaLibraryDestination) { _, _ in Task { await loadDestination() } } .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 model.mediaLibraryDestination == .all { + var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + liveItems.forEach { merged[$0.id] = $0 } + items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } else { + guard model.mediaLibraryLiveLibraryID == selectedLibraryID else { return } + items = liveItems + } if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id } } } @@ -156,38 +172,41 @@ struct MediaLibraryPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func featuredHero(_ item: MediaLibraryItem) -> some View { - ZStack(alignment: .bottomLeading) { - MediaArtwork(data: item.backdropData ?? item.posterData, icon: "film.fill", contentMode: .fill) - .frame(maxWidth: .infinity).frame(height: 330).clipped() - LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.94)], startPoint: .top, endPoint: .bottom) - LinearGradient(colors: [Color.black.opacity(0.78), .clear], startPoint: .leading, endPoint: .trailing) - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Text(item.mediaKind == .tv ? "剧集" : (item.mediaKind == .movie ? "电影" : "云盘视频")) - if !item.year.isEmpty { Text(item.year) } - let count = resourceCount(for: item) - if count > 1 { Text(item.mediaKind == .tv ? "\(count) 集" : "\(count) 个版本") } - } - .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: 8) { - if item.hasChineseAudio { MediaLanguageBadge(title: "中文音轨", icon: "waveform") } - if item.hasChineseSubtitle { MediaLanguageBadge(title: "中文字幕", icon: "captions.bubble") } - Text(item.file.formattedSize).font(.caption.monospacedDigit()).foregroundStyle(.white.opacity(0.58)) - } - HStack(spacing: 10) { - Button { play(item) } label: { Label(resourceCount(for: item) > 1 && item.mediaKind == .tv ? "播放第 1 集" : "播放", systemImage: "play.fill") } - .buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black) - Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }.buttonStyle(.bordered) + private var mediaTasksView: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("刮削任务").font(.title2.weight(.bold)) + Text("扫描、识别、入库与资源目录写回").font(.callout).foregroundStyle(.secondary) } + Spacer() } - .padding(.horizontal, 34).padding(.bottom, 30) + .padding(.horizontal, 28).padding(.top, 26) + if let progress = activeProgress { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label(progress.phase, systemImage: model.isScanningMediaLibrary ? "magnifyingglass" : "square.and.arrow.down") + .font(.headline) + Spacer() + Text("\(progress.completed)/\(progress.total)").font(.callout.monospacedDigit()).foregroundStyle(.secondary) + } + ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))).tint(AppTheme.orange) + HStack { + Text(model.mediaLibraryLiveLibraryID.flatMap { id in model.mediaLibraries.first(where: { $0.id == id })?.name } ?? "影视库") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button(role: .destructive) { + if model.isScanningMediaLibrary { cancelScan() } else { model.cancelMediaMetadataWriteback() } + } label: { Label("停止", systemImage: "stop.fill") }.buttonStyle(.bordered) + } + } + .padding(18).softCard(radius: 8).padding(.horizontal, 28) + } else { + ContentUnavailableView("没有正在运行的任务", systemImage: "checkmark.circle", description: Text("从具体影视库页面启动扫描后,进度会显示在这里。")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + Spacer() } - .frame(height: 330) } private var libraryControls: some View { @@ -198,13 +217,9 @@ struct MediaLibraryPage: View { .frame(width: 38, height: 38).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) } VStack(alignment: .leading, spacing: 2) { - Picker("影视库", selection: $selectedLibraryID) { - ForEach(model.mediaLibraries) { library in Text(library.name).tag(Optional(library.id)) } - } - .labelsHidden().frame(width: 220, alignment: .leading) - if let library = selectedLibrary { - Text("\(library.kind.title) · \(library.sources.count) 个目录").font(.caption).foregroundStyle(.secondary).lineLimit(1) - } + Text(selectedLibrary?.name ?? "所有影视").font(.headline.weight(.semibold)).lineLimit(1) + Text(selectedLibrary.map { "\($0.kind.title) · \($0.sources.count) 个目录" } ?? "\(model.mediaLibraries.count) 个影视库") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() HStack(spacing: 7) { @@ -212,16 +227,24 @@ struct MediaLibraryPage: View { TextField("搜索影视库", text: $searchText).textFieldStyle(.plain) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") }.buttonStyle(.plain).foregroundStyle(.secondary) } } - .padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 7)) + .padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.primary.opacity(0.055), 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 || model.isWritingMediaMetadata { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.triangle.2.circlepath") } + Button { + if model.isScanningMediaLibrary { cancelScan() } + else if model.isWritingMediaMetadata { model.cancelMediaMetadataWriteback() } + else if let library = selectedLibrary { startScan(library) } + } label: { + Image(systemName: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "stop.fill" : "arrow.triangle.2.circlepath") } - .buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || model.isWritingMediaMetadata || selectedLibrary == nil) + .buttonStyle(.bordered).tint(model.isScanningMediaLibrary || model.isWritingMediaMetadata ? .red : nil) + .help(model.isScanningMediaLibrary ? "取消扫描" : (model.isWritingMediaMetadata ? "停止写回" : "扫描并刮削")) + .disabled(selectedLibrary == nil) Menu { Button { editingLibrary = selectedLibrary } label: { Label("管理影视库", systemImage: "slider.horizontal.3") } + .disabled(selectedLibrary == nil) Divider() Button("删除影视库", role: .destructive) { showDeleteConfirmation = true } + .disabled(selectedLibrary == nil) } label: { Image(systemName: "ellipsis.circle") } .menuStyle(.borderlessButton).help("影视库操作") } @@ -253,49 +276,54 @@ struct MediaLibraryPage: View { @ViewBuilder private var libraryContent: some View { if isLoadingCache && items.isEmpty { - VStack(spacing: 14) { ProgressView().controlSize(.large); Text("正在读取本地影视库…").font(.callout.weight(.medium)) } - .frame(maxWidth: .infinity).padding(.vertical, 90) + MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28) } else if model.isScanningMediaLibrary && items.isEmpty { - VStack(spacing: 14) { - ProgressView().controlSize(.large) - Text(model.mediaLibraryScanProgress.phase).font(.callout.weight(.medium)) - if model.mediaLibraryScanProgress.total > 0 { - Text("\(model.mediaLibraryScanProgress.completed)/\(model.mediaLibraryScanProgress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity).padding(.vertical, 90) + MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28) } 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 + VStack(alignment: .leading, spacing: 18) { + Text("自动合集").font(.title2.weight(.bold)) + LazyVGrid(columns: posterColumns, 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) + .padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 36) } else if visibleItems.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(visibleItems) { item in - MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) { - withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id } - } onOpen: { play(item) } - .contextMenu { - Button { play(item) } label: { Label("播放", systemImage: "play.fill") } - 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") } + VStack(alignment: .leading, spacing: 18) { + HStack { + Text(wallTitle).font(.title2.weight(.bold)) + Spacer() + if model.isScanningMediaLibrary { Text("新条目会自动加入").font(.caption).foregroundStyle(.secondary) } + } + LazyVGrid(columns: posterColumns, alignment: .leading, spacing: 24) { + ForEach(visibleItems) { item in + MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) { + withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id } + } onOpen: { play(item) } + .contextMenu { + Button { play(item) } label: { Label("播放", systemImage: "play.fill") } + 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") } + } + } + if model.isScanningMediaLibrary { + ForEach(0.. Int { @@ -494,7 +555,7 @@ private struct MediaCollectionCard: View { .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.name).font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(2).frame(height: 36, alignment: .topLeading) Text(collection.items.map(\.year).filter { !$0.isEmpty }.sorted().first ?? "自动合集") .font(.caption).foregroundStyle(.secondary) } @@ -521,8 +582,8 @@ private struct MediaPosterCard: View { if isHovered { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) } else if resourceCount > 1 { Text(item.mediaKind == .tv ? "\(resourceCount) 集" : "\(resourceCount) 版本").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) } } - .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) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? AppTheme.orange : Color.primary.opacity(isHovered ? 0.34 : 0.10), lineWidth: isSelected ? 2 : 1) } + Text(item.title).font(.callout.weight(.semibold)).lineLimit(2).frame(height: 36, alignment: .topLeading) HStack(spacing: 6) { Text(item.year.isEmpty ? "视频" : item.year) if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") } @@ -536,23 +597,48 @@ private struct MediaPosterCard: View { } } -private struct MediaLanguageBadge: View { - let title: String - let icon: String +private struct MediaPosterWallSkeleton: View { + let columns: [GridItem] + let count: Int var body: some View { - Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.86)) + LazyVGrid(columns: columns, alignment: .leading, spacing: 24) { + ForEach(0..