import SwiftUI // MARK: - 媒体库面板主视图 struct MediaLibraryView: View { @ObservedObject var library: MediaLibrary @ObservedObject var bridge: PlayerBridge @Environment(\.dismiss) private var dismiss @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 displayLimit: Int = 100 @State private var showUnavailable: Bool = false enum SidebarItem: Hashable { case all case liked case playlist(String) case tag(String) } // 根据侧边栏选择 + 搜索获取显示列表 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 { #if os(iOS) iOSBody #else macOSBody #endif } // MARK: - iOS 专用布局 @ViewBuilder private var iOSBody: some View { NavigationStack { VStack(spacing: 0) { // 分类切换(替代侧边栏) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { CategoryChip(label: L.allMedia, icon: "square.grid.2x2", isSelected: selectedSidebar == .all) { selectedSidebar = .all } CategoryChip(label: L.liked, icon: "heart.fill", isSelected: selectedSidebar == .liked) { selectedSidebar = .liked } ForEach(library.playlists) { pl in CategoryChip(label: pl.name, icon: "music.note.list", isSelected: selectedSidebar == .playlist(pl.id)) { selectedSidebar = .playlist(pl.id) } } ForEach(library.allTags, id: \.self) { tag in CategoryChip(label: tag, icon: "tag", isSelected: selectedSidebar == .tag(tag)) { selectedSidebar = .tag(tag) } } } .padding(.horizontal, 12) } .padding(.vertical, 8) // 搜索框 HStack(spacing: 8) { HStack { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) .font(.system(size: 14)) TextField(L.searchChannels, text: $searchText) .textFieldStyle(.plain) .font(.system(size: 14)) .submitLabel(.search) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) } .buttonStyle(.plain) } } .padding(.horizontal, 10).padding(.vertical, 8) .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.12))) // 工具按钮 Button { showImportSheet = true } label: { Image(systemName: "square.and.arrow.down") .font(.system(size: 14)) } .buttonStyle(.bordered) .controlSize(.small) Button { bridge.importLocalToLibrary() } label: { Image(systemName: "folder.badge.plus") .font(.system(size: 14)) } .buttonStyle(.bordered) .controlSize(.small) } .padding(.horizontal, 12) // 检测进度 if library.isChecking { VStack(spacing: 2) { ProgressView(value: library.checkProgress) .progressViewStyle(.linear) HStack { Text(library.checkStatusText) .font(.caption2).foregroundColor(.secondary) Spacer() Button(L.cancelCheck) { library.cancelCheck() } .font(.caption2) } } .padding(.horizontal, 12).padding(.vertical, 4) } Divider() // 条目列表 if displayedItems.isEmpty { VStack { Spacer() Image(systemName: "tray") .font(.system(size: 36)) .foregroundColor(.secondary.opacity(0.5)) Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult) .foregroundColor(.secondary) .padding(.top, 4) Spacer() } } else { List { ForEach(displayedItems) { item in iOSLibraryRow(item: item) .listRowSeparator(.visible) .swipeActions(edge: .trailing) { Button(role: .destructive) { library.removeItem(item.id) } label: { Label(L.delete, systemImage: "trash") } Button { if let url = URL(string: item.url) { bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id) } } label: { Label(L.addToQueue, systemImage: "text.badge.plus") } .tint(.blue) } .onAppear { if item == displayedItems.last, allFilteredItems.count > displayLimit { displayLimit += 100 } } } } .listStyle(.plain) .scrollDismissesKeyboard(.interactively) .onTapGesture { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } // 底部状态栏 HStack(spacing: 8) { Text("\(allFilteredItems.count) / \(library.count)") .font(.caption).foregroundColor(.secondary) Spacer() if library.isChecking { Button { } label: { ProgressView().controlSize(.mini) } } else { Button { library.checkAllStreams() } label: { Image(systemName: "network") .font(.caption) } .buttonStyle(.plain) } Button { showUnavailable.toggle() } label: { Image(systemName: showUnavailable ? "eye.slash" : "eye") .font(.caption) .foregroundColor(showUnavailable ? .orange : .secondary) } .buttonStyle(.plain) } .padding(.horizontal, 12).padding(.vertical, 6) .background(.thinMaterial) } .navigationTitle(L.mediaLibrary) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(L.close) { dismiss() } } } #endif } .sheet(isPresented: $showImportSheet) { ImportM3UView(library: library, bridge: bridge) } .sheet(isPresented: $showNewPlaylistSheet) { NavigationStack { VStack(spacing: 16) { TextField(L.playlistName, text: $newPlaylistName) .textFieldStyle(.roundedBorder) Spacer() } .padding(20) .navigationTitle(L.newPlaylist) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L.cancel) { showNewPlaylistSheet = false; newPlaylistName = "" } } ToolbarItem(placement: .confirmationAction) { Button(L.create) { if !newPlaylistName.isEmpty { _ = library.createPlaylist(name: newPlaylistName) newPlaylistName = "" showNewPlaylistSheet = false } } .disabled(newPlaylistName.isEmpty) } } } .presentationDetents([.medium]) } .sheet(isPresented: $showTagInput) { NavigationStack { VStack(spacing: 16) { TextField(L.tagName, text: $tagInputText) .textFieldStyle(.roundedBorder) Spacer() } .padding(20) .navigationTitle(L.addTag) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L.cancel) { showTagInput = false; tagInputText = "" } } ToolbarItem(placement: .confirmationAction) { 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 } } .disabled(tagInputText.isEmpty) } } } .presentationDetents([.medium]) } } // MARK: - iOS 条目行 private func iOSLibraryRow(item: LibraryItem) -> some View { HStack(spacing: 10) { // 类型图标 + 可用性 ZStack(alignment: .topTrailing) { Image(systemName: iconForType(item.type)) .foregroundColor(.secondary) .font(.system(size: 16)) .frame(width: 24, height: 24) switch item.availability { case .available: Image(systemName: "circle.fill") .foregroundColor(.green) .font(.system(size: 6)) .offset(x: 2, y: -2) case .forbidden: Image(systemName: "lock.fill") .foregroundColor(.red) .font(.system(size: 8)) .offset(x: 4, y: -2) case .unavailable: Image(systemName: "xmark.circle.fill") .foregroundColor(.gray) .font(.system(size: 8)) .offset(x: 4, y: -2) case .unknown: EmptyView() } } VStack(alignment: .leading, spacing: 2) { Text(item.name) .font(.system(size: 14)) .lineLimit(1) HStack(spacing: 4) { if let group = item.group, !group.isEmpty { Text(group) .font(.caption2) .padding(.horizontal, 4).padding(.vertical, 1) .background(Capsule().fill(Color.blue.opacity(0.1))) .foregroundColor(.blue) } ForEach(item.tags.prefix(3), id: \.self) { tag in Text(tag) .font(.system(size: 10)) .padding(.horizontal, 4).padding(.vertical, 1) .background(Capsule().fill(Color.accentColor.opacity(0.1))) } } } Spacer() // 喜欢 Button { library.toggleLike(item.id) } label: { Image(systemName: item.liked ? "heart.fill" : "heart") .foregroundColor(item.liked ? .red : .secondary) .font(.system(size: 14)) } .buttonStyle(.plain) // 播放 Button { bridge.playFromLibrary(item) dismiss() } label: { Image(systemName: "play.circle.fill") .foregroundColor(.accentColor) .font(.system(size: 22)) } .buttonStyle(.plain) } .padding(.vertical, 2) .contentShape(Rectangle()) .contextMenu { Button(L.playNow) { bridge.playFromLibrary(item); dismiss() } 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) } } } // MARK: - macOS 布局(保持不变) @ViewBuilder private var macOSBody: some View { NavigationSplitView { sidebarView } detail: { macOSContentView } .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: - macOS 侧边栏 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: - macOS 内容区 private var macOSContentView: 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() 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)") } 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: - 分类切换按钮(iOS) struct CategoryChip: View { let label: String let icon: String let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 4) { Image(systemName: icon).font(.system(size: 11)) Text(label).font(.system(size: 13)) } .padding(.horizontal, 10).padding(.vertical, 6) .background( Capsule().fill(isSelected ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.08)) ) .foregroundColor(isSelected ? .accentColor : .primary) } .buttonStyle(.plain) } } // MARK: - macOS 条目行(保持原有) 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 { NavigationStack { VStack(spacing: 12) { // URL 输入 HStack { TextField("https://example.com/playlist.m3u", text: $m3uURL) .textFieldStyle(.roundedBorder) .autocorrectionDisabled() #if os(iOS) .textInputAutocapitalization(.never) .keyboardType(.URL) #endif 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) } List { 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: 13)).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)) } } .contentShape(Rectangle()) .onTapGesture { if selectedChannels.contains(ch.url) { selectedChannels.remove(ch.url) } else { selectedChannels.insert(ch.url) } } } } .listStyle(.plain) #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif } Spacer() } .padding(16) .navigationTitle(L.importM3U) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L.cancel) { dismiss() } } if !selectedChannels.isEmpty { ToolbarItem(placement: .confirmationAction) { Button("\(L.importSelected) (\(selectedChannels.count))") { importSelected() } } } } } } 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() } }