Files
guangya_client/guangya_mac/MediaLibraryPage.swift
T

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