fix: 修复M3U导入后UI冻结问题
- save()改为async+debounce(500ms),不阻塞主线程 - URL查找从O(n)线性扫描改为O(1)索引 - 播放位置保存增加节流(30秒/5秒差值) - 列表分页渲染(先100条,滚动加载) - 退出时强制同步保存位置 - 直播流跳过seek恢复
This commit is contained in:
parent
05afadfd11
commit
48011dd4f0
@ -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 }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user