- save()改为async+debounce(500ms),不阻塞主线程 - URL查找从O(n)线性扫描改为O(1)索引 - 播放位置保存增加节流(30秒/5秒差值) - 列表分页渲染(先100条,滚动加载) - 退出时强制同步保存位置 - 直播流跳过seek恢复
300 lines
9.7 KiB
Swift
300 lines
9.7 KiB
Swift
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()
|
||
}
|
||
|
||
/// 异步保存 — 不阻塞主线程,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去重
|
||
@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
|
||
}
|
||
|
||
/// 批量添加(M3U导入),跳过已存在的URL,仅触发一次save
|
||
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 }
|
||
}
|