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)) } } } } }