import SwiftUI // MARK: - 媒体库面板主视图 struct MediaLibraryView: View { @ObservedObject var library: MediaLibrary @ObservedObject var bridge: PlayerBridge @State private var searchText = "" @State private var selectedSidebar: SidebarItem = .all @State private var showImportSheet = false @State private var showNewPlaylistSheet = false @State private var showTagInput = false @State private var tagInputItemID: String? @State private var tagInputText = "" @State private var newPlaylistName = "" @State private var selectedItems: Set = [] @State private var displayLimit: Int = 100 // 分页:先显示100条,滚动加载更多 @State private var showUnavailable: Bool = false // 是否显示不可用频道 enum SidebarItem: Hashable { case all case liked case playlist(String) // playlist ID case tag(String) // tag name } // 根据侧边栏选择 + 搜索获取显示列表 private var allFilteredItems: [LibraryItem] { var result: [LibraryItem] switch selectedSidebar { case .all: result = library.items case .liked: result = library.items.filter { $0.liked } case .playlist(let id): result = library.items(in: id) case .tag(let tag): result = library.search("", tag: tag) } // 过滤不可用频道(默认隐藏) if !showUnavailable { result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable } } if !searchText.isEmpty { let q = searchText.lowercased() result = result.filter { item in item.name.lowercased().contains(q) || item.url.lowercased().contains(q) || item.tags.contains(where: { $0.lowercased().contains(q) }) || (item.group?.lowercased().contains(q) ?? false) } } return result } // 分页显示 private var displayedItems: [LibraryItem] { Array(allFilteredItems.prefix(displayLimit)) } var body: some View { NavigationSplitView { sidebarView } detail: { contentView } .frame(minWidth: 650, minHeight: 450) .onChange(of: selectedSidebar) { _ in displayLimit = 100 } .onChange(of: searchText) { _ in displayLimit = 100 } .sheet(isPresented: $showImportSheet) { ImportM3UView(library: library, bridge: bridge) } .sheet(isPresented: $showNewPlaylistSheet) { VStack(spacing: 16) { Text(L.newPlaylist).font(.headline) TextField(L.playlistName, text: $newPlaylistName) .textFieldStyle(.roundedBorder) HStack { Button(L.cancel) { showNewPlaylistSheet = false; newPlaylistName = "" } Spacer() Button(L.create) { if !newPlaylistName.isEmpty { _ = library.createPlaylist(name: newPlaylistName) newPlaylistName = "" showNewPlaylistSheet = false } } .keyboardShortcut(.defaultAction) .disabled(newPlaylistName.isEmpty) } } .padding(20) .frame(width: 350) } .sheet(isPresented: $showTagInput) { VStack(spacing: 16) { Text(L.addTag).font(.headline) TextField(L.tagName, text: $tagInputText) .textFieldStyle(.roundedBorder) HStack { Button(L.cancel) { showTagInput = false; tagInputText = "" } Spacer() Button(L.add) { if let id = tagInputItemID, !tagInputText.isEmpty { // 支持逗号分隔多标签 for tag in tagInputText.split(separator: ",") { library.addTag(String(tag.trimmingCharacters(in: .whitespaces)), to: id) } tagInputText = "" showTagInput = false } } .keyboardShortcut(.defaultAction) .disabled(tagInputText.isEmpty) } } .padding(20) .frame(width: 350) } } // MARK: - 侧边栏 private var sidebarView: some View { List { Section { Label(L.allMedia, systemImage: "square.grid.2x2") .tag(SidebarItem.all) .onTapGesture { selectedSidebar = .all } Label(L.liked, systemImage: "heart.fill") .tag(SidebarItem.liked) .onTapGesture { selectedSidebar = .liked } } Section(L.playlist) { ForEach(library.playlists) { pl in HStack { Label(pl.name, systemImage: "music.note.list") Spacer() Text("\(pl.itemIDs.count)") .font(.caption2).foregroundColor(.secondary) } .tag(SidebarItem.playlist(pl.id)) .onTapGesture { selectedSidebar = .playlist(pl.id) } .contextMenu { Button(L.delete, role: .destructive) { library.deletePlaylist(pl.id) if case .playlist(let id) = selectedSidebar, id == pl.id { selectedSidebar = .all } } } } Button { showNewPlaylistSheet = true } label: { Label(L.addPlaylist, systemImage: "plus.circle") } .buttonStyle(.plain) } if !library.allTags.isEmpty { Section(L.tags) { ForEach(library.allTags, id: \.self) { tag in Label(tag, systemImage: "tag") .tag(SidebarItem.tag(tag)) .onTapGesture { selectedSidebar = .tag(tag) } } } } } .listStyle(.sidebar) .frame(minWidth: 180) } // MARK: - 内容区 private var contentView: some View { VStack(spacing: 0) { // 顶部工具栏 HStack(spacing: 8) { // 搜索框 HStack { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) TextField(L.searchChannels, text: $searchText) .textFieldStyle(.plain) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) } .buttonStyle(.plain) } } .padding(.horizontal, 8).padding(.vertical, 6) .background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.15))) .frame(maxWidth: 300) Spacer() // 导入 M3U Button { showImportSheet = true } label: { Label(L.importM3U, systemImage: "square.and.arrow.down") } .buttonStyle(.bordered) .controlSize(.small) // 添加本地文件入库 Button { bridge.importLocalToLibrary() } label: { Label(L.addLocal, systemImage: "folder.badge.plus") } .buttonStyle(.bordered) .controlSize(.small) // 全部加入队列 if !displayedItems.isEmpty { Button { addDisplayedToQueue() } label: { Label(L.addAllToQueue, systemImage: "text.badge.plus") } .buttonStyle(.bordered) .controlSize(.small) } // 检测流可用性 if library.isChecking { Button { library.cancelCheck() } label: { HStack(spacing: 4) { ProgressView().controlSize(.mini) Text(L.cancelCheck) } } .buttonStyle(.bordered) .controlSize(.small) } else { Button { library.checkAllStreams() } label: { Label(L.checkStreams, systemImage: "network") } .buttonStyle(.bordered) .controlSize(.small) } // 显示/隐藏不可用 Button { showUnavailable.toggle() } label: { Image(systemName: showUnavailable ? "eye.slash" : "eye") .foregroundColor(showUnavailable ? .orange : .secondary) } .buttonStyle(.plain) #if os(macOS) .help(showUnavailable ? L.hideUnavailable : L.showUnavailable) #endif } .padding(.horizontal, 12).padding(.vertical, 8) // 检测进度条 if library.isChecking { VStack(spacing: 2) { ProgressView(value: library.checkProgress) .progressViewStyle(.linear) Text(library.checkStatusText) .font(.caption2).foregroundColor(.secondary) } .padding(.horizontal, 12) } Divider() // 条目列表 if displayedItems.isEmpty { VStack { Spacer() Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult) .foregroundColor(.secondary) Spacer() } } else { List { ForEach(displayedItems) { item in LibraryItemRow(item: item, bridge: bridge, library: library, showTagInput: $showTagInput, tagInputItemID: $tagInputItemID) .onAppear { // 滚动到底部时加载更多 if item == displayedItems.last, allFilteredItems.count > displayLimit { displayLimit += 100 } } } if allFilteredItems.count > displayLimit { HStack { Spacer() ProgressView() Text("Loading more... (\(allFilteredItems.count - displayLimit) remaining)") .font(.caption).foregroundColor(.secondary) Spacer() } .padding(.vertical, 8) } } .listStyle(.inset) } // 底部状态栏 HStack { Text("\(displayedItems.count) \(L.itemsCount)") .font(.caption).foregroundColor(.secondary) if !showUnavailable { let badCount = library.items.filter { $0.availability == .forbidden || $0.availability == .unavailable }.count if badCount > 0 { Text("(\(badCount) \(L.hiddenUnavailable))") .font(.caption).foregroundColor(.orange) } } Spacer() Text("\(library.count) \(L.totalItems)") .font(.caption).foregroundColor(.secondary) } .padding(.horizontal, 12).padding(.vertical, 4) } } // MARK: - 操作 private func addDisplayedToQueue() { for item in displayedItems { if let url = URL(string: item.url) { bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id) } } bridge.showToastMsg("\(displayedItems.count) \(L.addedToQueue)") } } // MARK: - 条目行 struct LibraryItemRow: View { let item: LibraryItem @ObservedObject var bridge: PlayerBridge @ObservedObject var library: MediaLibrary @Binding var showTagInput: Bool @Binding var tagInputItemID: String? var body: some View { HStack(spacing: 10) { // 类型图标 Image(systemName: iconForType(item.type)) .foregroundColor(.secondary) .frame(width: 20) VStack(alignment: .leading, spacing: 3) { Text(item.name).font(.system(size: 13)).lineLimit(1) HStack(spacing: 4) { Text(item.url) .font(.caption2).foregroundColor(.secondary).lineLimit(1) if let group = item.group, !group.isEmpty { Text("[\(group)]") .font(.caption2).foregroundColor(.blue.opacity(0.7)) } } if !item.tags.isEmpty { HStack(spacing: 4) { ForEach(item.tags, id: \.self) { tag in Text(tag) .font(.system(size: 10)) .padding(.horizontal, 6).padding(.vertical, 2) .background(Capsule().fill(Color.accentColor.opacity(0.15))) } } } } Spacer() // 可用性状态 switch item.availability { case .available: Image(systemName: "checkmark.circle.fill") .foregroundColor(.green).font(.caption) case .forbidden: Image(systemName: "lock.fill") .foregroundColor(.red).font(.caption) #if os(macOS) .help(L.statusForbidden) #endif case .unavailable: Image(systemName: "xmark.circle.fill") .foregroundColor(.gray).font(.caption) #if os(macOS) .help(L.statusUnavailable) #endif case .unknown: EmptyView() } // 喜欢 Button { library.toggleLike(item.id) } label: { Image(systemName: item.liked ? "heart.fill" : "heart") .foregroundColor(item.liked ? .red : .secondary) } .buttonStyle(.plain) // 不喜欢 Button { library.toggleDislike(item.id) } label: { Image(systemName: item.disliked ? "hand.thumbsdown.fill" : "hand.thumbsdown") .foregroundColor(item.disliked ? .orange : .secondary) } .buttonStyle(.plain) // 播放 Button { bridge.playFromLibrary(item) } label: { Image(systemName: "play.fill") .foregroundColor(.accentColor) } .buttonStyle(.plain) } .padding(.vertical, 4) .contextMenu { Button(L.playNow) { bridge.playFromLibrary(item) } Button(L.addToQueue) { if let url = URL(string: item.url) { bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id) } } Divider() Button(L.addTag) { tagInputItemID = item.id showTagInput = true } if !item.tags.isEmpty { Menu(L.removeTag) { ForEach(item.tags, id: \.self) { tag in Button(tag) { library.removeTag(tag, from: item.id) } } } } Divider() Menu(L.addToPlaylist) { ForEach(library.playlists) { pl in Button(pl.name) { library.addToPlaylist(pl.id, itemID: item.id) } } } Divider() Button(L.delete, role: .destructive) { library.removeItem(item.id) } } } private func iconForType(_ type: String) -> String { switch type { case "stream": return "antenna.radiowaves.left.and.right" case "file", "local": return "doc.fill" default: return "link" } } } // MARK: - 导入 M3U 弹窗 struct ImportM3UView: View { @ObservedObject var library: MediaLibrary @ObservedObject var bridge: PlayerBridge @Environment(\.dismiss) private var dismiss @State private var m3uURL = "" @State private var isLoading = false @State private var statusMessage = "" @State private var parsedChannels: [M3UParser.Channel] = [] @State private var selectedChannels: Set = [] @State private var errorMessage: String? var body: some View { VStack(spacing: 16) { Text(L.importM3U).font(.headline) // URL 输入 HStack { TextField("https://example.com/playlist.m3u", text: $m3uURL) .textFieldStyle(.roundedBorder) Button(L.fetch) { fetchM3U() } .disabled(m3uURL.isEmpty || isLoading) } if isLoading { ProgressView(L.fetching) } if let error = errorMessage { Text(error).foregroundColor(.red).font(.caption) } if !statusMessage.isEmpty { Text(statusMessage).font(.caption).foregroundColor(.secondary) } // 频道列表 if !parsedChannels.isEmpty { HStack { Text("\(parsedChannels.count) \(L.channelsFound)") .font(.caption).foregroundColor(.secondary) Spacer() Button(L.selectAll) { selectedChannels = Set(parsedChannels.map { $0.url }) } .font(.caption) Button(L.deselectAll) { selectedChannels = [] } .font(.caption) } ScrollView { LazyVStack(alignment: .leading, spacing: 4) { ForEach(parsedChannels, id: \.url) { ch in HStack { Image(systemName: selectedChannels.contains(ch.url) ? "checkmark.square.fill" : "square") .foregroundColor(selectedChannels.contains(ch.url) ? .accentColor : .secondary) VStack(alignment: .leading) { Text(ch.name).font(.system(size: 12)).lineLimit(1) Text(ch.url).font(.system(size: 10)).foregroundColor(.secondary).lineLimit(1) } Spacer() if let group = ch.group { Text(group).font(.system(size: 10)).foregroundColor(.blue.opacity(0.7)) } } .padding(.vertical, 2) .contentShape(Rectangle()) .onTapGesture { if selectedChannels.contains(ch.url) { selectedChannels.remove(ch.url) } else { selectedChannels.insert(ch.url) } } } } } .frame(maxHeight: 300) .background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.08))) HStack { Button(L.cancel) { dismiss() } Spacer() Button("\(L.importSelected) (\(selectedChannels.count))") { importSelected() } .keyboardShortcut(.defaultAction) .disabled(selectedChannels.isEmpty) } } else { HStack { Spacer() Button(L.close) { dismiss() } } } } .padding(20) .frame(width: 550, height: 500) } private func fetchM3U() { guard let url = URL(string: m3uURL) else { errorMessage = L.invalidURL return } isLoading = true errorMessage = nil statusMessage = "" Task { do { let channels = try await M3UParser.fetchAndParse(url: url) await MainActor.run { parsedChannels = channels selectedChannels = Set(channels.map { $0.url }) isLoading = false statusMessage = "\(L.fetched) \(channels.count) \(L.channelsFound)" } } catch { await MainActor.run { isLoading = false errorMessage = "\(L.fetchFailed): \(error.localizedDescription)" } } } } private func importSelected() { var newItems: [LibraryItem] = [] for ch in parsedChannels where selectedChannels.contains(ch.url) { newItems.append(LibraryItem( url: ch.url, name: ch.name, type: ch.type, tags: ch.group.map { [$0] } ?? [], group: ch.group, logoURL: ch.logoURL )) } let added = library.addItems(newItems) bridge.showToastMsg("\(L.imported) \(added) \(L.channelsAdded)") dismiss() } }