323 lines
18 KiB
Swift
323 lines
18 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
|
|
case all = "全部"
|
|
case movies = "电影"
|
|
case series = "剧集"
|
|
case unmatched = "未识别"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
struct MediaLibraryPage: View {
|
|
@ObservedObject var model: AppModel
|
|
@State private var selectedLibraryID: String?
|
|
@State private var items: [MediaLibraryItem] = []
|
|
@State private var selectedItemID: String?
|
|
@State private var filter: MediaLibraryFilter = .all
|
|
@State private var searchText = ""
|
|
@State private var isLoadingCache = false
|
|
@State private var errorText = ""
|
|
@State private var showCreateLibrary = false
|
|
@State private var showDeleteConfirmation = false
|
|
|
|
private var selectedLibrary: MediaLibraryDefinition? {
|
|
model.mediaLibraries.first { $0.id == selectedLibraryID }
|
|
}
|
|
|
|
private var visibleItems: [MediaLibraryItem] {
|
|
let filtered = items.filter { item in
|
|
let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText)
|
|
let matchesFilter: Bool
|
|
switch filter {
|
|
case .all: matchesFilter = true
|
|
case .movies: matchesFilter = item.mediaKind == .movie
|
|
case .series: matchesFilter = item.mediaKind == .tv
|
|
case .unmatched: matchesFilter = !item.isMatched
|
|
}
|
|
return matchesSearch && matchesFilter
|
|
}
|
|
var seen = Set<String>()
|
|
return filtered.filter { item in
|
|
let key = item.mediaKind == .tv && item.tmdbID != nil ? "tv-\(item.tmdbID!)" : item.id
|
|
return seen.insert(key).inserted
|
|
}
|
|
}
|
|
|
|
private var featuredItem: MediaLibraryItem? {
|
|
selectedItemID.flatMap { id in items.first { $0.id == id } }
|
|
?? visibleItems.first(where: { $0.backdropData != nil })
|
|
?? visibleItems.first
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color(red: 0.055, green: 0.06, blue: 0.06).ignoresSafeArea()
|
|
if model.mediaLibraries.isEmpty && !isLoadingCache {
|
|
emptyLibraryState
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
if let featuredItem { featuredHero(featuredItem) }
|
|
libraryControls
|
|
libraryContent
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
.sheet(isPresented: $showCreateLibrary) {
|
|
CreateMediaLibrarySheet(model: model) { name, rootID, rootPath, kind, recursive in
|
|
Task {
|
|
let library = await model.createMediaLibrary(name: name, rootID: rootID, rootPath: rootPath, kind: kind, recursive: recursive)
|
|
selectedLibraryID = library.id
|
|
await scan(library)
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog("删除影视库?", isPresented: $showDeleteConfirmation) {
|
|
Button("删除影视库", role: .destructive) {
|
|
guard let library = selectedLibrary else { return }
|
|
Task {
|
|
await model.deleteMediaLibrary(library)
|
|
selectedLibraryID = model.mediaLibraries.first?.id
|
|
await loadCachedItems()
|
|
}
|
|
}
|
|
Button("取消", role: .cancel) { }
|
|
} message: {
|
|
Text("只删除本机影视库配置和刮削缓存,不会删除云盘文件。")
|
|
}
|
|
.task { await initializeLibrary() }
|
|
.onChange(of: selectedLibraryID) { _, _ in Task { await loadCachedItems() } }
|
|
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } }
|
|
}
|
|
|
|
private var emptyLibraryState: some View {
|
|
VStack(spacing: 18) {
|
|
Image(systemName: "play.tv.fill").font(.system(size: 52, weight: .light)).foregroundStyle(.orange)
|
|
Text("创建第一个影视库").font(.title2.weight(.bold))
|
|
Text("选择云盘目录并指定内容类型,扫描后将在本机保存海报和刮削信息。")
|
|
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 440)
|
|
Button { showCreateLibrary = true } label: { Label("新建影视库", systemImage: "plus") }
|
|
.buttonStyle(.borderedProminent).tint(.orange)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func featuredHero(_ item: MediaLibraryItem) -> some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
MediaArtwork(data: item.backdropData ?? item.posterData, icon: "film.fill", contentMode: .fill)
|
|
.frame(maxWidth: .infinity).frame(height: 330).clipped()
|
|
LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.94)], startPoint: .top, endPoint: .bottom)
|
|
LinearGradient(colors: [Color.black.opacity(0.78), .clear], startPoint: .leading, endPoint: .trailing)
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 8) {
|
|
Text(item.mediaKind == .tv ? "剧集" : (item.mediaKind == .movie ? "电影" : "云盘视频"))
|
|
if !item.year.isEmpty { Text(item.year) }
|
|
let count = resourceCount(for: item)
|
|
if count > 1 { Text("\(count) 集") }
|
|
}
|
|
.font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.72))
|
|
Text(item.title).font(.system(size: 34, weight: .bold)).foregroundStyle(.white).lineLimit(2)
|
|
if !item.overview.isEmpty {
|
|
Text(item.overview).font(.callout).foregroundStyle(.white.opacity(0.78)).lineLimit(3).frame(maxWidth: 620, alignment: .leading)
|
|
}
|
|
HStack(spacing: 10) {
|
|
Button { play(item) } label: { Label(resourceCount(for: item) > 1 ? "播放第 1 集" : "播放", systemImage: "play.fill") }
|
|
.buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black)
|
|
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.padding(.horizontal, 34).padding(.bottom, 30)
|
|
}
|
|
.frame(height: 330)
|
|
}
|
|
|
|
private var libraryControls: some View {
|
|
VStack(spacing: 14) {
|
|
HStack(spacing: 12) {
|
|
Picker("影视库", selection: $selectedLibraryID) {
|
|
ForEach(model.mediaLibraries) { library in Text(library.name).tag(Optional(library.id)) }
|
|
}
|
|
.labelsHidden().frame(width: 210)
|
|
if let library = selectedLibrary {
|
|
Text(library.kind.title).font(.caption.weight(.semibold)).foregroundStyle(.orange)
|
|
Text(library.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
Spacer()
|
|
TextField("搜索影片、剧集", text: $searchText)
|
|
.textFieldStyle(.plain).padding(.horizontal, 12).frame(width: 230, height: 34)
|
|
.background(Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 6))
|
|
Button { showCreateLibrary = true } label: { Image(systemName: "plus") }.buttonStyle(.bordered).help("新建影视库")
|
|
Button { if let library = selectedLibrary { Task { await scan(library) } } } label: {
|
|
if model.isScanningMediaLibrary { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.triangle.2.circlepath") }
|
|
}
|
|
.buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || selectedLibrary == nil)
|
|
Menu { Button("删除影视库", role: .destructive) { showDeleteConfirmation = true } } label: { Image(systemName: "ellipsis.circle") }
|
|
.menuStyle(.borderlessButton).help("影视库操作")
|
|
}
|
|
if model.isScanningMediaLibrary {
|
|
HStack(spacing: 10) {
|
|
ProgressView(value: Double(model.mediaLibraryScanProgress.completed), total: Double(max(1, model.mediaLibraryScanProgress.total))).frame(maxWidth: 260)
|
|
Text(model.mediaLibraryScanProgress.phase).font(.caption.weight(.medium))
|
|
Text("\(model.mediaLibraryScanProgress.completed)/\(model.mediaLibraryScanProgress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
}
|
|
HStack {
|
|
Picker("分类", selection: $filter) { ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) } }
|
|
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
|
|
Spacer()
|
|
Text("\(visibleItems.count) 个条目 · \(items.count) 个资源").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 28).padding(.vertical, 20)
|
|
}
|
|
|
|
@ViewBuilder private var libraryContent: some View {
|
|
if isLoadingCache && items.isEmpty {
|
|
VStack(spacing: 14) { ProgressView().controlSize(.large); Text("正在读取本地影视库…").font(.callout.weight(.medium)) }
|
|
.frame(maxWidth: .infinity).padding(.vertical, 90)
|
|
} else if !errorText.isEmpty && items.isEmpty {
|
|
ContentUnavailableView("影视库加载失败", systemImage: "exclamationmark.triangle", description: Text(errorText)).frame(maxWidth: .infinity).padding(.vertical, 70)
|
|
} else if visibleItems.isEmpty {
|
|
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
|
|
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
|
} else {
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 145, maximum: 190), spacing: 18)], alignment: .leading, spacing: 24) {
|
|
ForEach(visibleItems) { item in
|
|
MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) {
|
|
withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id }
|
|
} onOpen: { play(item) }
|
|
.contextMenu {
|
|
Button { play(item) } label: { Label("播放", systemImage: "play.fill") }
|
|
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
|
|
Button { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") }
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 28).padding(.bottom, 36)
|
|
}
|
|
}
|
|
|
|
@MainActor private func initializeLibrary() async {
|
|
isLoadingCache = true
|
|
defer { isLoadingCache = false }
|
|
await model.loadMediaLibraries()
|
|
if let target = model.mediaLibraryTarget, target.isDirectory {
|
|
if let existing = model.mediaLibraries.first(where: { $0.rootID == target.id }) {
|
|
selectedLibraryID = existing.id
|
|
items = await model.cachedMediaLibraryItems(libraryID: existing.id)
|
|
} else {
|
|
let library = await model.createMediaLibrary(name: target.name, rootID: target.id, rootPath: target.cloudPath, kind: .mixed, recursive: true)
|
|
selectedLibraryID = library.id
|
|
await scan(library)
|
|
}
|
|
return
|
|
}
|
|
if selectedLibraryID == nil { selectedLibraryID = model.mediaLibraries.first?.id }
|
|
await loadCachedItems()
|
|
}
|
|
|
|
@MainActor private func loadCachedItems() async {
|
|
guard let library = selectedLibrary else { items = []; return }
|
|
isLoadingCache = true; errorText = ""
|
|
defer { isLoadingCache = false }
|
|
items = await model.cachedMediaLibraryItems(libraryID: library.id)
|
|
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
|
|
}
|
|
|
|
@MainActor private func scan(_ library: MediaLibraryDefinition) async {
|
|
errorText = ""
|
|
do {
|
|
items = try await model.scanMediaLibrary(library)
|
|
selectedItemID = items.first?.id
|
|
} catch { errorText = error.localizedDescription }
|
|
}
|
|
|
|
private func resourceCount(for item: MediaLibraryItem) -> Int {
|
|
guard item.mediaKind == .tv, let tmdbID = item.tmdbID else { return 1 }
|
|
return items.filter { $0.mediaKind == .tv && $0.tmdbID == tmdbID }.count
|
|
}
|
|
|
|
private func play(_ item: MediaLibraryItem) { Task { await model.playWithExternalPlayer(item.file) } }
|
|
}
|
|
|
|
private struct CreateMediaLibrarySheet: View {
|
|
@ObservedObject var model: AppModel
|
|
let onCreate: (String, String?, String, MediaLibraryKind, Bool) -> Void
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var name = ""
|
|
@State private var rootID: String?
|
|
@State private var rootPath = "云盘根目录"
|
|
@State private var kind: MediaLibraryKind = .movies
|
|
@State private var recursive = true
|
|
@State private var showFolderPicker = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "play.tv.fill").font(.title2).foregroundStyle(.orange).frame(width: 44, height: 44).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
|
VStack(alignment: .leading, spacing: 3) { Text("新建影视库").font(.title2.weight(.bold)); Text("配置内容类型与云盘媒体目录").font(.caption).foregroundStyle(.secondary) }
|
|
Spacer()
|
|
}
|
|
TextField("影视库名称", text: $name).textFieldStyle(.roundedBorder)
|
|
Picker("内容类型", selection: $kind) { ForEach(MediaLibraryKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented)
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 3) { Text("媒体目录").font(.caption).foregroundStyle(.secondary); Text(rootPath).font(.callout.weight(.semibold)).lineLimit(2) }
|
|
Spacer()
|
|
Button("选择目录") { showFolderPicker = true }.buttonStyle(.bordered)
|
|
}
|
|
.padding(12).background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
|
|
Toggle("扫描子文件夹", isOn: $recursive)
|
|
HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("创建并扫描") { onCreate(name, rootID, rootPath, kind, recursive); dismiss() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
|
}
|
|
.padding(24).frame(width: 520)
|
|
.sheet(isPresented: $showFolderPicker) {
|
|
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "选择此目录") { id, path in
|
|
rootID = id; rootPath = path
|
|
if name.isEmpty, path != "整个云盘" { name = path.split(separator: "/").last.map(String.init) ?? "影视库" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MediaPosterCard: View {
|
|
let item: MediaLibraryItem
|
|
let resourceCount: Int
|
|
let isSelected: Bool
|
|
let onSelect: () -> Void
|
|
let onOpen: () -> Void
|
|
@State private var isHovered = false
|
|
|
|
var body: some View {
|
|
Button(action: onSelect) {
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
MediaArtwork(data: item.posterData, icon: "film.fill", contentMode: .fill)
|
|
.aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6))
|
|
if isHovered { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) }
|
|
else if resourceCount > 1 { Text("\(resourceCount) 集").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) }
|
|
}
|
|
.overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? Color.orange : Color.white.opacity(isHovered ? 0.38 : 0.10), lineWidth: isSelected ? 2 : 1) }
|
|
Text(item.title).font(.callout.weight(.semibold)).lineLimit(1)
|
|
HStack(spacing: 6) { Text(item.year.isEmpty ? "视频" : item.year); if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") } }.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain).onHover { isHovered = $0 }.simultaneousGesture(TapGesture(count: 2).onEnded(onOpen))
|
|
}
|
|
}
|
|
|
|
private struct MediaArtwork: View {
|
|
let data: Data?
|
|
let icon: String
|
|
let contentMode: ContentMode
|
|
var body: some View {
|
|
Group {
|
|
if let data, let image = NSImage(data: data) { Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) }
|
|
else { ZStack { LinearGradient(colors: [Color(red: 0.19, green: 0.22, blue: 0.20), Color(red: 0.08, green: 0.09, blue: 0.09)], startPoint: .topLeading, endPoint: .bottomTrailing); Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.white.opacity(0.28)) } }
|
|
}
|
|
}
|
|
}
|