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 } }