MiniPlayer/Sources/MediaLibraryView.swift

995 lines
38 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
#if os(iOS)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
}
// 底部状态栏
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<String> = []
@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()
}
}