645 lines
34 KiB
Swift
645 lines
34 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?
|
|
@State private var mediaScanTask: Task<Void, Never>?
|
|
|
|
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 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 {
|
|
AppBackdrop()
|
|
if model.mediaLibraryDestination == .tasks {
|
|
mediaTasksView
|
|
} else if model.mediaLibraries.isEmpty && !isLoadingCache {
|
|
emptyLibraryState
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
libraryControls
|
|
ScrollView {
|
|
libraryContent
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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
|
|
startScan(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)
|
|
startScan(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: 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
|
|
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 }
|
|
}
|
|
}
|
|
|
|
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 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, 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()
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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) {
|
|
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.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7))
|
|
Button { showCreateLibrary = true } label: { Image(systemName: "plus") }.buttonStyle(.bordered).help("新建影视库")
|
|
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).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("影视库操作")
|
|
}
|
|
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 {
|
|
MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28)
|
|
} else if model.isScanningMediaLibrary && items.isEmpty {
|
|
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 {
|
|
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(.top, 24).padding(.bottom, 36)
|
|
} else if visibleItems.isEmpty {
|
|
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
|
|
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
|
} else {
|
|
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..<min(6, max(2, model.mediaLibraryScanProgress.total - model.mediaLibraryScanProgress.completed)), id: \.self) { _ in MediaPosterSkeleton() }
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 28).padding(.top, 24).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 } }) {
|
|
model.mediaLibraryDestination = .library(existing.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)
|
|
model.mediaLibraryDestination = .library(library.id)
|
|
selectedLibraryID = library.id
|
|
startScan(library)
|
|
}
|
|
return
|
|
}
|
|
await loadDestination()
|
|
}
|
|
|
|
@MainActor private func loadDestination() async {
|
|
isLoadingCache = true; errorText = ""
|
|
defer { isLoadingCache = false }
|
|
switch model.mediaLibraryDestination {
|
|
case .all:
|
|
selectedLibraryID = nil
|
|
var loaded: [MediaLibraryItem] = []
|
|
for library in model.mediaLibraries { loaded += await model.cachedMediaLibraryItems(libraryID: library.id) }
|
|
items = Array(Dictionary(grouping: loaded, by: \.id).compactMap { $0.value.first })
|
|
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
|
case .library(let id):
|
|
selectedLibraryID = id
|
|
items = await model.cachedMediaLibraryItems(libraryID: id)
|
|
case .tasks:
|
|
selectedLibraryID = nil
|
|
}
|
|
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
|
|
}
|
|
|
|
@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 startScan(_ library: MediaLibraryDefinition) {
|
|
guard mediaScanTask == nil else { return }
|
|
mediaScanTask = Task {
|
|
await scan(library)
|
|
mediaScanTask = nil
|
|
}
|
|
}
|
|
|
|
@MainActor private func cancelScan() {
|
|
mediaScanTask?.cancel()
|
|
}
|
|
|
|
@MainActor private func scan(_ library: MediaLibraryDefinition) async {
|
|
errorText = ""
|
|
do {
|
|
items = try await model.scanMediaLibrary(library)
|
|
selectedItemID = items.first?.id
|
|
} catch is CancellationError { }
|
|
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(2).frame(height: 36, alignment: .topLeading)
|
|
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 ? 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 ? "电影" : "剧集") }
|
|
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 MediaPosterWallSkeleton: View {
|
|
let columns: [GridItem]
|
|
let count: Int
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: columns, alignment: .leading, spacing: 24) {
|
|
ForEach(0..<count, id: \.self) { _ in MediaPosterSkeleton() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MediaPosterSkeleton: View {
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.075)).aspectRatio(2 / 3, contentMode: .fit)
|
|
RoundedRectangle(cornerRadius: 3).fill(Color.primary.opacity(0.10)).frame(height: 14)
|
|
RoundedRectangle(cornerRadius: 3).fill(Color.primary.opacity(0.06)).frame(width: 72, height: 11)
|
|
}
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private struct MediaArtwork: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
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: colorScheme == .dark
|
|
? [Color(red: 0.18, green: 0.21, blue: 0.20), Color(red: 0.09, green: 0.10, blue: 0.11)]
|
|
: [Color(red: 0.88, green: 0.92, blue: 0.91), Color(red: 0.95, green: 0.89, blue: 0.84)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.secondary.opacity(0.55))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|