559 lines
31 KiB
Swift
559 lines
31 KiB
Swift
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)) } }
|
|
}
|
|
}
|
|
}
|