265 lines
12 KiB
Swift
265 lines
12 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 sourceID: String?
|
|
@State private var sourceName = "云盘根目录"
|
|
@State private var items: [MediaLibraryItem] = []
|
|
@State private var selectedID: String?
|
|
@State private var filter: MediaLibraryFilter = .all
|
|
@State private var searchText = ""
|
|
@State private var isLoading = false
|
|
@State private var errorText = ""
|
|
@State private var showFolderPicker = false
|
|
|
|
private var filteredItems: [MediaLibraryItem] {
|
|
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
|
|
}
|
|
}
|
|
|
|
private var featuredItem: MediaLibraryItem? {
|
|
selectedID.flatMap { id in items.first { $0.id == id } }
|
|
?? items.first(where: { $0.backdropData != nil })
|
|
?? items.first
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color(red: 0.055, green: 0.06, blue: 0.06).ignoresSafeArea()
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
if let featuredItem { featuredHero(featuredItem) }
|
|
libraryControls
|
|
libraryContent
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
.sheet(isPresented: $showFolderPicker) {
|
|
ScanFolderPickerSheet(model: model, title: "选择影视库目录", selectionLabel: "设为影视库") { id, name in
|
|
model.mediaLibraryTarget = nil
|
|
sourceID = id
|
|
sourceName = name
|
|
Task { await loadLibrary() }
|
|
}
|
|
}
|
|
.task {
|
|
syncTarget()
|
|
await loadLibrary()
|
|
}
|
|
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in
|
|
syncTarget()
|
|
Task { await loadLibrary() }
|
|
}
|
|
}
|
|
|
|
private func featuredHero(_ item: MediaLibraryItem) -> some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
MediaArtwork(data: item.backdropData ?? item.posterData, icon: item.file.isDirectory ? "folder.fill" : "film.fill", contentMode: .fill)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 330)
|
|
.clipped()
|
|
LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.92)], startPoint: .top, endPoint: .bottom)
|
|
LinearGradient(colors: [Color.black.opacity(0.76), .clear], startPoint: .leading, endPoint: .trailing)
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 8) {
|
|
if let kind = item.mediaKind {
|
|
Text(kind == .movie ? "电影" : "剧集")
|
|
} else {
|
|
Text(item.file.isDirectory ? "媒体文件夹" : "云盘视频")
|
|
}
|
|
if !item.year.isEmpty { Text(item.year) }
|
|
}
|
|
.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 { open(item) } label: {
|
|
Label(item.file.isDirectory ? "浏览内容" : "播放", systemImage: item.file.isDirectory ? "rectangle.grid.2x2" : "play.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black)
|
|
if !item.file.isDirectory {
|
|
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: 14) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("影视库").font(.title2.weight(.bold))
|
|
Text(sourceName).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 { showFolderPicker = true } label: { Image(systemName: "folder") }.buttonStyle(.bordered).help("选择影视库目录")
|
|
Button { Task { await loadLibrary() } } label: {
|
|
if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") }
|
|
}
|
|
.buttonStyle(.bordered).help("刷新影视库").disabled(isLoading)
|
|
}
|
|
HStack {
|
|
Picker("分类", selection: $filter) {
|
|
ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) }
|
|
}
|
|
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
|
|
Spacer()
|
|
Text("\(filteredItems.count) 项").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 28).padding(.vertical, 20)
|
|
}
|
|
|
|
@ViewBuilder private var libraryContent: some View {
|
|
if isLoading && items.isEmpty {
|
|
VStack(spacing: 14) {
|
|
ProgressView().controlSize(.large)
|
|
Text("正在构建影视库…").font(.callout.weight(.medium))
|
|
Text("正在匹配媒体信息与海报").font(.caption).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 filteredItems.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(filteredItems) { item in
|
|
MediaPosterCard(item: item, isSelected: selectedID == item.id) {
|
|
withAnimation(.easeInOut(duration: 0.18)) { selectedID = item.id }
|
|
} onOpen: {
|
|
open(item)
|
|
}
|
|
.contextMenu {
|
|
Button { open(item) } label: { Label(item.file.isDirectory ? "浏览内容" : "播放", systemImage: item.file.isDirectory ? "folder" : "play.fill") }
|
|
if !item.file.isDirectory { 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)
|
|
}
|
|
}
|
|
|
|
private func syncTarget() {
|
|
if let target = model.mediaLibraryTarget, target.isDirectory {
|
|
sourceID = target.id
|
|
sourceName = target.name
|
|
} else {
|
|
sourceID = model.currentScanRootID
|
|
sourceName = model.scanScopeLocationName
|
|
}
|
|
}
|
|
|
|
@MainActor private func loadLibrary() async {
|
|
guard !isLoading else { return }
|
|
isLoading = true
|
|
errorText = ""
|
|
defer { isLoading = false }
|
|
do {
|
|
let loaded = try await model.loadMediaLibrary(parentID: sourceID)
|
|
items = loaded
|
|
if !loaded.contains(where: { $0.id == selectedID }) { selectedID = loaded.first?.id }
|
|
} catch is CancellationError { }
|
|
catch { errorText = error.localizedDescription }
|
|
}
|
|
|
|
private func open(_ item: MediaLibraryItem) {
|
|
if item.file.isDirectory {
|
|
sourceID = item.file.id
|
|
sourceName = item.title
|
|
model.mediaLibraryTarget = item.file
|
|
Task { await loadLibrary() }
|
|
} else {
|
|
Task { await model.playWithExternalPlayer(item.file) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MediaPosterCard: View {
|
|
let item: MediaLibraryItem
|
|
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: item.file.isDirectory ? "folder.fill" : "film.fill", contentMode: .fill)
|
|
.aspectRatio(2 / 3, contentMode: .fit)
|
|
.frame(maxWidth: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
if item.file.isDirectory {
|
|
Image(systemName: "rectangle.grid.2x2.fill").font(.caption.weight(.bold)).padding(7).background(.black.opacity(0.68), in: Circle()).padding(8)
|
|
} else if isHovered {
|
|
Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10)
|
|
}
|
|
}
|
|
.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.file.isDirectory ? "文件夹" : "视频") : 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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|