0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
2450 lines
130 KiB
Swift
2450 lines
130 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
|
||
case all = "全部"
|
||
case movies = "电影"
|
||
case series = "剧集"
|
||
case collections = "合集"
|
||
case unmatched = "未识别"
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
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("同时执行 3–20 个 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))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|