import AppKit import SwiftUI private enum MediaLibraryFilter: String, CaseIterable, Identifiable { case all = "全部" case movies = "电影" case series = "剧集" case collections = "合集" case unmatched = "未识别" var id: String { rawValue } } struct MediaLibraryPage: View { @ObservedObject var model: AppModel @State private var selectedLibraryID: String? @State private var items: [MediaLibraryItem] = [] @State private var selectedItemID: String? @State private var filter: MediaLibraryFilter = .all @State private var searchText = "" @State private var isLoadingCache = false @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) let matchesFilter: Bool switch filter { 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 } var order: [String] = [] var preferred: [String: MediaLibraryItem] = [:] for item in filtered { let key = item.tmdbID.map { "\(item.mediaKind?.rawValue ?? "media")-\($0)" } ?? item.id if preferred[key] == nil { order.append(key) } if preferred[key].map({ languageScore(item) > languageScore($0) }) ?? true { preferred[key] = item } } 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 }) ?? visibleItems.first } 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 { emptyLibraryState } else { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if let featuredItem, filter != .collections || activeCollection != nil { featuredHero(featuredItem) } libraryControls 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) 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 } Task { await model.deleteMediaLibrary(library) selectedLibraryID = model.mediaLibraries.first?.id await loadCachedItems() } } Button("取消", role: .cancel) { } } message: { Text("只删除本机影视库配置和刮削缓存,不会删除云盘文件。") } .task { await initializeLibrary() } .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("添加一个或多个云盘目录,扫描后将在本机保存海报、合集和刮削信息。") .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 440) Button { showCreateLibrary = true } label: { Label("新建影视库", systemImage: "plus") } .buttonStyle(.borderedProminent).tint(.orange) } .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) } } .padding(.horizontal, 34).padding(.bottom, 30) } .frame(height: 330) } private var libraryControls: some View { VStack(spacing: 14) { HStack(spacing: 12) { if let library = selectedLibrary { Image(systemName: library.kind.icon).font(.title3.weight(.semibold)).foregroundStyle(.orange) .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) } } Spacer() HStack(spacing: 7) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) 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)) 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") } } .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 let progress = activeProgress { HStack(spacing: 10) { 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: 440) Spacer() 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) } } .padding(.horizontal, 28).padding(.vertical, 20) .background(.ultraThinMaterial) .overlay(alignment: .bottom) { Divider().opacity(0.45) } } @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) } 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) } 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) } 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") } } } } .padding(.horizontal, 28).padding(.bottom, 36) } } @MainActor private func initializeLibrary() async { isLoadingCache = true defer { isLoadingCache = false } await model.loadMediaLibraries() if let target = model.mediaLibraryTarget, target.isDirectory { 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 { let library = await model.createMediaLibrary(name: target.name, rootID: target.id, rootPath: target.cloudPath, kind: .mixed, recursive: true) selectedLibraryID = library.id await scan(library) } return } if selectedLibraryID == nil { selectedLibraryID = model.mediaLibraries.first?.id } await loadCachedItems() } @MainActor private func loadCachedItems() async { guard let library = selectedLibrary else { items = []; return } isLoadingCache = true; errorText = "" defer { isLoadingCache = false } items = await model.cachedMediaLibraryItems(libraryID: library.id) if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id } } @MainActor private func scan(_ library: MediaLibraryDefinition) async { errorText = "" do { items = try await model.scanMediaLibrary(library) selectedItemID = items.first?.id } catch { errorText = error.localizedDescription } } private func resourceCount(for item: MediaLibraryItem) -> Int { guard let tmdbID = item.tmdbID else { return 1 } return items.filter { $0.mediaKind == item.mediaKind && $0.tmdbID == tmdbID }.count } private func languageScore(_ item: MediaLibraryItem) -> Int { (item.hasChineseAudio ? 2 : 0) + (item.hasChineseSubtitle ? 1 : 0) } private func play(_ item: MediaLibraryItem) { let preferred = items.filter { candidate in guard let tmdbID = item.tmdbID else { return candidate.id == item.id } return candidate.mediaKind == item.mediaKind && candidate.tmdbID == tmdbID }.max { languageScore($0) < languageScore($1) } ?? item Task { await model.playWithExternalPlayer(preferred.file) } } } private struct CreateMediaLibrarySheet: View { @ObservedObject var model: AppModel let initialLibrary: MediaLibraryDefinition? let onSave: (String, [MediaLibrarySource], MediaLibraryKind, Bool) -> Void @Environment(\.dismiss) private var dismiss @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 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(initialLibrary == nil ? "新建影视库" : "管理影视库").font(.title2.weight(.bold)); Text("\(sources.count) 个云端媒体目录").font(.caption).foregroundStyle(.secondary) } Spacer() Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭") } .padding(24) Divider() VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 8) { Text("名称").font(.callout.weight(.semibold)) TextField("例如:电影、华语剧集", text: $name).textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 10) { Text("内容类型").font(.callout.weight(.semibold)) HStack(spacing: 10) { ForEach(MediaLibraryKind.allCases) { option in LibraryKindButton(kind: option, isSelected: kind == option) { kind = option } } } } VStack(alignment: .leading, spacing: 10) { Text("媒体目录").font(.callout.weight(.semibold)) 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) } } } } .frame(maxHeight: 152) .background(Color.primary.opacity(0.04), 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 sources.isEmpty { Label("请至少添加一个媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) } Spacer() Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) 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) } } .frame(width: 590) .sheet(isPresented: $showFolderPicker) { 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 } } } } } private struct LibraryKindButton: View { let kind: MediaLibraryKind let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { VStack(spacing: 8) { Image(systemName: kind.icon).font(.title3.weight(.semibold)).foregroundStyle(isSelected ? .orange : .secondary) Text(kind.title).font(.callout.weight(.semibold)).foregroundStyle(.primary) } .frame(maxWidth: .infinity).frame(height: 72) .background(isSelected ? Color.orange.opacity(0.12) : Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) .overlay { RoundedRectangle(cornerRadius: 8).stroke(isSelected ? Color.orange.opacity(0.8) : Color.primary.opacity(0.08), lineWidth: isSelected ? 1.5 : 1) } } .buttonStyle(.plain) } } 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 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: "film.fill", contentMode: .fill) .aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6)) 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) HStack(spacing: 6) { Text(item.year.isEmpty ? "视频" : item.year) if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") } Spacer(minLength: 2) if item.hasChineseAudio { Image(systemName: "waveform").help("中文音轨") } if item.hasChineseSubtitle { Image(systemName: "captions.bubble").help("中文字幕") } }.font(.caption).foregroundStyle(.secondary).lineLimit(1) }.contentShape(Rectangle()) } .buttonStyle(.plain).onHover { isHovered = $0 }.simultaneousGesture(TapGesture(count: 2).onEnded(onOpen)) } } private struct MediaLanguageBadge: View { let title: String let icon: String var body: some View { Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.86)) } } 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)) } } } } }