Files
guangya_client/guangya_mac/MediaLibraryPage.swift
T
ngfchl 0df8013d3e feat: 移除内置播放器,统一外部播放体验
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器
- 播放器选择器优化为 Grid 布局,显示真实应用图标
- 右键菜单合并为"播放"菜单,直接列出所有播放器
- 详情页剧集列表增加右键菜单
- 媒体库所有上下文菜单统一播放入口
- 修复启动时自动清理已删除文件的秒传记录
- 添加 prepareAndPerformFastTransfer 静态方法
2026-07-18 00:50:21 +08:00

2450 lines
130 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
case all = "全部"
case movies = "电影"
case series = "剧集"
case collections = "合集"
case unmatched = "未识别"
var id: String { rawValue }
}
private enum MediaShelfStyle: Equatable {
case poster
case landscape
}
struct MediaLibraryPage: View {
@ObservedObject var model: AppModel
@State private var selectedLibraryID: String?
@State private var items: [MediaLibraryItem] = []
@State private var libraryVisualItems: [String: [MediaLibraryItem]] = [:]
@State private var selectedItemID: String?
@State private var filter: MediaLibraryFilter = .all
@State private var isCompactSidebar = false
@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 selectedTaskID: UUID?
@State private var showTMDBSettings = false
@State private var detailItem: MediaLibraryItem?
@State private var manualRecognitionItem: MediaLibraryItem?
@State private var mediaSearchResults: [MediaLibraryItem] = []
@State private var isLoadingMediaSearch = false
@State private var pendingLiveItemsByLibraryID: [String: [MediaLibraryItem]] = [:]
@State private var liveItemsRefreshTask: Task<Void, Never>?
@State private var cachedItemsOffset = 0
@State private var canLoadMoreCachedItems = false
@State private var isLoadingMoreCachedItems = false
private let cachedItemsPageSize = 72
private var selectedLibrary: MediaLibraryDefinition? {
model.mediaLibraries.first { $0.id == selectedLibraryID }
}
private var currentLibraryTask: MediaLibraryTask? {
guard let selectedLibraryID else { return nil }
return model.mediaLibraryTasks.first { $0.libraryID == selectedLibraryID && $0.isActive }
}
private var activeTaskCount: Int { model.mediaLibraryTasks.filter(\.isActive).count }
private var isShowingMediaSearchResults: Bool { !model.mediaLibrarySearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
private var detailContextItems: [MediaLibraryItem] { isShowingMediaSearchResults ? mediaSearchResults : items }
private var visibleItems: [MediaLibraryItem] {
let filtered = items.filter { item in
let matchesSearch = model.mediaLibrarySearchText.isEmpty || item.title.localizedCaseInsensitiveContains(model.mediaLibrarySearchText) || item.originalTitle.localizedCaseInsensitiveContains(model.mediaLibrarySearchText)
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: 166), spacing: 16)] }
private var movieCount: Int { model.mediaLibraryStatistics.movies }
private var seriesCount: Int { model.mediaLibraryStatistics.series }
private var unmatchedCount: Int { model.mediaLibraryStatistics.unmatched }
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 {
// Keep the media workspace in the same material and brand language
// as the cloud workspace, while the artwork remains the visual focus.
AppBackdrop()
HStack(spacing: 0) {
mediaSidebar
Divider().opacity(0.5)
ScrollViewReader { scrollProxy in
Group {
if let detailItem {
MediaItemDetailPage(model: model, item: detailItem, items: detailContextItems, preferredLibraryID: selectedLibraryID, onBack: {
self.detailItem = nil
DispatchQueue.main.async {
guard let selectedItemID else { return }
withAnimation(.easeInOut(duration: 0.18)) { scrollProxy.scrollTo("wall-\(selectedItemID)", anchor: .center) }
}
}, onPlay: { item in
play(item)
}, onScrape: { item in
scrapeUnmatchedItem(item)
}, onRecognized: { updated in
if let index = items.firstIndex(where: { $0.id == updated.id }) { items[index] = updated }
if let index = mediaSearchResults.firstIndex(where: { $0.id == updated.id }) { mediaSearchResults[index] = updated }
self.detailItem = updated
})
} else if isShowingMediaSearchResults {
mediaSearchResultsPage
} else if model.mediaLibraryDestination == .tasks {
mediaTasksView
} else if model.mediaLibraryDestination == .management {
mediaLibraryManagementView
} else if model.mediaLibraryDestination == .categories {
MediaCategoryManagementPage(model: model)
} else if model.mediaLibraryDestination == .tmdb {
ScrollView { TMDBToolPage(model: model).padding(30) }
} else if model.mediaLibraries.isEmpty && !isLoadingCache {
emptyLibraryState
} else {
VStack(spacing: 0) {
libraryControls
ScrollView {
libraryContent
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.sheet(isPresented: $showCreateLibrary) {
CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive, minimumSizeMB in
Task {
let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB)
model.mediaLibraryDestination = .library(library.id)
selectedLibraryID = library.id
startScan(library)
}
}
}
.sheet(item: $editingLibrary) { library in
CreateMediaLibrarySheet(model: model, initialLibrary: library) { name, sources, kind, recursive, minimumSizeMB in
Task {
let updated = MediaLibraryDefinition(id: library.id, name: name, sources: sources, kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: library.updatedAt)
await model.updateMediaLibrary(updated)
startScan(updated)
}
}
}
.sheet(isPresented: $showTMDBSettings) {
MediaTMDBSettingsSheet(model: model)
}
.sheet(item: $manualRecognitionItem) { item in
ManualMediaRecognitionSheet(model: model, item: item, preferredLibraryID: selectedLibraryID) { updated in
if let index = items.firstIndex(where: { $0.id == updated.id }) { items[index] = updated }
if detailItem?.id == updated.id { detailItem = 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()
await loadMediaSearchResults()
}
.onChange(of: selectedLibraryID) { _, _ in activeCollectionID = nil; Task { await loadCachedItems() } }
.onChange(of: model.mediaLibraryDestination) { _, _ in detailItem = nil; Task { await loadDestination() } }
.onChange(of: filter) { _, value in if value != .collections { activeCollectionID = nil } }
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } }
.onChange(of: model.mediaLibrarySearchText) { _, _ in Task { await loadMediaSearchResults() } }
.background {
GeometryReader { proxy in
Color.clear
.onAppear { isCompactSidebar = proxy.size.width < 1120 }
.onChange(of: proxy.size.width) { _, width in isCompactSidebar = width < 1120 }
}
}
.onReceive(model.$mediaLibraryLiveItemsByLibraryID) { liveItemsByLibraryID in
for (libraryID, liveItems) in liveItemsByLibraryID {
libraryVisualItems[libraryID] = liveItems
}
pendingLiveItemsByLibraryID = liveItemsByLibraryID
scheduleLiveItemsRefresh(immediately: items.isEmpty)
}
}
private func scheduleLiveItemsRefresh(immediately: Bool) {
liveItemsRefreshTask?.cancel()
guard !immediately else {
applyPendingLiveItems()
return
}
liveItemsRefreshTask = Task {
try? await Task.sleep(for: .milliseconds(180))
guard !Task.isCancelled else { return }
applyPendingLiveItems()
}
}
/// Scans can yield several results per second. Coalescing wall updates keeps
/// scrolling and sidebar interaction responsive, while the first batch still
/// appears as soon as it has been scraped.
private func applyPendingLiveItems() {
let liveItemsByLibraryID = pendingLiveItemsByLibraryID
if model.mediaLibraryDestination == .all {
var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
liveItemsByLibraryID.values.lazy.flatMap { $0 }.forEach { merged[$0.id] = $0 }
items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
} else {
guard let selectedLibraryID, let liveItems = liveItemsByLibraryID[selectedLibraryID] else { return }
items = liveItems.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
}
private var mediaSidebar: some View {
Group {
if isCompactSidebar { compactMediaSidebar }
else { fullMediaSidebar }
}
}
private var fullMediaSidebar: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "play.tv.fill")
.font(.headline.weight(.bold)).foregroundStyle(.white)
.frame(width: 38, height: 38)
.background(AppTheme.orange, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text("光鸭影视").font(.headline.weight(.bold))
Text("MEDIA CENTER").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.1).foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 18)
VStack(spacing: 3) {
mediaNavigationRow(title: "首页", icon: "house.fill", isSelected: model.mediaLibraryDestination == .all && filter == .all) {
model.mediaLibraryDestination = .all; filter = .all; activeCollectionID = nil
}
mediaNavigationRow(title: "电影", icon: "film", isSelected: model.mediaLibraryDestination == .all && filter == .movies, badge: movieCount == 0 ? nil : "\(movieCount)") {
model.mediaLibraryDestination = .all; filter = .movies; activeCollectionID = nil
}
mediaNavigationRow(title: "电视剧", icon: "play.tv", isSelected: model.mediaLibraryDestination == .all && filter == .series, badge: seriesCount == 0 ? nil : "\(seriesCount)") {
model.mediaLibraryDestination = .all; filter = .series; activeCollectionID = nil
}
mediaNavigationRow(title: "合集", icon: "rectangle.stack.fill", isSelected: model.mediaLibraryDestination == .all && filter == .collections, badge: model.mediaLibraryStatistics.collections == 0 ? nil : "\(model.mediaLibraryStatistics.collections)") {
model.mediaLibraryDestination = .all; filter = .collections; activeCollectionID = nil
}
mediaNavigationRow(title: "未识别", icon: "questionmark.folder", isSelected: model.mediaLibraryDestination == .all && filter == .unmatched, badge: unmatchedCount == 0 ? nil : "\(unmatchedCount)") {
model.mediaLibraryDestination = .all; filter = .unmatched; activeCollectionID = nil
}
}
.padding(.horizontal, 6)
Text("媒体库").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
.padding(.horizontal, 16).padding(.top, 22).padding(.bottom, 8)
ScrollView {
VStack(spacing: 3) {
ForEach(model.mediaLibraries) { library in
mediaNavigationRow(title: library.name, icon: library.kind.icon, isSelected: model.mediaLibraryDestination == .library(library.id), badge: libraryItemCount(library.id) == 0 ? nil : "\(libraryItemCount(library.id))") {
model.mediaLibraryDestination = .library(library.id); filter = .all; activeCollectionID = nil
}
}
}
.padding(.horizontal, 6)
}
Text("管理").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
.padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8)
VStack(spacing: 3) {
mediaNavigationRow(title: "管理", icon: "slider.horizontal.3", isSelected: model.mediaLibraryDestination == .management) {
model.mediaLibraryDestination = .management
}
mediaNavigationRow(title: "刮削任务", icon: activeTaskCount > 0 ? "arrow.triangle.2.circlepath" : "checklist", isSelected: model.mediaLibraryDestination == .tasks, badge: activeTaskCount == 0 ? nil : "\(activeTaskCount)") {
model.mediaLibraryDestination = .tasks
}
mediaNavigationRow(title: "TMDB 整理", icon: "wand.and.stars", isSelected: model.mediaLibraryDestination == .tmdb) {
model.mediaLibraryDestination = .tmdb
}
mediaNavigationRow(title: "分类管理", icon: "square.grid.2x2", isSelected: model.mediaLibraryDestination == .categories) {
model.mediaLibraryDestination = .categories
}
}
.padding(.horizontal, 6)
Divider().opacity(0.5)
mediaAccountFooter
}
.frame(width: 248)
.background(.regularMaterial)
}
private var compactMediaSidebar: some View {
VStack(spacing: 10) {
Image(systemName: "play.tv.fill").font(.headline).foregroundStyle(.white)
.frame(width: 36, height: 36).background(AppTheme.orange, in: RoundedRectangle(cornerRadius: 9))
.help("光鸭影视")
Divider()
mediaCompactButton("首页", icon: "house.fill", selected: model.mediaLibraryDestination == .all && filter == .all) { model.mediaLibraryDestination = .all; filter = .all }
mediaCompactButton("电影", icon: "film", selected: model.mediaLibraryDestination == .all && filter == .movies) { model.mediaLibraryDestination = .all; filter = .movies }
mediaCompactButton("电视剧", icon: "play.tv", selected: model.mediaLibraryDestination == .all && filter == .series) { model.mediaLibraryDestination = .all; filter = .series }
mediaCompactButton("合集", icon: "rectangle.stack.fill", selected: model.mediaLibraryDestination == .all && filter == .collections) { model.mediaLibraryDestination = .all; filter = .collections }
mediaCompactButton("未识别", icon: "questionmark.folder", selected: model.mediaLibraryDestination == .all && filter == .unmatched) { model.mediaLibraryDestination = .all; filter = .unmatched }
Divider().padding(.vertical, 2)
ForEach(model.mediaLibraries) { library in
mediaCompactButton(library.name, icon: library.kind.icon, selected: model.mediaLibraryDestination == .library(library.id)) { model.mediaLibraryDestination = .library(library.id); filter = .all }
}
Spacer(minLength: 8)
Divider()
mediaCompactButton("管理", icon: "slider.horizontal.3", selected: model.mediaLibraryDestination == .management) { model.mediaLibraryDestination = .management }
mediaCompactButton("刮削任务", icon: "checklist", selected: model.mediaLibraryDestination == .tasks) { model.mediaLibraryDestination = .tasks }
mediaCompactButton("TMDB 整理", icon: "wand.and.stars", selected: model.mediaLibraryDestination == .tmdb) { model.mediaLibraryDestination = .tmdb }
mediaCompactButton("分类管理", icon: "square.grid.2x2", selected: model.mediaLibraryDestination == .categories) { model.mediaLibraryDestination = .categories }
Menu {
Text(model.user.name)
if !model.user.phone.isEmpty { Text(model.user.phone) }
Divider()
Button("退出登录", role: .destructive) { model.logout() }
} label: {
Text(String(model.user.name.prefix(1))).font(.caption.weight(.bold)).foregroundStyle(AppTheme.orange)
.frame(width: 32, height: 32).background(AppTheme.orange.opacity(0.14), in: Circle())
}
.menuStyle(.borderlessButton).help("账户:\(model.user.name)")
}
.padding(.vertical, 14).frame(width: 64).frame(maxHeight: .infinity)
.background(.regularMaterial)
}
private func mediaCompactButton(_ title: String, icon: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon).frame(width: 34, height: 34)
.foregroundStyle(selected ? AppTheme.orange : Color.secondary)
.background { mediaSelectionSurface(selected, cornerRadius: 7) }
}
.buttonStyle(.plain).help(title).compactHoverHint(title).accessibilityLabel(title)
}
private var mediaAccountFooter: some View {
Menu {
Text(model.user.name)
if !model.user.phone.isEmpty { Text(model.user.phone) }
if model.user.capacity != nil { Text(model.user.capacityText) }
Divider()
Button("退出登录", role: .destructive) { model.logout() }
} label: {
HStack(spacing: 10) {
Text(String(model.user.name.prefix(1))).font(.callout.weight(.bold)).foregroundStyle(AppTheme.orange)
.frame(width: 32, height: 32).background(AppTheme.orange.opacity(0.14), in: Circle())
VStack(alignment: .leading, spacing: 1) {
Text(model.user.name).font(.callout.weight(.semibold)).lineLimit(1)
Text(model.user.phone.isEmpty ? "账户信息" : model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Image(systemName: "chevron.up.chevron.down").font(.caption).foregroundStyle(.tertiary)
}
.padding(12).contentShape(Rectangle())
}
.menuStyle(.borderlessButton).padding(10)
}
private func mediaNavigationRow(title: String, icon: String, isSelected: Bool, badge: String? = nil, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 13, weight: .semibold))
.frame(width: 27, height: 27)
.foregroundStyle(isSelected ? Color.white : AppTheme.orange)
.background(isSelected ? AppTheme.orange : AppTheme.orange.opacity(0.11), in: RoundedRectangle(cornerRadius: 9, style: .continuous))
Text(title).font(.callout.weight(isSelected ? .semibold : .regular)).lineLimit(1)
Spacer(minLength: 4)
if let badge { Text(badge).font(.caption2.monospacedDigit()).foregroundStyle(isSelected ? AppTheme.orange : .secondary) }
}
.padding(.horizontal, 9).frame(height: 42)
.background { mediaSelectionSurface(isSelected, cornerRadius: 12) }
}
.buttonStyle(.plain)
}
@ViewBuilder
private func mediaSelectionSurface(_ isSelected: Bool, cornerRadius: CGFloat) -> some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
if isSelected {
if #available(macOS 26.0, *) {
AppTheme.orange.opacity(0.14)
.glassEffect(.regular.tint(AppTheme.orange.opacity(0.23)), in: shape)
} else {
AppTheme.orange.opacity(0.14)
.background(.ultraThinMaterial, in: shape)
}
} else {
Color.clear
}
}
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 mediaLibraryManagementView: some View {
VStack(spacing: 0) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text("媒体库管理").font(.title2.weight(.bold))
Text("管理媒体目录、扫描状态与刮削规则").font(.callout).foregroundStyle(.secondary)
}
Spacer()
if model.isMediaLibraryBackupRunning {
ProgressView().controlSize(.small)
}
Menu {
Button("导出本地备份…", action: exportMediaLibraryBackup)
Button("导入本地备份…", action: importMediaLibraryBackup)
Divider()
Button("备份到云盘") { Task { await model.backupMediaLibraryToCloud() } }
Button("从云盘恢复") { Task { await model.restoreMediaLibraryBackupFromCloud() } }
} label: {
Label("数据", systemImage: "externaldrive.badge.icloud")
}
.buttonStyle(.bordered)
Button { showTMDBSettings = true } label: { Label("TMDB 设置", systemImage: "key") }
.buttonStyle(.bordered)
Button { showCreateLibrary = true } label: { Label("新增媒体库", systemImage: "plus") }
.buttonStyle(.borderedProminent).tint(AppTheme.orange)
Menu { Button("名称") { } ; Button("最近扫描") { } } label: { Label("排序", systemImage: "arrow.up.arrow.down") }
.buttonStyle(.bordered)
}
.padding(.horizontal, 30).padding(.vertical, 20)
if !model.mediaLibraryBackupStatus.isEmpty {
HStack(spacing: 7) {
Image(systemName: model.isMediaLibraryBackupRunning ? "arrow.triangle.2.circlepath" : "externaldrive")
Text(model.mediaLibraryBackupStatus)
.lineLimit(1)
Spacer()
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 30).padding(.bottom, 10)
}
Divider()
if model.mediaLibraries.isEmpty {
ContentUnavailableView("还没有媒体库", systemImage: "rectangle.stack.badge.plus", description: Text("添加一个或多个云端媒体目录后开始刮削。"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 0) {
HStack {
Text("媒体库").frame(width: 255, alignment: .leading)
Text("媒体文件夹").frame(maxWidth: .infinity, alignment: .leading)
Text("类型").frame(width: 100, alignment: .leading)
Text("文件最近更新").frame(width: 130, alignment: .leading)
Text("操作").frame(width: 180, alignment: .trailing)
}
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
.padding(.horizontal, 30).frame(height: 46)
ForEach(model.mediaLibraries) { library in
MediaLibraryManagementRow(
library: library,
itemCount: libraryItemCount(library.id),
onOpen: { model.mediaLibraryDestination = .library(library.id) },
onScan: { startScan(library) },
onEdit: { editingLibrary = library },
onDelete: { selectedLibraryID = library.id; showDeleteConfirmation = true }
)
Divider().padding(.leading, 30)
}
}
}
}
}
}
private func exportMediaLibraryBackup() {
let panel = NSSavePanel()
panel.title = "导出影视缓存与刮削数据"
panel.nameFieldStringValue = "media-library.sqlite3"
panel.allowedContentTypes = [.database]
guard panel.runModal() == .OK, let url = panel.url else { return }
Task {
do {
model.mediaLibraryBackupStatus = "正在导出本地备份…"
try await model.exportMediaLibraryBackup(to: url)
model.mediaLibraryBackupStatus = "本地备份已导出"
} catch {
model.mediaLibraryBackupStatus = "导出失败:\(error.localizedDescription)"
}
}
}
private func importMediaLibraryBackup() {
let panel = NSOpenPanel()
panel.title = "导入影视缓存与刮削数据"
panel.allowedContentTypes = [.database]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else { return }
let accessed = url.startAccessingSecurityScopedResource()
Task {
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
do {
model.mediaLibraryBackupStatus = "正在导入本地备份…"
try await model.importMediaLibraryBackup(from: url)
await initializeLibrary()
model.mediaLibraryBackupStatus = "本地备份已导入"
} catch {
model.mediaLibraryBackupStatus = "导入失败:\(error.localizedDescription)"
}
}
}
private var mediaTasksView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 5) {
Text("刮削任务").font(.system(size: 26, weight: .bold))
Text("每个影视库独立扫描、识别、入库和写回,可并行执行。")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
Label("\(activeTaskCount) 个进行中", systemImage: "arrow.triangle.2.circlepath")
.font(.callout.weight(.medium)).foregroundStyle(activeTaskCount == 0 ? Color.secondary : AppTheme.orange)
if model.mediaLibraryTasks.contains(where: { !$0.isActive }) {
Button("清理已结束") { model.clearFinishedMediaLibraryTasks() }
.buttonStyle(.bordered).controlSize(.small)
.help("删除已完成、已停止和失败任务的记录")
}
}
if model.tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Label("TMDB API Key 未配置,扫描到的视频无法自动识别。请在影视模式侧栏配置后重新扫描。", systemImage: "exclamationmark.triangle.fill")
.font(.callout).foregroundStyle(.yellow).padding(12)
.background(Color.yellow.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
}
if model.mediaLibraryTasks.isEmpty {
ContentUnavailableView("没有刮削记录", systemImage: "checklist", description: Text("从任意影视库点击刷新即可创建并行任务。"))
.frame(maxWidth: .infinity).padding(.vertical, 90)
} else {
ForEach(model.mediaLibraryTasks) { task in
MediaLibraryTaskCard(task: task, isExpanded: selectedTaskID == task.id) {
selectedTaskID = selectedTaskID == task.id ? nil : task.id
} onCancel: {
model.cancelMediaLibraryTask(task.id)
} onResume: {
Task { await model.resumeMediaLibraryTask(task.id) }
} onRemove: {
if selectedTaskID == task.id { selectedTaskID = nil }
model.removeMediaLibraryTask(task.id)
}
}
}
}
.padding(.horizontal, 30).padding(.vertical, 26)
.frame(maxWidth: 1280, alignment: .leading)
}
}
private var libraryControls: some View {
VStack(spacing: 10) {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 3) {
Text(selectedLibrary?.name ?? (filter == .all ? "首页" : wallTitle))
.font(.system(size: 24, weight: .bold))
.lineLimit(1)
if let selectedLibrary {
Text("\(selectedLibrary.kind.title) · \(selectedLibrary.sources.count) 个媒体目录")
.font(.caption).foregroundStyle(.secondary)
} else if filter == .all {
Text("在你的云盘中浏览、整理和播放影视内容")
.font(.caption).foregroundStyle(.secondary)
}
}
Spacer(minLength: 24)
Button { showCreateLibrary = true } label: { Label("新建媒体库", systemImage: "plus") }
.buttonStyle(.borderedProminent).tint(AppTheme.orange).help("新建影视库")
if let library = selectedLibrary {
Button { startScan(library) } label: { Image(systemName: "arrow.triangle.2.circlepath") }
.buttonStyle(.bordered).help("扫描并自动识别")
Menu {
Button { editingLibrary = library } label: { Label("管理影视库", systemImage: "slider.horizontal.3") }
Divider()
Button("删除影视库", role: .destructive) { showDeleteConfirmation = true }
} label: { Image(systemName: "ellipsis") }
.menuStyle(.borderlessButton).help("影视库操作")
}
}
if let task = currentLibraryTask {
HStack(spacing: 10) {
ProgressView(value: Double(task.progress.completed), total: Double(max(1, task.progress.total))).frame(maxWidth: 240).tint(AppTheme.orange)
Text(task.progress.phase).font(.caption.weight(.medium))
Text("\(task.progress.completed)/\(task.progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
if let name = task.currentScrapingName ?? task.currentWritingName { Text(name).font(.caption).foregroundStyle(.tertiary).lineLimit(1) }
Spacer()
Button(role: .destructive) { model.cancelMediaLibraryTask(task.id) } label: { Image(systemName: "stop.fill") }
.buttonStyle(.bordered).help("停止此任务")
}
}
}
.padding(16)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(.white.opacity(0.25), lineWidth: 1) }
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 8)
}
@ViewBuilder private var libraryContent: some View {
if isLoadingCache && items.isEmpty {
MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28)
} else if currentLibraryTask?.isActive == true && 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 model.mediaLibraryDestination == .all && filter == .all && model.mediaLibrarySearchText.isEmpty {
mediaHomeContent
} else if visibleItems.isEmpty {
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
.frame(maxWidth: .infinity).padding(.vertical, 70)
} else {
VStack(alignment: .leading, spacing: 18) {
if model.mediaLibraryDestination == .all && filter == .all && model.mediaLibrarySearchText.isEmpty && !model.mediaLibraries.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("媒体库").font(.title3.weight(.bold))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(model.mediaLibraries) { library in
MediaLibraryShortcutCard(library: library, itemCount: model.mediaLibraryLiveItemsByLibraryID[library.id]?.count ?? 0) {
model.mediaLibraryDestination = .library(library.id)
}
}
}
}
}
}
HStack {
Text(wallTitle).font(.title2.weight(.bold))
Spacer()
if filter == .unmatched, !visibleItems.isEmpty {
Button {
Task {
_ = await model.batchRecognizeUnmatchedMediaItems(visibleItems, preferredLibraryID: selectedLibraryID)
}
} label: {
Label(model.isBatchRecognizingMedia ? "正在批量识别" : "批量自动识别 \(visibleItems.count) 项", systemImage: "sparkle.magnifyingglass")
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(model.isBatchRecognizingMedia || currentLibraryTask?.isActive == true)
}
if currentLibraryTask?.isActive == true { 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 }
detailItem = item
} onOpen: { play(item) }
.contextMenu {
Menu("播放") {
if AppModel.installedPlayers.isEmpty {
Button { play(item) } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(item.file, withPlayerNamed: player.name) } } label: { Label(player.name, 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 !item.isMatched { Button { scrapeUnmatchedItem(item) } label: { Label("刮削", systemImage: "wand.and.stars") } }
Button { manualRecognitionItem = item } label: { Label("手动识别", systemImage: "magnifyingglass") }
}
.id("wall-\(item.id)")
}
if let task = currentLibraryTask, task.isActive {
ForEach(0..<min(6, max(2, task.progress.total - task.progress.completed)), id: \.self) { _ in MediaPosterSkeleton() }
}
}
cachedItemsLoadMoreFooter
}
.padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 36)
}
}
private var mediaSearchResultsPage: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("影视搜索结果").font(.title3.weight(.bold))
Text("“\(model.mediaLibrarySearchText)”")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text("\(mediaSearchResults.count) 项")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Button { model.mediaLibrarySearchText = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("返回影视库")
}
.padding(.horizontal, 28)
.padding(.vertical, 16)
Divider()
if isLoadingMediaSearch {
MediaPosterWallSkeleton(columns: posterColumns, count: 18)
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if mediaSearchResults.isEmpty {
ContentUnavailableView("没有匹配的影视", systemImage: "magnifyingglass", description: Text("尝试标题、原始标题或发行年份。"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVGrid(columns: posterColumns, alignment: .leading, spacing: 24) {
ForEach(mediaSearchResults) { item in
MediaPosterCard(item: item, resourceCount: mediaSearchResourceCount(for: item), isSelected: selectedItemID == item.id) {
selectedItemID = item.id
detailItem = item
} onOpen: { playSearchResult(item) }
.contextMenu {
Menu("播放") {
if AppModel.installedPlayers.isEmpty {
Button { playSearchResult(item) } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(item.file, withPlayerNamed: player.name) } } label: { Label(player.name, 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 !item.isMatched { Button { scrapeUnmatchedItem(item) } label: { Label("刮削", systemImage: "wand.and.stars") } }
Button { manualRecognitionItem = item } label: { Label("手动识别", systemImage: "magnifyingglass") }
}
}
}
.padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 36)
}
}
}
}
@MainActor private func loadMediaSearchResults() async {
let query = model.mediaLibrarySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
mediaSearchResults = []
isLoadingMediaSearch = false
return
}
isLoadingMediaSearch = true
let cachedItems = await model.cachedAllMediaLibraryItems()
guard model.mediaLibrarySearchText.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
let matchingItems = cachedItems.filter {
$0.title.localizedCaseInsensitiveContains(query) ||
$0.originalTitle.localizedCaseInsensitiveContains(query) ||
$0.releaseDate.localizedCaseInsensitiveContains(query)
}
var preferred: [String: MediaLibraryItem] = [:]
for item in matchingItems {
let key = item.tmdbID.map { "\(item.mediaKind?.rawValue ?? "media")-\($0)" } ?? item.id
if preferred[key].map({ languageScore(item) > languageScore($0) }) ?? true { preferred[key] = item }
}
mediaSearchResults = preferred.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
isLoadingMediaSearch = false
}
private func mediaSearchResourceCount(for item: MediaLibraryItem) -> Int {
guard let tmdbID = item.tmdbID else { return 1 }
return mediaSearchResults.filter { $0.mediaKind == item.mediaKind && $0.tmdbID == tmdbID }.count
}
private func playSearchResult(_ item: MediaLibraryItem) {
guard !item.file.isDirectory else {
model.lastActionMessage = "原盘已作为完整目录入库;请使用支持蓝光/DVD 目录的本地播放器打开该目录。"
return
}
PlayerWindowCoordinator.shared.open(model: model, file: item.file)
}
private var mediaHomeContent: some View {
VStack(alignment: .leading, spacing: 34) {
if model.tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Label("TMDB API Key 未配置,新增内容无法自动识别。", systemImage: "exclamationmark.triangle.fill")
.font(.callout).foregroundStyle(.yellow)
.padding(.horizontal, 13).padding(.vertical, 10)
.background(Color.yellow.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
}
if !model.mediaLibraries.isEmpty {
VStack(alignment: .leading, spacing: 13) {
mediaShelfHeading("媒体库", actionTitle: "管理") { model.mediaLibraryDestination = .management }
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) {
ForEach(model.mediaLibraries) { library in
MediaLibraryVisualCard(library: library, items: itemsForLibrary(library.id), itemCount: libraryItemCount(library.id)) {
model.mediaLibraryDestination = .library(library.id)
}
}
}
}
}
}
let recentlyAdded = visibleItems.sorted { $0.file.modifiedAt.localizedStandardCompare($1.file.modifiedAt) == .orderedDescending }
if !recentlyAdded.isEmpty { mediaShelf(title: "最近入库", items: recentlyAdded, style: .landscape) }
let movies = visibleItems.filter { $0.mediaKind == .movie }
if !movies.isEmpty { mediaShelf(title: "电影", items: movies, filter: .movies) }
let series = visibleItems.filter { $0.mediaKind == .tv }
if !series.isEmpty { mediaShelf(title: "电视剧", items: series, filter: .series) }
let unmatched = visibleItems.filter { !$0.isMatched }
if !unmatched.isEmpty { mediaShelf(title: "待识别", items: unmatched, filter: .unmatched) }
cachedItemsLoadMoreFooter
}
.padding(.horizontal, 32).padding(.top, 27).padding(.bottom, 42)
}
private func mediaShelf(title: String, items: [MediaLibraryItem], filter targetFilter: MediaLibraryFilter? = nil, style: MediaShelfStyle = .poster) -> some View {
VStack(alignment: .leading, spacing: 13) {
mediaShelfHeading(title, actionTitle: targetFilter == nil ? nil : "查看全部") {
guard let targetFilter else { return }
filter = targetFilter
}
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 14) {
ForEach(Array(items.prefix(18))) { item in
Group {
if style == .landscape {
MediaLandscapeCard(item: item, onOpen: { detailItem = item }, onPlay: { play(item) })
} else {
MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) {
selectedItemID = item.id
detailItem = item
} onOpen: { play(item) }
.frame(width: 136)
}
}
.contextMenu {
Menu("播放") {
if AppModel.installedPlayers.isEmpty {
Button { play(item) } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(item.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
}
}
}
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
if !item.isMatched { Button { scrapeUnmatchedItem(item) } label: { Label("刮削", systemImage: "wand.and.stars") } }
Button { manualRecognitionItem = item } label: { Label("手动识别", systemImage: "magnifyingglass") }
}
}
}
}
}
}
private func mediaShelfHeading(_ title: String, actionTitle: String? = nil, action: @escaping () -> Void = {}) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title).font(.title3.weight(.bold))
Spacer()
if let actionTitle {
Button(action: action) {
Label(actionTitle, systemImage: "chevron.right")
.font(.caption.weight(.medium)).labelStyle(.titleAndIcon)
}
.buttonStyle(.plain).foregroundStyle(.secondary)
}
}
}
@ViewBuilder private var cachedItemsLoadMoreFooter: some View {
if isLoadingMoreCachedItems {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("正在加载更多影视…")
}
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else if canLoadMoreCachedItems {
Color.clear
.frame(height: 1)
.onAppear { Task { await loadMoreCachedItems() } }
Button { Task { await loadMoreCachedItems() } } label: {
Label("加载更多影视", systemImage: "arrow.down.circle")
}
.buttonStyle(.bordered)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
}
private func libraryItemCount(_ libraryID: String) -> Int {
model.mediaLibraryStatisticsByLibraryID[libraryID]?.total ?? model.mediaLibraryLiveItemsByLibraryID[libraryID]?.count ?? libraryVisualItems[libraryID]?.count ?? (selectedLibraryID == libraryID ? items.count : 0)
}
private func itemsForLibrary(_ libraryID: String) -> [MediaLibraryItem] {
model.mediaLibraryLiveItemsByLibraryID[libraryID] ?? libraryVisualItems[libraryID] ?? (selectedLibraryID == libraryID ? items : [])
}
@MainActor private func initializeLibrary() async {
isLoadingCache = true
defer { isLoadingCache = false }
await model.loadMediaLibraries()
let removedLegacyEntries = await model.removeLegacyIgnoredMediaItemsIfNeeded()
if removedLegacyEntries > 0 {
model.lastActionMessage = "已清理 \(removedLegacyEntries) 个原盘内部序号文件(如 00000),它们不会再参与刮削。"
}
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.cachedMediaLibraryPage(libraryID: existing.id, limit: cachedItemsPageSize)
cachedItemsOffset = items.count
canLoadMoreCachedItems = items.count == cachedItemsPageSize
libraryVisualItems[existing.id] = items
} 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
items = await model.cachedMediaLibraryPage(libraryID: nil, limit: cachedItemsPageSize)
case .library(let id):
selectedLibraryID = id
items = await model.cachedMediaLibraryPage(libraryID: id, limit: cachedItemsPageSize)
libraryVisualItems[id] = items
case .management:
selectedLibraryID = nil
case .categories:
selectedLibraryID = nil
case .tasks:
selectedLibraryID = nil
case .tmdb:
selectedLibraryID = nil
}
cachedItemsOffset = items.count
canLoadMoreCachedItems = items.count == cachedItemsPageSize
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.cachedMediaLibraryPage(libraryID: library.id, limit: cachedItemsPageSize)
cachedItemsOffset = items.count
canLoadMoreCachedItems = items.count == cachedItemsPageSize
libraryVisualItems[library.id] = items
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
}
@MainActor private func loadMoreCachedItems() async {
guard canLoadMoreCachedItems, !isLoadingMoreCachedItems else { return }
let destination = model.mediaLibraryDestination
let libraryID: String?
switch destination {
case .all: libraryID = nil
case .library(let id): libraryID = id
default: return
}
isLoadingMoreCachedItems = true
defer { isLoadingMoreCachedItems = false }
let page = await model.cachedMediaLibraryPage(libraryID: libraryID, limit: cachedItemsPageSize, offset: cachedItemsOffset)
guard destination == model.mediaLibraryDestination else { return }
cachedItemsOffset += page.count
canLoadMoreCachedItems = page.count == cachedItemsPageSize
guard !page.isEmpty else { return }
var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
page.forEach { merged[$0.id] = $0 }
items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
if let libraryID { libraryVisualItems[libraryID] = items }
}
private func scrapeUnmatchedItem(_ item: MediaLibraryItem) {
guard !item.isMatched else { return }
Task {
_ = await model.batchRecognizeUnmatchedMediaItems([item], preferredLibraryID: selectedLibraryID)
await loadDestination()
if let refreshed = items.first(where: { $0.id == item.id }) { detailItem = refreshed }
}
}
@MainActor private func startScan(_ library: MediaLibraryDefinition) {
errorText = ""
_ = model.startMediaLibraryScan(library)
}
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) {
guard !item.file.isDirectory else {
model.lastActionMessage = "原盘已作为完整目录入库;请使用支持蓝光/DVD 目录的本地播放器打开该目录。"
return
}
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
PlayerWindowCoordinator.shared.open(model: model, file: preferred.file)
}
}
private struct MediaEpisodeEntry: Identifiable {
let item: MediaLibraryItem
let season: Int
let episode: Int?
var id: String { item.id }
}
private struct MediaItemDetailPage: View {
@ObservedObject var model: AppModel
let item: MediaLibraryItem
let items: [MediaLibraryItem]
let preferredLibraryID: String?
let onBack: () -> Void
let onPlay: (MediaLibraryItem) -> Void
let onScrape: (MediaLibraryItem) -> Void
let onRecognized: (MediaLibraryItem) -> Void
@State private var showManualRecognition = false
@State private var artworkAssets: [MediaArtworkAsset] = []
@State private var isLoadingArtwork = false
@State private var castMembers: [MediaCastMember] = []
@State private var isLoadingCast = false
@State private var selectedResourceID: String?
private var relatedResources: [MediaLibraryItem] {
guard let tmdbID = item.tmdbID, let kind = item.mediaKind else { return [item] }
let values = items.filter { $0.tmdbID == tmdbID && $0.mediaKind == kind }
return values.isEmpty ? [item] : values
}
private var selectedResource: MediaLibraryItem {
relatedResources.first(where: { $0.id == selectedResourceID }) ?? item
}
private var episodes: [MediaEpisodeEntry] {
guard item.mediaKind == .tv else { return [] }
return relatedResources.map { resource in
let parsed = model.parsedTMDBInfo(for: resource.file)
return MediaEpisodeEntry(item: resource, season: parsed.season ?? 1, episode: parsed.episode)
}
.sorted {
if $0.season != $1.season { return $0.season < $1.season }
if $0.episode != $1.episode { return ($0.episode ?? .max) < ($1.episode ?? .max) }
return $0.item.file.name.localizedStandardCompare($1.item.file.name) == .orderedAscending
}
}
private var seasons: [Int] { Array(Set(episodes.map(\.season))).sorted() }
private var posterAssets: [MediaArtworkAsset] { artworkAssets.filter { $0.label.contains("海报") } }
private var backdropAssets: [MediaArtworkAsset] { artworkAssets.filter { $0.label.contains("横幅") || $0.label.contains("同人画") } }
private var logoAssets: [MediaArtworkAsset] { artworkAssets.filter { $0.label.localizedCaseInsensitiveContains("logo") } }
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Button(action: onBack) { Image(systemName: "chevron.left") }
.buttonStyle(.bordered).help("返回海报墙")
Text(item.isMatched ? "影视详情" : "文件详情").font(.headline)
Spacer()
Button { Task { await model.share(item.file) } } label: { Image(systemName: "square.and.arrow.up") }
.buttonStyle(.bordered).help("分享")
}
.padding(.horizontal, 28).padding(.vertical, 14)
.background(.ultraThinMaterial)
ScrollView {
VStack(alignment: .leading, spacing: 28) {
detailHero
if item.isMatched && !item.overview.isEmpty { detailOverview }
if item.isMatched { artworkGallery }
if item.isMatched { castGallery }
if item.mediaKind == .tv && !episodes.isEmpty { episodeList }
else { resourceList }
fileInfo
}
.padding(.horizontal, 30).padding(.vertical, 26)
}
}
.sheet(isPresented: $showManualRecognition) {
ManualMediaRecognitionSheet(model: model, item: item, preferredLibraryID: preferredLibraryID) { updated in
showManualRecognition = false
onRecognized(updated)
}
}
.task(id: item.tmdbID) {
guard item.isMatched else { return }
isLoadingArtwork = true
artworkAssets = await model.mediaArtworkAssets(for: item)
isLoadingArtwork = false
isLoadingCast = true
castMembers = await model.mediaCastMembers(for: item)
isLoadingCast = false
}
}
private var detailHero: some View {
ZStack(alignment: .bottomLeading) {
MediaArtwork(data: item.backdropData, icon: "film.stack", contentMode: .fill)
.frame(maxWidth: .infinity).frame(height: 310).clipped()
.overlay {
LinearGradient(colors: [.black.opacity(0.12), .black.opacity(0.82)], startPoint: .top, endPoint: .bottom)
}
HStack(alignment: .bottom, spacing: 20) {
MediaArtwork(data: item.posterData, icon: item.file.isDirectory ? "opticaldisc.fill" : "film.fill", contentMode: .fill)
.frame(width: 146, height: 219).clipShape(RoundedRectangle(cornerRadius: 6))
.shadow(color: .black.opacity(0.35), radius: 12, y: 5)
VStack(alignment: .leading, spacing: 11) {
Text(item.title).font(.system(size: 30, weight: .bold)).foregroundStyle(.white).lineLimit(2)
if item.isMatched, !item.originalTitle.isEmpty, item.originalTitle != item.title {
Text(item.originalTitle).font(.callout).foregroundStyle(.white.opacity(0.75))
}
HStack(spacing: 7) {
if !item.year.isEmpty { detailTag(item.year) }
if let kind = item.mediaKind { detailTag(kind == .movie ? "电影" : "剧集") }
if item.mediaKind == .tv { detailTag("\(episodes.count) 集") }
if item.hasChineseAudio { detailTag("中文音轨") }
if item.hasChineseSubtitle { detailTag("中文字幕") }
}
HStack(spacing: 9) {
if !selectedResource.file.isDirectory {
Menu {
if AppModel.installedPlayers.isEmpty {
Button { onPlay(selectedResource) } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(selectedResource.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
}
}
} label: { Label("播放", systemImage: "play.fill") }
.buttonStyle(.borderedProminent)
}
Button { Task { await model.download(selectedResource.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
.buttonStyle(.bordered).tint(.white)
if !item.isMatched {
Button { onScrape(selectedResource) } label: { Label("刮削", systemImage: "wand.and.stars") }
.buttonStyle(.borderedProminent)
.disabled(model.isBatchRecognizingMedia)
}
Button("手动识别") { showManualRecognition = true }.buttonStyle(.bordered).tint(.white)
}
}
Spacer(minLength: 0)
}
.padding(24)
}
.clipShape(RoundedRectangle(cornerRadius: 7))
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.13), lineWidth: 1) }
.overlay(alignment: .bottomTrailing) {
if let collection = item.collectionName, !collection.isEmpty {
Label(collection, systemImage: "rectangle.stack").font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.82)).padding(16)
}
}
}
private var detailOverview: some View {
Text(item.overview)
.font(.callout)
.foregroundStyle(.secondary)
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 7))
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.10), lineWidth: 1) }
}
private var episodeList: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("剧集").font(.title3.weight(.bold))
Text("\(episodes.count) 集").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Spacer()
}
ForEach(seasons, id: \.self) { season in
VStack(alignment: .leading, spacing: 6) {
Text("第 \(season) 季").font(.headline)
ForEach(episodes.filter { $0.season == season }) { entry in
Button { onPlay(entry.item) } label: {
HStack(spacing: 12) {
Text(entry.episode.map { String(format: "E%02d", $0) } ?? "文件")
.font(.callout.monospacedDigit().weight(.semibold)).frame(width: 54, alignment: .leading)
VStack(alignment: .leading, spacing: 2) {
Text(entry.item.file.name).font(.callout).lineLimit(1)
Text("\(entry.item.file.formattedSize) · \(entry.item.file.modifiedAt.isEmpty ? "未知时间" : entry.item.file.modifiedAt)")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
versionTag(for: entry.item)
if entry.item.hasChineseAudio { Image(systemName: "waveform").foregroundStyle(.secondary).help("中文音轨") }
if entry.item.hasChineseSubtitle { Image(systemName: "captions.bubble").foregroundStyle(.secondary).help("中文字幕") }
Image(systemName: "play.circle.fill").foregroundStyle(.blue)
}
.padding(.horizontal, 14).padding(.vertical, 11).contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6))
.contextMenu {
Button { onPlay(entry.item) } label: { Label("播放", systemImage: "play.fill") }
Divider()
if AppModel.installedPlayers.isEmpty {
Button { Task { await model.playWithExternalPlayer(entry.item.file) } } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(entry.item.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
}
}
Divider()
Button { Task { await model.download(entry.item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
}
}
}
.padding(.top, 4)
}
}
}
@ViewBuilder private var artworkGallery: some View {
if isLoadingArtwork && artworkAssets.isEmpty {
HStack(spacing: 10) { ProgressView().controlSize(.small); Text("正在加载图片素材").font(.callout).foregroundStyle(.secondary) }
} else if !artworkAssets.isEmpty {
VStack(alignment: .leading, spacing: 24) {
if !posterAssets.isEmpty {
artworkHeading("海报", count: posterAssets.count)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(posterAssets) { asset in
artworkPoster(asset)
}
}
}
}
if !backdropAssets.isEmpty {
artworkHeading("横幅与同人画", count: backdropAssets.count)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 230, maximum: 340), spacing: 12)], spacing: 12) {
ForEach(backdropAssets) { asset in artworkBackdrop(asset) }
}
}
if !logoAssets.isEmpty {
artworkHeading("Logo", count: logoAssets.count)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(logoAssets) { asset in artworkLogo(asset) }
}
}
}
}
}
}
private func artworkHeading(_ title: String, count: Int) -> some View {
HStack {
Text(title).font(.title3.weight(.bold))
Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Spacer()
}
}
private func artworkPoster(_ asset: MediaArtworkAsset) -> some View {
ZStack(alignment: .bottomLeading) {
MediaArtwork(data: asset.data, icon: "photo", contentMode: .fill)
.frame(width: 126, height: 189).clipped()
Text(asset.label).font(.caption2.weight(.medium)).foregroundStyle(.white)
.padding(.horizontal, 6).padding(.vertical, 3).background(.black.opacity(0.66), in: RoundedRectangle(cornerRadius: 4)).padding(6)
}
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.10), lineWidth: 1) }
}
private func artworkBackdrop(_ asset: MediaArtworkAsset) -> some View {
ZStack(alignment: .bottomLeading) {
MediaArtwork(data: asset.data, icon: "photo", contentMode: .fill)
.aspectRatio(16 / 9, contentMode: .fit).frame(maxWidth: .infinity).clipped()
Text(asset.label).font(.caption2.weight(.medium)).foregroundStyle(.white)
.padding(.horizontal, 6).padding(.vertical, 3).background(.black.opacity(0.66), in: RoundedRectangle(cornerRadius: 4)).padding(6)
}
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.10), lineWidth: 1) }
}
private func artworkLogo(_ asset: MediaArtworkAsset) -> some View {
MediaArtwork(data: asset.data, icon: "textformat", contentMode: .fit)
.frame(width: 190, height: 92)
.padding(10)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6))
.overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.10), lineWidth: 1) }
}
@ViewBuilder private var castGallery: some View {
if isLoadingCast && castMembers.isEmpty {
HStack(spacing: 10) { ProgressView().controlSize(.small); Text("正在同步演员素材").font(.callout).foregroundStyle(.secondary) }
} else if !castMembers.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("演员").font(.title3.weight(.bold))
Text("\(castMembers.count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Spacer()
}
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(castMembers) { member in
VStack(alignment: .leading, spacing: 6) {
MediaArtwork(data: member.imageData, icon: "person.fill", contentMode: .fill)
.frame(width: 96, height: 122).clipShape(RoundedRectangle(cornerRadius: 6))
Text(member.name).font(.caption.weight(.semibold)).lineLimit(1).frame(width: 96, alignment: .leading)
if !member.role.isEmpty { Text(member.role).font(.caption2).foregroundStyle(.secondary).lineLimit(1).frame(width: 96, alignment: .leading) }
}
}
}
}
}
}
}
private var resourceList: some View {
VStack(alignment: .leading, spacing: 12) {
Text(relatedResources.count > 1 ? "资源版本" : "媒体资源").font(.title3.weight(.bold))
ForEach(relatedResources) { resource in
HStack(spacing: 12) {
Image(systemName: resource.file.isDirectory ? "opticaldisc.fill" : "play.rectangle.fill").foregroundStyle(.blue).frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(resource.file.name).font(.callout).lineLimit(1)
Text("\(resource.file.formattedSize) · \(resource.file.modifiedAt.isEmpty ? "未知时间" : resource.file.modifiedAt)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
versionTag(for: resource)
if !resource.file.isDirectory {
Menu {
if AppModel.installedPlayers.isEmpty {
Button { onPlay(resource) } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(resource.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
}
}
} label: { Image(systemName: "play.fill") }.buttonStyle(.bordered).help("播放")
}
}
.padding(.horizontal, 14).padding(.vertical, 11)
.contentShape(Rectangle())
.onTapGesture { selectedResourceID = resource.id }
.background(resource.id == selectedResource.id ? Color.blue.opacity(0.12) : Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6))
.overlay { RoundedRectangle(cornerRadius: 6).stroke(resource.id == selectedResource.id ? Color.blue.opacity(0.46) : .clear, lineWidth: 1) }
.accessibilityAddTraits(resource.id == selectedResource.id ? .isSelected : [])
}
}
}
private func versionTag(for resource: MediaLibraryItem) -> some View {
let parsed = model.parsedTMDBInfo(for: resource.file)
let resolution: String
if resource.file.isDirectory || parsed.isDiscStructure { resolution = "原盘" }
else {
switch parsed.resolution?.lowercased() {
case "2160p", "4k": resolution = "4K"
case "1080p", "1080i": resolution = "1080P"
case "720p": resolution = "720P"
case "480p": resolution = "480P"
default: resolution = "未知"
}
}
let details = [resolution, parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ")
return Text(details).font(.caption.monospacedDigit().weight(.medium)).foregroundStyle(.secondary)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color.blue.opacity(0.10), in: RoundedRectangle(cornerRadius: 5))
.help(details)
}
private var fileInfo: some View {
VStack(alignment: .leading, spacing: 11) {
HStack {
Text("文件信息").font(.title3.weight(.bold))
if relatedResources.count > 1 { Text("当前版本").font(.caption.weight(.medium)).foregroundStyle(.blue) }
Spacer()
}
detailRow("名称", selectedResource.file.name)
detailRow("路径", selectedResource.file.cloudPath.isEmpty ? "--" : selectedResource.file.cloudPath, selectable: true)
detailRow("类型", selectedResource.file.isDirectory ? "原盘目录" : selectedResource.file.typeName)
detailRow("大小", selectedResource.file.formattedSize)
detailRow("修改时间", selectedResource.file.modifiedAt.isEmpty ? "--" : selectedResource.file.modifiedAt)
}
}
private func detailTag(_ value: String) -> some View {
Text(value).font(.caption.weight(.medium)).padding(.horizontal, 8).padding(.vertical, 4)
.foregroundStyle(.white).background(.black.opacity(0.40), in: RoundedRectangle(cornerRadius: 5))
}
private func detailRow(_ label: String, _ value: String, selectable: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 18) {
Text(label).foregroundStyle(.secondary).frame(width: 68, alignment: .leading)
if selectable { Text(value).fixedSize(horizontal: false, vertical: true).textSelection(.enabled) }
else { Text(value).fixedSize(horizontal: false, vertical: true) }
Spacer(minLength: 0)
}
.font(.callout)
}
}
private struct ManualMediaRecognitionSheet: View {
@ObservedObject var model: AppModel
let item: MediaLibraryItem
let preferredLibraryID: String?
let onRecognized: (MediaLibraryItem) -> Void
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var mediaKind: TMDBMediaKind = .automatic
@State private var year = ""
@State private var episodeLabel = ""
@State private var candidates: [TMDBCandidate] = []
@State private var isSearching = false
@State private var isApplying = false
@State private var errorText = ""
private var parsedInfo: ParsedMediaName { model.parsedTMDBInfo(for: item) }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 13) {
Image(systemName: "sparkle.magnifyingglass")
.font(.title2.weight(.semibold)).foregroundStyle(.blue)
.frame(width: 44, height: 44)
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 3) {
Text("手动识别").font(.title2.weight(.bold))
Text("修改检索条件后选择正确的 TMDB 条目。路径和原文件不会被改写。")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭")
}
sourceSummary
HStack(alignment: .bottom, spacing: 10) {
VStack(alignment: .leading, spacing: 5) {
Text("用于刮削的标题").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
TextField("影视名称", text: $query).textFieldStyle(.roundedBorder).onSubmit(search)
}
Button(action: search) { Image(systemName: "magnifyingglass") }
.buttonStyle(.bordered).disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
.help("搜索 TMDB")
}
HStack(spacing: 12) {
Picker("类型", selection: $mediaKind) {
ForEach(TMDBMediaKind.allCases) { kind in Text(kind.title).tag(kind) }
}
.pickerStyle(.segmented).labelsHidden().frame(width: 240)
TextField("年份", text: $year).textFieldStyle(.roundedBorder).frame(width: 88)
if !episodeLabel.isEmpty {
Label(episodeLabel, systemImage: "list.number").font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
if isSearching {
ProgressView("正在搜索 TMDB…").frame(maxWidth: .infinity, minHeight: 210)
} else if !errorText.isEmpty {
ContentUnavailableView("搜索失败", systemImage: "exclamationmark.triangle", description: Text(errorText)).frame(maxWidth: .infinity, minHeight: 210)
} else if candidates.isEmpty {
ContentUnavailableView("未找到候选项", systemImage: "magnifyingglass", description: Text("可修改标题后再次搜索。"))
.frame(maxWidth: .infinity, minHeight: 210)
} else {
List(candidates) { candidate in
Button {
apply(candidate)
} label: {
HStack(alignment: .top, spacing: 12) {
Image(systemName: candidate.mediaType == .movie ? "film" : "play.tv")
.frame(width: 28, height: 34).foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 3) {
Text(candidate.title).font(.headline)
Text("\(candidate.mediaType == .movie ? "电影" : "剧集") · \(String(candidate.releaseDate.prefix(4)).isEmpty ? "年份未知" : String(candidate.releaseDate.prefix(4))) · TMDB \(candidate.id)")
.font(.caption).foregroundStyle(.secondary)
if !candidate.overview.isEmpty { Text(candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) }
}
Spacer()
if isApplying { ProgressView().controlSize(.small) }
}
}
.buttonStyle(.plain).disabled(isApplying)
}
.listStyle(.inset)
}
}
.padding(24).frame(width: 720, height: 640)
.task {
let parsed = parsedInfo
query = item.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? parsed.title : item.title
year = parsed.year.map(String.init) ?? ""
mediaKind = parsed.isEpisode ? .tv : .automatic
if let season = parsed.season, let episode = parsed.episode {
episodeLabel = String(format: "S%02dE%02d", season, episode)
}
search()
}
}
private var sourceSummary: some View {
VStack(alignment: .leading, spacing: 7) {
HStack {
Text("来源与解析").font(.callout.weight(.semibold))
Spacer()
if let resolution = parsedInfo.resolution { Text(resolution.uppercased()).font(.caption.monospacedDigit().weight(.medium)).foregroundStyle(.blue) }
if parsedInfo.isEpisode, let season = parsedInfo.season, let episode = parsedInfo.episode { Text(String(format: "S%02dE%02d", season, episode)).font(.caption.monospacedDigit().weight(.medium)).foregroundStyle(.blue) }
}
recognitionSummaryRow("文件名", item.file.name)
recognitionSummaryRow("路径", item.file.cloudPath.isEmpty ? "--" : item.file.cloudPath)
recognitionSummaryRow("已解析", "标题:\(parsedInfo.title)\(parsedInfo.year.map { " · 年份:\($0)" } ?? "")\(parsedInfo.source.map { " · 来源:\($0)" } ?? "")")
}
.padding(12)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8))
.overlay { RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.10), lineWidth: 1) }
}
private func recognitionSummaryRow(_ label: String, _ value: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(label).font(.caption).foregroundStyle(.tertiary).frame(width: 42, alignment: .leading)
Text(value).font(.caption).lineLimit(label == "路径" ? 2 : 1).textSelection(.enabled)
Spacer(minLength: 0)
}
}
private func search() {
let value = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
Task {
isSearching = true; errorText = ""; candidates = []
defer { isSearching = false }
do { candidates = try await model.manualMediaCandidates(query: value, mediaKind: mediaKind, year: Int(year)) }
catch { errorText = error.localizedDescription }
}
}
private func apply(_ candidate: TMDBCandidate) {
Task {
isApplying = true; errorText = ""
defer { isApplying = false }
do { onRecognized(try await model.applyManualMediaCandidate(candidate, to: item, preferredLibraryID: preferredLibraryID)) }
catch { errorText = error.localizedDescription }
}
}
}
private struct MediaTMDBSettingsSheet: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var key = ""
@State private var proxyHost = ""
@State private var proxyPort = ""
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("TMDB 识别配置").font(.title2.weight(.bold))
Text("用于影视库的自动识别、详情和海报下载。仅保存在当前 Mac。")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭")
}
SecureField("TMDB API Key", text: $key).textFieldStyle(.roundedBorder)
HStack {
TextField("代理地址(可选)", text: $proxyHost).textFieldStyle(.roundedBorder)
TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 100)
}
VStack(alignment: .leading, spacing: 7) {
HStack {
Label("刮削并发", systemImage: "arrow.triangle.branch")
Spacer()
Text("\(model.mediaScrapeConcurrency)")
.font(.callout.monospacedDigit().weight(.semibold))
.foregroundStyle(AppTheme.orange)
}
Stepper(value: $model.mediaScrapeConcurrency, in: 3...20) {
Text("同时执行 320 个 TMDB 识别任务")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding(12)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Toggle(isOn: $model.isMediaWritebackEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("识别后回写云盘")
Text("关闭时仅保存到本机影视库;开启后会整理目录并上传刮削信息。")
.font(.caption).foregroundStyle(.secondary)
}
}
.toggleStyle(.switch)
.padding(12)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
HStack {
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button("保存") {
model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort)
dismiss()
}
.buttonStyle(.borderedProminent).tint(.blue).keyboardShortcut(.defaultAction)
}
}
.padding(24).frame(width: 500)
.onAppear {
key = model.tmdbAPIKey
proxyHost = model.tmdbProxyHost
proxyPort = model.tmdbProxyPort
}
}
}
private struct CreateMediaLibrarySheet: View {
@ObservedObject var model: AppModel
let initialLibrary: MediaLibraryDefinition?
let onSave: (String, [MediaLibrarySource], MediaLibraryKind, Bool, Int) -> 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 minimumSizeMB: Int
@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, Int) -> 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)
_minimumSizeMB = State(initialValue: initialLibrary?.minimumSizeMB ?? 50)
}
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) }
}
Stepper(value: $minimumSizeMB, in: 0...50_000, step: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("忽略小文件").font(.callout.weight(.semibold))
Text(minimumSizeMB == 0 ? "不按文件大小过滤" : "小于 \(minimumSizeMB) MB 的视频不会进入自动刮削")
.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, minimumSizeMB); 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: "添加此目录", allowsFilteringAndSorting: true) { 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 MediaLibraryManagementRow: View {
let library: MediaLibraryDefinition
let itemCount: Int
let onOpen: () -> Void
let onScan: () -> Void
let onEdit: () -> Void
let onDelete: () -> Void
@State private var isHovered = false
private var updatedText: String {
guard let date = library.updatedAt else { return "尚未扫描" }
return date.formatted(.dateTime.year().month().day())
}
var body: some View {
HStack(spacing: 0) {
Button(action: onOpen) {
HStack(spacing: 13) {
ZStack {
RoundedRectangle(cornerRadius: 7).fill(Color.blue.opacity(0.12))
Image(systemName: library.kind.icon).font(.title3.weight(.semibold)).foregroundStyle(.blue)
}
.frame(width: 74, height: 74)
VStack(alignment: .leading, spacing: 5) {
Text(library.name).font(.callout.weight(.semibold)).lineLimit(1)
Text("\(itemCount) 个媒体条目 · \(library.recursive ? "递归扫描" : "仅当前目录")")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
.frame(width: 255, alignment: .leading)
}
.buttonStyle(.plain)
Text(library.sources.map(\.path).joined(separator: " · "))
.font(.callout).foregroundStyle(.secondary).lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
Text(library.kind.title).font(.callout).frame(width: 100, alignment: .leading)
Text(updatedText).font(.callout).foregroundStyle(.secondary).frame(width: 130, alignment: .leading)
HStack(spacing: 12) {
Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }.help("删除媒体库")
Button(action: onEdit) { Image(systemName: "pencil") }.help("编辑媒体库")
Button(action: onScan) { Image(systemName: "arrow.triangle.2.circlepath") }.help("扫描媒体库文件")
Menu { Button("打开媒体库", action: onOpen); Button("扫描媒体库", action: onScan); Button("编辑媒体库", action: onEdit) } label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton)
}
.buttonStyle(.borderless).foregroundStyle(.secondary)
.frame(width: 180, alignment: .trailing)
}
.padding(.horizontal, 30).frame(minHeight: 116)
.background(isHovered ? Color.primary.opacity(0.035) : .clear)
.onHover { isHovered = $0 }
}
}
private struct MediaLibraryTaskCard: View {
let task: MediaLibraryTask
let isExpanded: Bool
let onToggle: () -> Void
let onCancel: () -> Void
let onResume: () -> Void
let onRemove: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var didCopyAllLogs = false
@State private var copiedLogID: UUID?
private var tint: Color {
switch task.state {
case .queued: return .secondary
case .scanning: return .blue
case .writing: return .orange
case .completed: return .green
case .cancelled: return .secondary
case .failed: return .red
}
}
private var logHeight: CGFloat {
let screenHeight = NSScreen.main?.visibleFrame.height ?? 800
// Header and progress consume about 170pt; keep the whole expanded card
// within roughly half of the visible display by constraining the log pane.
return max(120, min(220, screenHeight * 0.5 - 175))
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
Image(systemName: task.state.icon).foregroundStyle(tint).frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(task.libraryName).font(.headline.weight(.semibold))
Text(task.progress.phase.isEmpty ? task.state.title : task.progress.phase).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(task.state.title).font(.caption.weight(.semibold)).foregroundStyle(tint)
if task.isActive {
Button(role: .destructive, action: onCancel) { Image(systemName: "stop.fill") }
.buttonStyle(.bordered).controlSize(.small).help("停止此任务")
} else if task.state == .cancelled || task.state == .failed {
Button(action: onResume) {
Label(task.state == .cancelled ? "继续" : "重试", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered).controlSize(.small)
.help(task.state == .cancelled ? "继续未完成的识别和写回" : "重新执行失败的任务")
}
if !task.isActive {
Button(role: .destructive, action: onRemove) { Image(systemName: "trash") }
.buttonStyle(.bordered).controlSize(.small).help("清理此任务记录")
}
Button(action: onToggle) { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") }
.buttonStyle(.bordered).controlSize(.small).help(isExpanded ? "收起详情" : "查看详情与日志")
}
HStack(spacing: 10) {
ProgressView(value: Double(task.progress.completed), total: Double(max(1, task.progress.total)))
.tint(tint)
Text("\(task.progress.completed)/\(task.progress.total)")
.font(.caption.monospacedDigit().weight(.medium))
.foregroundStyle(.secondary)
.frame(minWidth: 54, alignment: .trailing)
}
HStack(spacing: 8) {
MediaTaskStage(title: "扫描 / TMDB 识别", value: task.currentScrapingName ?? "等待媒体文件", icon: "magnifyingglass")
MediaTaskStage(title: "本机入库", value: task.currentStoringName ?? "等待入库", icon: "tray.and.arrow.down")
MediaTaskStage(title: "资源目录写回", value: task.currentWritingName ?? "等待写回", icon: "square.and.arrow.down")
}
if isExpanded {
Divider()
if let failure = task.failureReason, !failure.isEmpty {
Label(failure, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.yellow)
}
VStack(alignment: .leading, spacing: 7) {
HStack {
Text("任务日志").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Button {
copyAllLogs()
didCopyAllLogs = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { didCopyAllLogs = false }
} label: {
Image(systemName: didCopyAllLogs ? "checkmark" : "doc.on.doc")
}
.buttonStyle(.bordered).controlSize(.small)
.help(didCopyAllLogs ? "已复制全部日志" : "复制全部日志")
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 7) {
ForEach(task.logs.reversed()) { entry in
HStack(alignment: .top, spacing: 9) {
Text(entry.date, format: .dateTime.hour().minute().second()).font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
Text(entry.message).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
Spacer(minLength: 4)
Button {
copyLog(entry)
copiedLogID = entry.id
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
if copiedLogID == entry.id { copiedLogID = nil }
}
} label: {
Image(systemName: copiedLogID == entry.id ? "checkmark" : "doc.on.doc")
}
.buttonStyle(.plain).foregroundStyle(.secondary)
.help(copiedLogID == entry.id ? "已复制" : "复制此行日志")
}
}
}
}
.frame(height: logHeight)
.padding(.trailing, 4)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
.background(Color.primary.opacity(colorScheme == .dark ? 0.07 : 0.035), in: RoundedRectangle(cornerRadius: 7))
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.09), lineWidth: 1) }
}
private func copyAllLogs() {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
let text = task.logs.map { "\(formatter.string(from: $0.date)) \($0.message)" }.joined(separator: "\n")
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
private func copyLog(_ entry: MediaLibraryTaskLog) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(formatter.string(from: entry.date)) \(entry.message)", forType: .string)
}
}
private struct MediaCategoryManagementPage: View {
@ObservedObject var model: AppModel
@State private var editingRule: MediaCategoryRule?
@State private var isCreating = false
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text("影视分类").font(.title2.weight(.bold))
Text("仅按 TMDB 原始语言匹配分类;刮削信息仍优先使用中文。").font(.callout).foregroundStyle(.secondary)
}
Spacer()
Button { model.resetMediaCategoryRules() } label: { Label("恢复预设", systemImage: "arrow.counterclockwise") }
.buttonStyle(.bordered)
Button { isCreating = true } label: { Label("新增分类", systemImage: "plus") }
.buttonStyle(.borderedProminent).tint(.blue)
}
.padding(.horizontal, 30).padding(.vertical, 20)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
categorySection(title: "电影分类", kind: .movie, icon: "film")
categorySection(title: "剧集分类", kind: .tv, icon: "tv")
}
.padding(30).frame(maxWidth: 1040, alignment: .leading)
}
}
.sheet(isPresented: $isCreating) {
MediaCategoryRuleEditorSheet { rule in
model.saveMediaCategoryRules(model.mediaCategoryRules + [rule])
}
}
.sheet(item: $editingRule) { rule in
MediaCategoryRuleEditorSheet(rule: rule) { updated in
var rules = model.mediaCategoryRules
if let index = rules.firstIndex(where: { $0.id == updated.id }) { rules[index] = updated }
model.saveMediaCategoryRules(rules)
}
}
}
private func categorySection(title: String, kind: TMDBMediaKind, icon: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon).font(.headline.weight(.semibold))
VStack(spacing: 0) {
ForEach(model.mediaCategoryRules.filter { $0.mediaKind == kind }) { rule in
categoryRow(rule)
if rule.id != model.mediaCategoryRules.filter({ $0.mediaKind == kind }).last?.id { Divider().padding(.leading, 16) }
}
}
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 7))
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.09), lineWidth: 1) }
}
}
private func categoryRow(_ rule: MediaCategoryRule) -> some View {
HStack(spacing: 14) {
Image(systemName: rule.mediaKind == .movie ? "film" : "tv").foregroundStyle(.blue).frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name).font(.callout.weight(.semibold))
Text(ruleDetail(rule)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if rule.isFallback { Text("默认").font(.caption.weight(.medium)).foregroundStyle(.secondary) }
Button { move(rule, by: -1) } label: { Image(systemName: "arrow.up") }
.buttonStyle(.borderless).help("上移匹配优先级").disabled(firstIndex(of: rule) == 0)
Button { move(rule, by: 1) } label: { Image(systemName: "arrow.down") }
.buttonStyle(.borderless).help("下移匹配优先级").disabled(firstIndex(of: rule) == model.mediaCategoryRules.count - 1)
Button { editingRule = rule } label: { Image(systemName: "pencil") }
.buttonStyle(.borderless).help("编辑分类")
Button(role: .destructive) { remove(rule) } label: { Image(systemName: "trash") }
.buttonStyle(.borderless).help("删除分类").disabled(rule.isFallback)
}
.padding(.horizontal, 16).frame(minHeight: 58)
}
private func ruleDetail(_ rule: MediaCategoryRule) -> String {
rule.languages.isEmpty ? "未设置原始语言" : "语言 \(rule.languages.joined(separator: ", "))"
}
private func firstIndex(of rule: MediaCategoryRule) -> Int { model.mediaCategoryRules.firstIndex(of: rule) ?? 0 }
private func move(_ rule: MediaCategoryRule, by offset: Int) {
var rules = model.mediaCategoryRules
guard let index = rules.firstIndex(of: rule) else { return }
let target = index + offset
guard rules.indices.contains(target), rules[target].mediaKind == rule.mediaKind else { return }
rules.swapAt(index, target)
model.saveMediaCategoryRules(rules)
}
private func remove(_ rule: MediaCategoryRule) {
model.saveMediaCategoryRules(model.mediaCategoryRules.filter { $0.id != rule.id })
}
}
private struct MediaCategoryRuleEditorSheet: View {
let rule: MediaCategoryRule?
let onSave: (MediaCategoryRule) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var mediaKind: TMDBMediaKind
@State private var languageText: String
@State private var isFallback: Bool
init(rule: MediaCategoryRule? = nil, onSave: @escaping (MediaCategoryRule) -> Void) {
self.rule = rule
self.onSave = onSave
_name = State(initialValue: rule?.name ?? "")
_mediaKind = State(initialValue: rule?.mediaKind ?? .movie)
_languageText = State(initialValue: rule?.languages.joined(separator: ", ") ?? "")
_isFallback = State(initialValue: rule?.isFallback ?? false)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text(rule == nil ? "新增分类" : "编辑分类").font(.title2.weight(.bold))
TextField("分类名称", text: $name).textFieldStyle(.roundedBorder)
Picker("媒体类型", selection: $mediaKind) {
Text("电影").tag(TMDBMediaKind.movie)
Text("剧集").tag(TMDBMediaKind.tv)
}
.pickerStyle(.segmented)
VStack(alignment: .leading, spacing: 6) {
Text("原始语言").font(.callout.weight(.semibold))
TextField("例如:zh, en, ja, ko", text: $languageText).textFieldStyle(.roundedBorder)
Text("分类目录仅根据此字段匹配,多个语言请用逗号分隔。")
.font(.caption).foregroundStyle(.secondary)
}
Toggle("默认分类", isOn: $isFallback)
HStack {
Spacer()
Button("取消") { dismiss() }
Button("保存") { save() }.buttonStyle(.borderedProminent).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24).frame(width: 460)
}
private func save() {
let languages = languageText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }.filter { !$0.isEmpty }
onSave(MediaCategoryRule(id: rule?.id ?? UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), mediaKind: mediaKind, genreIDs: [], languages: languages, isFallback: isFallback))
dismiss()
}
}
private struct MediaTaskStage: View {
let title: String
let value: String
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Label(title, systemImage: icon).font(.caption2).foregroundStyle(.tertiary)
Text(value).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 6))
}
}
private struct MediaLibraryShortcutCard: View {
let library: MediaLibraryDefinition
let itemCount: Int
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
private var accent: Color {
switch library.kind {
case .movies: return .blue
case .series: return .pink
case .mixed: return .orange
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 13) {
Image(systemName: library.kind.icon)
.font(.title2.weight(.semibold))
.foregroundStyle(accent)
.frame(width: 44, height: 44)
.background(accent.opacity(colorScheme == .dark ? 0.18 : 0.12), in: RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 4) {
Text(library.name).font(.callout.weight(.semibold)).lineLimit(1)
Text("\(library.kind.title) · \(itemCount) 个条目").font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer(minLength: 4)
Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary)
}
.padding(14)
.frame(width: 238, height: 76)
.background(Color.primary.opacity(colorScheme == .dark ? 0.08 : 0.045), in: RoundedRectangle(cornerRadius: 7))
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.10), lineWidth: 1) }
}
.buttonStyle(.plain)
}
}
private struct MediaLibraryVisualCard: View {
let library: MediaLibraryDefinition
let items: [MediaLibraryItem]
let itemCount: Int
let action: () -> Void
@State private var isHovered = false
private var posterItems: [MediaLibraryItem] { Array(items.prefix(4)) }
var body: some View {
Button(action: action) {
VStack(spacing: 0) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color.primary.opacity(0.06))
if posterItems.isEmpty {
VStack(spacing: 8) {
Image(systemName: library.kind.icon).font(.title2.weight(.medium))
Text("等待扫描").font(.caption)
}
.foregroundStyle(.secondary)
} else {
HStack(spacing: 2) {
ForEach(Array(posterItems.enumerated()), id: \.element.id) { _, item in
MediaArtwork(data: item.posterData, icon: "film.fill", contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
}
if posterItems.count < 4 {
ForEach(0..<(4 - posterItems.count), id: \.self) { _ in
Color.primary.opacity(0.05)
}
}
}
}
LinearGradient(colors: [.clear, .black.opacity(0.58)], startPoint: .center, endPoint: .bottom)
}
.frame(width: 222, height: 126)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
HStack(spacing: 8) {
Image(systemName: library.kind.icon).foregroundStyle(.blue).font(.caption.weight(.semibold))
Text(library.name).font(.callout.weight(.semibold)).lineLimit(1)
Spacer(minLength: 0)
Text("\(itemCount)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
.padding(.horizontal, 10).frame(width: 222, height: 38)
}
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(isHovered ? 0.28 : 0.10), lineWidth: 1)
}
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.accessibilityLabel("打开媒体库 \(library.name),共 \(itemCount) 项")
}
}
private struct MediaLandscapeCard: View {
let item: MediaLibraryItem
let onOpen: () -> Void
let onPlay: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onOpen) {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomLeading) {
MediaArtwork(data: item.backdropData ?? item.posterData, icon: "film.fill", contentMode: .fill)
.frame(width: 244, height: 137)
.clipped()
LinearGradient(colors: [.clear, .black.opacity(0.70)], startPoint: .center, endPoint: .bottom)
VStack(alignment: .leading, spacing: 2) {
Text(item.title).font(.callout.weight(.semibold)).foregroundStyle(.white).lineLimit(1)
Text(item.year.isEmpty ? (item.mediaKind == .tv ? "电视剧" : "电影") : item.year)
.font(.caption).foregroundStyle(.white.opacity(0.74))
}
.padding(10)
if isHovered && !item.file.isDirectory {
Image(systemName: "play.fill")
.font(.headline).foregroundStyle(.black)
.frame(width: 36, height: 36)
.background(.white, in: Circle())
.padding(10)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.primary.opacity(isHovered ? 0.30 : 0.10), lineWidth: 1)
}
}
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.simultaneousGesture(TapGesture(count: 2).onEnded(onPlay))
.accessibilityLabel("打开 \(item.title)")
}
}
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 && !item.file.isDirectory { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) }
else if item.file.isDirectory { Text("原盘").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) }
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.blue : 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 MediaTheaterBackdrop: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: colorScheme == .dark
? [Color(red: 0.045, green: 0.047, blue: 0.052), Color(red: 0.065, green: 0.067, blue: 0.072)]
: [Color(red: 0.955, green: 0.96, blue: 0.97), Color(red: 0.91, green: 0.925, blue: 0.945)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
}
}
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))
}
}
}
}
}