Files
guangya_client/guangya_mac/MediaLibraryPage.swift
T

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)) } }
}
}
}