MiniPlayer/Sources/MediaLibrary.swift
yumoqing 05afadfd11 feat: 媒体库 - 播放记忆、多播放列表、M3U导入、模糊搜索、喜欢/标签系统
- MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢
- M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性)
- MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI
- PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复)
- 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放
- 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U
2026-06-30 00:28:14 +08:00

245 lines
7.8 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 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: -
/// URLitem
@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
}
/// M3UURL
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 }
}