MiniPlayer/Sources/MediaLibraryView.swift
yumoqing ce5878957e fix(iOS): fix recording, playback compatibility, and media library UI
Recording:
- stopRecording() now only sets stopRequested=true, no longer cancels task
- Task exits polling loop naturally → merges TS segments → exports MP4
- System share sheet + Photos save shown on completion
- Added HEAD request to detect HLS Content-Type for non-.m3u8 URLs
- Faster exit when stopRequested && segments exist

Playback:
- Copy imported files to app Documents sandbox (security-scoped URLs expire after defer)
- Remove Referer header that blocks some streams; use iPhone UA only
- AVURLAsset options only for http/https schemes

Media Library UI (iOS):
- Replace NavigationSplitView with VStack + horizontal category chips
- Search bar at top, swipe actions for queue/delete
- scrollDismissesKeyboard(.interactively) on list
- Tap gesture sends resignFirstResponder to dismiss keyboard
- Compact rows with context menu, inline play button
- Close button in navigation bar
- presentationDetents(.medium) for tag/playlist sheets
2026-07-02 07:44:09 +08:00

993 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 {
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<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()
}
}