MiniPlayer/Sources/MediaLibraryView.swift
yumoqing 48011dd4f0 fix: 修复M3U导入后UI冻结问题
- save()改为async+debounce(500ms),不阻塞主线程
- URL查找从O(n)线性扫描改为O(1)索引
- 播放位置保存增加节流(30秒/5秒差值)
- 列表分页渲染(先100条,滚动加载)
- 退出时强制同步保存位置
- 直播流跳过seek恢复
2026-06-30 08:49:18 +08:00

534 lines
20 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
@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<String> = []
@State private var displayLimit: Int = 100 // 100
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 !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(selection: $selectedSidebar) {
Section {
Label(L.allMedia, systemImage: "square.grid.2x2")
.tag(SidebarItem.all)
Label(L.liked, systemImage: "heart.fill")
.tag(SidebarItem.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))
.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))
}
}
}
}
.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)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
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)
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()
//
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 {
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()
}
}