feat: 优化影视库浏览与创建流程

This commit is contained in:
2026-07-16 13:26:16 +08:00
parent 4256587122
commit 3c27e04b2e
+154 -41
View File
@@ -37,11 +37,14 @@ struct MediaLibraryPage: View {
}
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
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? {
@@ -52,7 +55,7 @@ struct MediaLibraryPage: View {
var body: some View {
ZStack {
Color(red: 0.055, green: 0.06, blue: 0.06).ignoresSafeArea()
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 {
@@ -116,15 +119,20 @@ struct MediaLibraryPage: View {
Text(item.mediaKind == .tv ? "剧集" : (item.mediaKind == .movie ? "电影" : "云盘视频"))
if !item.year.isEmpty { Text(item.year) }
let count = resourceCount(for: item)
if count > 1 { Text("\(count)") }
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 ? "播放第 1 集" : "播放", systemImage: "play.fill") }
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)
}
@@ -137,18 +145,26 @@ struct MediaLibraryPage: View {
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)
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()
TextField("搜索影片、剧集", text: $searchText)
.textFieldStyle(.plain).padding(.horizontal, 12).frame(width: 230, height: 34)
.background(Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 6))
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") }
@@ -169,16 +185,29 @@ struct MediaLibraryPage: View {
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)
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 {
@@ -237,11 +266,19 @@ struct MediaLibraryPage: View {
}
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
guard let tmdbID = item.tmdbID else { return 1 }
return items.filter { $0.mediaKind == item.mediaKind && $0.tmdbID == tmdbID }.count
}
private func play(_ item: MediaLibraryItem) { Task { await model.playWithExternalPlayer(item.file) } }
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 {
@@ -250,39 +287,100 @@ private struct CreateMediaLibrarySheet: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var rootID: String?
@State private var rootPath = "云盘根目录"
@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 {
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()
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)
}
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)
.frame(width: 590)
.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) ?? "影视库" }
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
@@ -298,17 +396,32 @@ private struct MediaPosterCard: View {
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) }
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 ? "电影" : "剧集") } }.font(.caption).foregroundStyle(.secondary).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