MiniPlayer/Sources/MediaLibrary.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

300 lines
9.7 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 Foundation
import Combine
///
struct LibraryItem: Identifiable, Codable, Equatable {
let id: String
var url: String
var name: String
var type: String // "file", "url", "stream", "local"
var lastPosition: Double //
var liked: Bool
var disliked: Bool
var tags: [String]
var addedAt: Date
var group: String? // M3U group-title
var logoURL: String? // M3U tvg-logo
init(id: String = UUID().uuidString, url: String, name: String,
type: String = "url", lastPosition: Double = 0,
liked: Bool = false, disliked: Bool = false,
tags: [String] = [], group: String? = nil, logoURL: String? = nil) {
self.id = id
self.url = url
self.name = name
self.type = type
self.lastPosition = lastPosition
self.liked = liked
self.disliked = disliked
self.tags = tags
self.addedAt = Date()
self.group = group
self.logoURL = logoURL
}
static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool { lhs.id == rhs.id }
}
///
struct LibraryPlaylist: Identifiable, Codable, Equatable {
let id: String
var name: String
var itemIDs: [String]
var createdAt: Date
init(id: String = UUID().uuidString, name: String, itemIDs: [String] = []) {
self.id = id
self.name = name
self.itemIDs = itemIDs
self.createdAt = Date()
}
static func == (lhs: LibraryPlaylist, rhs: LibraryPlaylist) -> Bool { lhs.id == rhs.id }
}
///
private struct LibraryStore: Codable {
var items: [LibraryItem]
var playlists: [LibraryPlaylist]
}
/// JSON
@MainActor
final class MediaLibrary: ObservableObject {
@Published var items: [LibraryItem] = []
@Published var playlists: [LibraryPlaylist] = []
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.filePath = dir.appendingPathComponent("library.json")
load()
}
// MARK: -
private func load() {
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()
}
/// 线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: -
/// URL
@discardableResult
func addItem(url: String, name: String, type: String = "url",
group: String? = nil, logoURL: String? = nil) -> LibraryItem {
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
}
/// M3UURLsave
func addItems(_ newItems: [LibraryItem]) -> Int {
var count = 0
for item in newItems {
if urlIndex[item.url] == nil {
let newIndex = items.count
items.append(item)
urlIndex[item.url] = item.id
idIndex[item.id] = newIndex
count += 1
}
}
if count > 0 { save() }
return count
}
func removeItem(_ id: String) {
items.removeAll { $0.id == id }
for i in playlists.indices {
playlists[i].itemIDs.removeAll { $0 == id }
}
rebuildIndexes()
save()
}
// MARK: - /
func toggleLike(_ id: String) {
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 = idIndex[id] else { return }
items[idx].disliked.toggle()
if items[idx].disliked { items[idx].liked = false }
save()
}
// MARK: -
func addTag(_ tag: String, to id: String) {
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)
items[idx].tags.sort()
save()
}
func removeTag(_ tag: String, from id: String) {
guard let idx = idIndex[id] else { return }
items[idx].tags.removeAll { $0 == tag }
save()
}
/// 使+
var allTags: [String] {
Array(Set(items.flatMap { $0.tags })).sorted()
}
// MARK: - 30
private var lastPositionSaveTime: Date = .distantPast
func savePosition(_ itemID: String, position: Double) {
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 savePositionImmediate(_ itemID: String, position: Double) {
guard let idx = idIndex[itemID] else { return }
items[idx].lastPosition = position
saveSync()
}
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? {
guard let id = urlIndex[url], let idx = idIndex[id] else { return nil }
return items[idx]
}
// MARK: -
func createPlaylist(name: String) -> LibraryPlaylist {
let pl = LibraryPlaylist(name: name)
playlists.append(pl)
save()
return pl
}
func deletePlaylist(_ id: String) {
playlists.removeAll { $0.id == id }
save()
}
func renamePlaylist(_ id: String, name: String) {
guard let idx = playlists.firstIndex(where: { $0.id == id }) else { return }
playlists[idx].name = name
save()
}
func addToPlaylist(_ playlistID: String, itemID: String) {
guard let idx = playlists.firstIndex(where: { $0.id == playlistID }) else { return }
guard !playlists[idx].itemIDs.contains(itemID) else { return }
playlists[idx].itemIDs.append(itemID)
save()
}
func removeFromPlaylist(_ playlistID: String, itemID: String) {
guard let idx = playlists.firstIndex(where: { $0.id == playlistID }) else { return }
playlists[idx].itemIDs.removeAll { $0 == itemID }
save()
}
func items(in playlistID: String) -> [LibraryItem] {
guard let pl = playlists.first(where: { $0.id == playlistID }) else { return [] }
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)
|| item.url.lowercased().contains(q)
|| item.tags.contains(where: { $0.lowercased().contains(q) })
|| (item.group?.lowercased().contains(q) ?? false)
}
}
// MARK: -
var count: Int { items.count }
}