From 48011dd4f0791b54fa6d294958de58a5f459f52b Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 30 Jun 2026 08:49:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DM3U=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=90=8EUI=E5=86=BB=E7=BB=93=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - save()改为async+debounce(500ms),不阻塞主线程 - URL查找从O(n)线性扫描改为O(1)索引 - 播放位置保存增加节流(30秒/5秒差值) - 列表分页渲染(先100条,滚动加载) - 退出时强制同步保存位置 - 直播流跳过seek恢复 --- Sources/MediaLibrary.swift | 121 ++++++++++++++++++++++++--------- Sources/MediaLibraryView.swift | 30 ++++++-- Sources/PlayerBridge.swift | 31 +++++++-- 3 files changed, 138 insertions(+), 44 deletions(-) diff --git a/Sources/MediaLibrary.swift b/Sources/MediaLibrary.swift index f0fe460..187269f 100644 --- a/Sources/MediaLibrary.swift +++ b/Sources/MediaLibrary.swift @@ -53,7 +53,7 @@ struct LibraryPlaylist: Identifiable, Codable, Equatable { } /// 持久化结构 -private struct LibraryData: Codable { +private struct LibraryStore: Codable { var items: [LibraryItem] var playlists: [LibraryPlaylist] } @@ -64,53 +64,90 @@ final class MediaLibrary: ObservableObject { @Published var items: [LibraryItem] = [] @Published var playlists: [LibraryPlaylist] = [] - private let fileURL: URL + private let filePath: URL + + // MARK: - 索引(O(1)查找) + private var urlIndex: [String: String] = [:] // url → itemID + private var idIndex: [String: Int] = [:] // itemID → array index init() { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let dir = appSupport.appendingPathComponent("MiniPlayer", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - self.fileURL = dir.appendingPathComponent("library.json") + self.filePath = dir.appendingPathComponent("library.json") load() } // MARK: - 持久化 private func load() { - guard let data = try? Data(contentsOf: fileURL) else { return } - guard let decoded = try? JSONDecoder().decode(LibraryData.self, from: data) else { return } - items = decoded.items - playlists = decoded.playlists + guard let data = try? Data(contentsOf: filePath), + let store = try? JSONDecoder().decode(LibraryStore.self, from: data) else { return } + items = store.items + playlists = store.playlists + rebuildIndexes() } - func save() { - let data = LibraryData(items: items, playlists: playlists) - guard let encoded = try? JSONEncoder().encode(data) else { return } - try? encoded.write(to: fileURL, options: .atomic) + /// 异步保存 — 不阻塞主线程,M3U几千条数据不卡UI + private var saveTask: DispatchWorkItem? + + private func save() { + // debounce: 合并连续save调用,只在500ms后真正写入 + saveTask?.cancel() + let store = LibraryStore(items: items, playlists: playlists) + let path = filePath + let task = DispatchWorkItem { + guard let data = try? JSONEncoder().encode(store) else { return } + try? data.write(to: path, options: .atomic) + } + saveTask = task + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5, execute: task) + } + + /// 强制同步保存(退出时调用) + func saveSync() { + saveTask?.cancel() + let store = LibraryStore(items: items, playlists: playlists) + guard let data = try? JSONEncoder().encode(store) else { return } + try? data.write(to: filePath, options: .atomic) + } + + private func rebuildIndexes() { + urlIndex.removeAll(keepingCapacity: true) + idIndex.removeAll(keepingCapacity: true) + for (i, item) in items.enumerated() { + urlIndex[item.url] = item.id + idIndex[item.id] = i + } } // MARK: - 条目操作 - /// 添加条目,URL去重,返回已有或新建的item + /// 添加单条,URL去重 @discardableResult func addItem(url: String, name: String, type: String = "url", group: String? = nil, logoURL: String? = nil) -> LibraryItem { - if let existing = items.first(where: { $0.url == url }) { - return existing + if let existingID = urlIndex[url], let idx = idIndex[existingID] { + return items[idx] } let item = LibraryItem(url: url, name: name, type: type, group: group, logoURL: logoURL) + let newIndex = items.count items.append(item) + urlIndex[item.url] = item.id + idIndex[item.id] = newIndex save() return item } - /// 批量添加(M3U导入),跳过已存在的URL + /// 批量添加(M3U导入),跳过已存在的URL,仅触发一次save func addItems(_ newItems: [LibraryItem]) -> Int { var count = 0 - let existingURLs = Set(items.map { $0.url }) for item in newItems { - if !existingURLs.contains(item.url) { + if urlIndex[item.url] == nil { + let newIndex = items.count items.append(item) + urlIndex[item.url] = item.id + idIndex[item.id] = newIndex count += 1 } } @@ -123,20 +160,21 @@ final class MediaLibrary: ObservableObject { for i in playlists.indices { playlists[i].itemIDs.removeAll { $0 == id } } + rebuildIndexes() save() } // MARK: - 喜欢 / 不喜欢 func toggleLike(_ id: String) { - guard let idx = items.firstIndex(where: { $0.id == id }) else { return } + guard let idx = idIndex[id] else { return } items[idx].liked.toggle() if items[idx].liked { items[idx].disliked = false } save() } func toggleDislike(_ id: String) { - guard let idx = items.firstIndex(where: { $0.id == id }) else { return } + guard let idx = idIndex[id] else { return } items[idx].disliked.toggle() if items[idx].disliked { items[idx].liked = false } save() @@ -145,7 +183,7 @@ final class MediaLibrary: ObservableObject { // MARK: - 标签 func addTag(_ tag: String, to id: String) { - guard let idx = items.firstIndex(where: { $0.id == id }) else { return } + guard let idx = idIndex[id] else { return } let t = tag.trimmingCharacters(in: .whitespaces) guard !t.isEmpty, !items[idx].tags.contains(t) else { return } items[idx].tags.append(t) @@ -154,7 +192,7 @@ final class MediaLibrary: ObservableObject { } func removeTag(_ tag: String, from id: String) { - guard let idx = items.firstIndex(where: { $0.id == id }) else { return } + guard let idx = idIndex[id] else { return } items[idx].tags.removeAll { $0 == tag } save() } @@ -164,21 +202,39 @@ final class MediaLibrary: ObservableObject { Array(Set(items.flatMap { $0.tags })).sorted() } - // MARK: - 播放位置 + // MARK: - 播放位置(节流:30秒内只保存一次) + + private var lastPositionSaveTime: Date = .distantPast func savePosition(_ itemID: String, position: Double) { - guard let idx = items.firstIndex(where: { $0.id == itemID }) else { return } + guard let idx = idIndex[itemID] else { return } + // 节流:距上次保存 < 30秒且位置差 < 5秒则跳过 + let now = Date() + if now.timeIntervalSince(lastPositionSaveTime) < 30, + abs(items[idx].lastPosition - position) < 5 { + return + } items[idx].lastPosition = position + lastPositionSaveTime = now save() } - func getPosition(for itemID: String) -> Double { - items.first(where: { $0.id == itemID })?.lastPosition ?? 0 + /// 强制保存位置(退出时) + func savePositionImmediate(_ itemID: String, position: Double) { + guard let idx = idIndex[itemID] else { return } + items[idx].lastPosition = position + saveSync() } - /// 按URL查找库条目 + func getPosition(for itemID: String) -> Double { + guard let idx = idIndex[itemID] else { return 0 } + return items[idx].lastPosition + } + + /// 按URL查找库条目 — O(1) func findItem(byURL url: String) -> LibraryItem? { - items.first { $0.url == url } + guard let id = urlIndex[url], let idx = idIndex[id] else { return nil } + return items[idx] } // MARK: - 播放列表 @@ -216,18 +272,18 @@ final class MediaLibrary: ObservableObject { func items(in playlistID: String) -> [LibraryItem] { guard let pl = playlists.first(where: { $0.id == playlistID }) else { return [] } - return pl.itemIDs.compactMap { id in items.first { $0.id == id } } + return pl.itemIDs.compactMap { id in + guard let idx = idIndex[id] else { return nil } + return items[idx] + } } // MARK: - 搜索(模糊查询) func search(_ text: String, tag: String? = nil, likedOnly: Bool = false) -> [LibraryItem] { items.filter { item in - // 喜欢过滤 if likedOnly && !item.liked { return false } - // 标签过滤 if let tag = tag, !item.tags.contains(tag) { return false } - // 模糊搜索 guard !text.isEmpty else { return true } let q = text.lowercased() return item.name.lowercased().contains(q) @@ -237,8 +293,7 @@ final class MediaLibrary: ObservableObject { } } - // MARK: - 导出 + // MARK: - 辅助 - /// 库条目总数 var count: Int { items.count } } diff --git a/Sources/MediaLibraryView.swift b/Sources/MediaLibraryView.swift index 10afa51..d1cc628 100644 --- a/Sources/MediaLibraryView.swift +++ b/Sources/MediaLibraryView.swift @@ -15,6 +15,7 @@ struct MediaLibraryView: View { @State private var tagInputText = "" @State private var newPlaylistName = "" @State private var selectedItems: Set = [] + @State private var displayLimit: Int = 100 // 分页:先显示100条,滚动加载更多 enum SidebarItem: Hashable { case all @@ -24,7 +25,7 @@ struct MediaLibraryView: View { } // 根据侧边栏选择 + 搜索获取显示列表 - private var displayedItems: [LibraryItem] { + private var allFilteredItems: [LibraryItem] { var result: [LibraryItem] switch selectedSidebar { case .all: @@ -36,7 +37,6 @@ struct MediaLibraryView: View { case .tag(let tag): result = library.search("", tag: tag) } - // 应用搜索过滤 if !searchText.isEmpty { let q = searchText.lowercased() result = result.filter { item in @@ -49,6 +49,11 @@ struct MediaLibraryView: View { return result } + // 分页显示 + private var displayedItems: [LibraryItem] { + Array(allFilteredItems.prefix(displayLimit)) + } + var body: some View { NavigationSplitView { sidebarView @@ -56,6 +61,8 @@ struct MediaLibraryView: View { 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) } @@ -224,12 +231,27 @@ struct MediaLibraryView: View { .foregroundColor(.secondary) Spacer() } } else { - List(selection: $selectedItems) { + List { ForEach(displayedItems) { item in LibraryItemRow(item: item, bridge: bridge, library: library, showTagInput: $showTagInput, tagInputItemID: $tagInputItemID) - .tag(item.id) + .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) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index fe237c8..35b3d96 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -204,10 +204,14 @@ final class PlayerBridge: ObservableObject { if pi.status == .readyToPlay { DispatchQueue.main.async { self?.loadDuration(pi) - // 恢复播放位置(播放记忆) - if savedPosition > 1.0 { - self?.player.seek(to: CMTime(seconds: savedPosition, preferredTimescale: 600)) - print("[MiniPlayer] Resumed at \(savedPosition)s") + // 恢复播放位置(仅对点播内容,直播流跳过) + let dur = self?.cachedDuration ?? 0 + if savedPosition > 1.0 && dur > 0 { + let pos = min(savedPosition, dur - 5) + if pos > 0 { + self?.player.seek(to: CMTime(seconds: pos, preferredTimescale: 600)) + print("[MiniPlayer] Resumed at \(pos)s") + } } } } @@ -502,7 +506,6 @@ final class PlayerBridge: ObservableObject { guard position.isFinite && position > 1.0 else { return } let item = queue[currentIndex] - // 优先用 libraryID,否则按 URL 查找 if let libID = item.libraryID { library.savePosition(libID, position: position) } else if let libItem = library.findItem(byURL: item.url.absoluteString) { @@ -510,6 +513,20 @@ final class PlayerBridge: ObservableObject { } } + /// 强制保存位置(退出时,不等debounce) + func saveCurrentPositionImmediate() { + guard currentIndex >= 0, currentIndex < queue.count else { return } + let position = player.currentTime().seconds + guard position.isFinite && position > 1.0 else { return } + + let item = queue[currentIndex] + if let libID = item.libraryID { + library.savePositionImmediate(libID, position: position) + } else if let libItem = library.findItem(byURL: item.url.absoluteString) { + library.savePositionImmediate(libItem.id, position: position) + } + } + // MARK: - 媒体库操作 /// 从媒体库播放条目(替换当前播放,保存位置) @@ -601,8 +618,8 @@ final class PlayerBridge: ObservableObject { func cleanup() { isTearingDown = true - // 保存当前播放位置(退出前记忆) - saveCurrentPosition() + // 强制保存播放位置(退出前记忆,不等debounce) + saveCurrentPositionImmediate() player.pause()