diff --git a/guangya_mac/MediaLibraryPage.swift b/guangya_mac/MediaLibraryPage.swift index 090ceb4..e7413c1 100644 --- a/guangya_mac/MediaLibraryPage.swift +++ b/guangya_mac/MediaLibraryPage.swift @@ -37,11 +37,14 @@ struct MediaLibraryPage: View { } return matchesSearch && matchesFilter } - var seen = Set() - 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