- MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢 - M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性) - MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI - PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复) - 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放 - 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U
245 lines
7.8 KiB
Swift
245 lines
7.8 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 LibraryData: Codable {
|
||
var items: [LibraryItem]
|
||
var playlists: [LibraryPlaylist]
|
||
}
|
||
|
||
/// 媒体库管理器 — JSON文件持久化
|
||
@MainActor
|
||
final class MediaLibrary: ObservableObject {
|
||
@Published var items: [LibraryItem] = []
|
||
@Published var playlists: [LibraryPlaylist] = []
|
||
|
||
private let fileURL: URL
|
||
|
||
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")
|
||
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
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// MARK: - 条目操作
|
||
|
||
/// 添加条目,URL去重,返回已有或新建的item
|
||
@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
|
||
}
|
||
let item = LibraryItem(url: url, name: name, type: type, group: group, logoURL: logoURL)
|
||
items.append(item)
|
||
save()
|
||
return item
|
||
}
|
||
|
||
/// 批量添加(M3U导入),跳过已存在的URL
|
||
func addItems(_ newItems: [LibraryItem]) -> Int {
|
||
var count = 0
|
||
let existingURLs = Set(items.map { $0.url })
|
||
for item in newItems {
|
||
if !existingURLs.contains(item.url) {
|
||
items.append(item)
|
||
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 }
|
||
}
|
||
save()
|
||
}
|
||
|
||
// MARK: - 喜欢 / 不喜欢
|
||
|
||
func toggleLike(_ id: String) {
|
||
guard let idx = items.firstIndex(where: { $0.id == 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 }
|
||
items[idx].disliked.toggle()
|
||
if items[idx].disliked { items[idx].liked = false }
|
||
save()
|
||
}
|
||
|
||
// MARK: - 标签
|
||
|
||
func addTag(_ tag: String, to id: String) {
|
||
guard let idx = items.firstIndex(where: { $0.id == 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 = items.firstIndex(where: { $0.id == id }) else { return }
|
||
items[idx].tags.removeAll { $0 == tag }
|
||
save()
|
||
}
|
||
|
||
/// 所有已使用的标签(去重+排序)
|
||
var allTags: [String] {
|
||
Array(Set(items.flatMap { $0.tags })).sorted()
|
||
}
|
||
|
||
// MARK: - 播放位置
|
||
|
||
func savePosition(_ itemID: String, position: Double) {
|
||
guard let idx = items.firstIndex(where: { $0.id == itemID }) else { return }
|
||
items[idx].lastPosition = position
|
||
save()
|
||
}
|
||
|
||
func getPosition(for itemID: String) -> Double {
|
||
items.first(where: { $0.id == itemID })?.lastPosition ?? 0
|
||
}
|
||
|
||
/// 按URL查找库条目
|
||
func findItem(byURL url: String) -> LibraryItem? {
|
||
items.first { $0.url == url }
|
||
}
|
||
|
||
// 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 items.first { $0.id == id } }
|
||
}
|
||
|
||
// 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 }
|
||
}
|