fix: 修复M3U导入后UI冻结问题

- save()改为async+debounce(500ms),不阻塞主线程
- URL查找从O(n)线性扫描改为O(1)索引
- 播放位置保存增加节流(30秒/5秒差值)
- 列表分页渲染(先100条,滚动加载)
- 退出时强制同步保存位置
- 直播流跳过seek恢复
This commit is contained in:
yumoqing 2026-06-30 08:49:18 +08:00
parent 05afadfd11
commit 48011dd4f0
3 changed files with 138 additions and 44 deletions

View File

@ -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)
/// 线M3UUI
private var saveTask: DispatchWorkItem?
private func save() {
// debounce: save500ms
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: -
/// URLitem
/// 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
}
/// M3UURL
/// M3UURLsave
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 }
}

View File

@ -15,6 +15,7 @@ struct MediaLibraryView: View {
@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
@ -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)

View File

@ -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()