feat: 优化影视库浏览与创建流程
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user