Files
guangya_client/guangya_mac/MediaLibraryPage.swift
T

436 lines
24 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 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 featuredItem: MediaLibraryItem? {
selectedItemID.flatMap { id in items.first { $0.id == id } }
?? visibleItems.first(where: { $0.backdropData != nil })
?? visibleItems.first
}
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.07, green: 0.075, blue: 0.075), Color(red: 0.035, green: 0.038, blue: 0.04)], startPoint: .top, endPoint: .bottom).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(item.mediaKind == .tv ? "\(count) 集" : "\(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: 8) {
if item.hasChineseAudio { MediaLanguageBadge(title: "中文音轨", icon: "waveform") }
if item.hasChineseSubtitle { MediaLanguageBadge(title: "中文字幕", icon: "captions.bubble") }
Text(item.file.formattedSize).font(.caption.monospacedDigit()).foregroundStyle(.white.opacity(0.58))
}
HStack(spacing: 10) {
Button { play(item) } label: { Label(resourceCount(for: item) > 1 && item.mediaKind == .tv ? "播放第 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) {
if let library = selectedLibrary {
Image(systemName: library.kind.icon).font(.title3.weight(.semibold)).foregroundStyle(.orange)
.frame(width: 38, height: 38).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
}
VStack(alignment: .leading, spacing: 2) {
Picker("影视库", selection: $selectedLibraryID) {
ForEach(model.mediaLibraries) { library in Text(library.name).tag(Optional(library.id)) }
}
.labelsHidden().frame(width: 220, alignment: .leading)
if let library = selectedLibrary {
Text("\(library.kind.title) · \(library.rootPath)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
HStack(spacing: 7) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("搜索影视库", text: $searchText).textFieldStyle(.plain)
if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") }.buttonStyle(.plain).foregroundStyle(.secondary) }
}
.padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 7))
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()
Label("\(visibleItems.count) 个条目", systemImage: "rectangle.grid.2x2").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Text("·").foregroundStyle(.tertiary)
Label("\(items.count) 个资源", systemImage: "film.stack").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 28).padding(.vertical, 20)
.background(.ultraThinMaterial)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
}
@ViewBuilder private var libraryContent: some View {
if isLoadingCache && items.isEmpty {
VStack(spacing: 14) { ProgressView().controlSize(.large); Text("正在读取本地影视库…").font(.callout.weight(.medium)) }
.frame(maxWidth: .infinity).padding(.vertical, 90)
} else if model.isScanningMediaLibrary && items.isEmpty {
VStack(spacing: 14) {
ProgressView().controlSize(.large)
Text(model.mediaLibraryScanProgress.phase).font(.callout.weight(.medium))
if model.mediaLibraryScanProgress.total > 0 {
Text("\(model.mediaLibraryScanProgress.completed)/\(model.mediaLibraryScanProgress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
.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 let tmdbID = item.tmdbID else { return 1 }
return items.filter { $0.mediaKind == item.mediaKind && $0.tmdbID == tmdbID }.count
}
private func languageScore(_ item: MediaLibraryItem) -> Int { (item.hasChineseAudio ? 2 : 0) + (item.hasChineseSubtitle ? 1 : 0) }
private func play(_ item: MediaLibraryItem) {
let preferred = items.filter { candidate in
guard let tmdbID = item.tmdbID else { return candidate.id == item.id }
return candidate.mediaKind == item.mediaKind && candidate.tmdbID == tmdbID
}.max { languageScore($0) < languageScore($1) } ?? item
Task { await model.playWithExternalPlayer(preferred.file) }
}
}
private struct CreateMediaLibrarySheet: View {
@ObservedObject var model: AppModel
let 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
@State private var hasChosenRoot = false
private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) }
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("新建影视库").font(.title2.weight(.bold)); Text("云端媒体目录").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))
Button { showFolderPicker = true } label: {
HStack(spacing: 12) {
Image(systemName: hasChosenRoot ? "folder.fill" : "folder.badge.plus").font(.title3).foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 3) {
Text(hasChosenRoot ? rootPath : "选择云盘文件夹").font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(1)
if hasChosenRoot { Text(rootID == nil ? "整个云盘" : "已选择媒体目录").font(.caption).foregroundStyle(.secondary) }
}
Spacer()
Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary)
}
.padding(12).contentShape(Rectangle())
}
.buttonStyle(.plain).background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8))
}
Toggle(isOn: $recursive) {
VStack(alignment: .leading, spacing: 2) { Text("包含子文件夹").font(.callout.weight(.semibold)); Text("递归扫描目录中的电影、剧集和刮削文件").font(.caption).foregroundStyle(.secondary) }
}
Divider()
HStack {
if !hasChosenRoot { Label("请选择媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) }
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button { onCreate(trimmedName, rootID, rootPath, kind, recursive); dismiss() } label: { Label("创建并扫描", systemImage: "plus") }
.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(trimmedName.isEmpty || !hasChosenRoot)
}
}
.padding(24)
}
}
.frame(width: 590)
.sheet(isPresented: $showFolderPicker) {
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "选择此目录") { id, path in
rootID = id; rootPath = path; hasChosenRoot = true
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 MediaPosterCard: View {
let item: MediaLibraryItem
let resourceCount: Int
let isSelected: Bool
let onSelect: () -> Void
let onOpen: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 9) {
ZStack(alignment: .bottomTrailing) {
MediaArtwork(data: item.posterData, icon: "film.fill", contentMode: .fill)
.aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6))
if isHovered { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) }
else if resourceCount > 1 { Text(item.mediaKind == .tv ? "\(resourceCount) 集" : "\(resourceCount) 版本").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) }
}
.overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? 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 ? "电影" : "剧集") }
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 MediaLanguageBadge: View {
let title: String
let icon: String
var body: some View {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.86))
}
}
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)) } }
}
}
}