From 05afadfd1144103333d78acaecd7ec6df9099fc0 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 30 Jun 2026 00:28:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AA=92=E4=BD=93=E5=BA=93=20-=20?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E8=AE=B0=E5=BF=86=E3=80=81=E5=A4=9A=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E3=80=81M3U=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E3=80=81=E6=A8=A1=E7=B3=8A=E6=90=9C=E7=B4=A2=E3=80=81=E5=96=9C?= =?UTF-8?q?=E6=AC=A2/=E6=A0=87=E7=AD=BE=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢 - M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性) - MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI - PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复) - 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放 - 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U --- Sources/Localization.swift | 44 +++ Sources/M3UParser.swift | 117 ++++++++ Sources/MediaLibrary.swift | 244 ++++++++++++++++ Sources/MediaLibraryView.swift | 511 +++++++++++++++++++++++++++++++++ Sources/MiniPlayerApp.swift | 8 + Sources/PlayerBridge.swift | 126 +++++++- 6 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 Sources/M3UParser.swift create mode 100644 Sources/MediaLibrary.swift create mode 100644 Sources/MediaLibraryView.swift diff --git a/Sources/Localization.swift b/Sources/Localization.swift index 1936b42..cf280f2 100644 --- a/Sources/Localization.swift +++ b/Sources/Localization.swift @@ -55,4 +55,48 @@ enum L { static let scanQRCode = NSLocalizedString("scan_qr_code", value: "Scan QR Code", comment: "") static let scanHint = NSLocalizedString("scan_hint", value: "Point camera at a QR code containing a media URL", comment: "") static let detectedCode = NSLocalizedString("detected_code", value: "Detected:", comment: "") + + // 媒体库 + static let mediaLibrary = NSLocalizedString("media_library", value: "Media Library", comment: "") + static let allMedia = NSLocalizedString("all_media", value: "All Media", comment: "") + static let liked = NSLocalizedString("liked", value: "Liked", comment: "") + static let tags = NSLocalizedString("tags", value: "Tags", comment: "") + static let searchChannels = NSLocalizedString("search_channels", value: "Search channels...", comment: "") + static let importM3U = NSLocalizedString("import_m3u", value: "Import M3U", comment: "") + static let addLocal = NSLocalizedString("add_local", value: "Add Local", comment: "") + static let addAllToQueue = NSLocalizedString("add_all_queue", value: "Queue All", comment: "") + static let libraryEmpty = NSLocalizedString("library_empty", value: "Library is empty. Import M3U or add local files.", comment: "") + static let noSearchResult = NSLocalizedString("no_search_result", value: "No results found", comment: "") + static let totalItems = NSLocalizedString("total_items", value: "total", comment: "") + static let addedToQueue = NSLocalizedString("added_to_queue", value: "items added to queue", comment: "") + + // 播放列表管理 + static let newPlaylist = NSLocalizedString("new_playlist", value: "New Playlist", comment: "") + static let playlistName = NSLocalizedString("playlist_name", value: "Playlist name", comment: "") + static let create = NSLocalizedString("create", value: "Create", comment: "") + static let addPlaylist = NSLocalizedString("add_playlist", value: "New Playlist", comment: "") + + // 标签 + static let addTag = NSLocalizedString("add_tag", value: "Add Tag", comment: "") + static let tagName = NSLocalizedString("tag_name", value: "Tag name (comma separated for multiple)", comment: "") + static let removeTag = NSLocalizedString("remove_tag", value: "Remove Tag", comment: "") + + // 操作 + static let playNow = NSLocalizedString("play_now", value: "Play Now", comment: "") + static let addToQueue = NSLocalizedString("add_to_queue", value: "Add to Queue", comment: "") + static let addToPlaylist = NSLocalizedString("add_to_playlist", value: "Add to Playlist", comment: "") + static let delete = NSLocalizedString("delete", value: "Delete", comment: "") + + // M3U 导入 + static let fetch = NSLocalizedString("fetch", value: "Fetch", comment: "") + static let fetching = NSLocalizedString("fetching", value: "Fetching...", comment: "") + static let fetched = NSLocalizedString("fetched", value: "Fetched", comment: "") + static let channelsFound = NSLocalizedString("channels_found", value: "channels", comment: "") + static let selectAll = NSLocalizedString("select_all", value: "Select All", comment: "") + static let deselectAll = NSLocalizedString("deselect_all", value: "Deselect All", comment: "") + static let importSelected = NSLocalizedString("import_selected", value: "Import Selected", comment: "") + static let imported = NSLocalizedString("imported", value: "Imported", comment: "") + static let channelsAdded = NSLocalizedString("channels_added", value: "channels", comment: "") + static let fetchFailed = NSLocalizedString("fetch_failed", value: "Fetch failed", comment: "") + static let localFilesAdded = NSLocalizedString("local_files_added", value: "local files", comment: "") } diff --git a/Sources/M3UParser.swift b/Sources/M3UParser.swift new file mode 100644 index 0000000..be025d7 --- /dev/null +++ b/Sources/M3UParser.swift @@ -0,0 +1,117 @@ +import Foundation + +/// M3U 播放列表解析器(支持 IPTV EXTINF 格式) +enum M3UParser { + + struct Channel { + var name: String + var url: String + var group: String? + var logoURL: String? + var type: String // "stream" or "url" + } + + /// 从字符串内容解析 M3U + static func parse(_ content: String) -> [Channel] { + let lines = content.components(separatedBy: .newlines) + var channels: [Channel] = [] + var pendingName: String? + var pendingGroup: String? + var pendingLogo: String? + + for rawLine in lines { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } + + // 跳过 M3U 头部 + if line.hasPrefix("#EXTM3U") { continue } + + // 跳过其他注释(非 EXTINF) + if line.hasPrefix("#") && !line.hasPrefix("#EXTINF") { continue } + + if line.hasPrefix("#EXTINF") { + // 解析 #EXTINF:-1 tvg-name="..." group-title="..." tvg-logo="...",Channel Name + let info = String(line.dropFirst("#EXTINF".count)) + + // 提取逗号后的频道名 + if let commaIdx = info.lastIndex(of: ",") { + let afterComma = String(info[info.index(after: commaIdx)...]) + pendingName = afterComma.trimmingCharacters(in: .whitespaces) + } + + // 提取属性 + let attrs = parseAttributes(info) + + if let tvgName = attrs["tvg-name"], (pendingName ?? "").isEmpty { + pendingName = tvgName + } + pendingGroup = attrs["group-title"] + pendingLogo = attrs["tvg-logo"] + + } else if !line.hasPrefix("#") { + // URL 行 + let url = line + let name = (pendingName ?? "").isEmpty + ? (URL(string: url)?.host ?? url) + : pendingName! + + let isStream = url.contains(".m3u8") || url.contains(".m3u") + || url.contains("://") && !url.hasSuffix(".mp4") + + channels.append(Channel( + name: name, + url: url, + group: pendingGroup, + logoURL: pendingLogo, + type: isStream ? "stream" : "url" + )) + + pendingName = nil + pendingGroup = nil + pendingLogo = nil + } + } + + return channels + } + + /// 从 URL 异步下载并解析 M3U + static func fetchAndParse(url: URL) async throws -> [Channel] { + let (data, _) = try await URLSession.shared.data(from: url) + guard let content = String(data: data, encoding: .utf8) else { + throw M3UError.invalidEncoding + } + return parse(content) + } + + // MARK: - 私有 + + private static func parseAttributes(_ line: String) -> [String: String] { + var attrs: [String: String] = [:] + // 匹配 key="value" + let pattern = #"([a-zA-Z\-]+)="([^"]*)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return attrs } + let matches = regex.matches(in: line, range: NSRange(line.startIndex..., in: line)) + for m in matches { + guard m.numberOfRanges == 3, + let keyRange = Range(m.range(at: 1), in: line), + let valRange = Range(m.range(at: 2), in: line) else { continue } + attrs[String(line[keyRange])] = String(line[valRange]) + } + return attrs + } + + enum M3UError: Error, LocalizedError { + case invalidEncoding + case invalidURL + case emptyResult + + var errorDescription: String? { + switch self { + case .invalidEncoding: return "无法解码M3U文件(需要UTF-8编码)" + case .invalidURL: return "无效的URL" + case .emptyResult: return "M3U文件中未找到频道" + } + } + } +} diff --git a/Sources/MediaLibrary.swift b/Sources/MediaLibrary.swift new file mode 100644 index 0000000..f0fe460 --- /dev/null +++ b/Sources/MediaLibrary.swift @@ -0,0 +1,244 @@ +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 } +} diff --git a/Sources/MediaLibraryView.swift b/Sources/MediaLibraryView.swift new file mode 100644 index 0000000..10afa51 --- /dev/null +++ b/Sources/MediaLibraryView.swift @@ -0,0 +1,511 @@ +import SwiftUI + +// MARK: - 媒体库面板主视图 + +struct MediaLibraryView: View { + @ObservedObject var library: MediaLibrary + @ObservedObject var bridge: PlayerBridge + + @State private var searchText = "" + @State private var selectedSidebar: SidebarItem = .all + @State private var showImportSheet = false + @State private var showNewPlaylistSheet = false + @State private var showTagInput = false + @State private var tagInputItemID: String? + @State private var tagInputText = "" + @State private var newPlaylistName = "" + @State private var selectedItems: Set = [] + + enum SidebarItem: Hashable { + case all + case liked + case playlist(String) // playlist ID + case tag(String) // tag name + } + + // 根据侧边栏选择 + 搜索获取显示列表 + private var displayedItems: [LibraryItem] { + var result: [LibraryItem] + switch selectedSidebar { + case .all: + result = library.items + case .liked: + result = library.items.filter { $0.liked } + case .playlist(let id): + result = library.items(in: id) + case .tag(let tag): + result = library.search("", tag: tag) + } + // 应用搜索过滤 + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { item in + item.name.lowercased().contains(q) + || item.url.lowercased().contains(q) + || item.tags.contains(where: { $0.lowercased().contains(q) }) + || (item.group?.lowercased().contains(q) ?? false) + } + } + return result + } + + var body: some View { + NavigationSplitView { + sidebarView + } detail: { + contentView + } + .frame(minWidth: 650, minHeight: 450) + .sheet(isPresented: $showImportSheet) { + ImportM3UView(library: library, bridge: bridge) + } + .sheet(isPresented: $showNewPlaylistSheet) { + VStack(spacing: 16) { + Text(L.newPlaylist).font(.headline) + TextField(L.playlistName, text: $newPlaylistName) + .textFieldStyle(.roundedBorder) + HStack { + Button(L.cancel) { showNewPlaylistSheet = false; newPlaylistName = "" } + Spacer() + Button(L.create) { + if !newPlaylistName.isEmpty { + _ = library.createPlaylist(name: newPlaylistName) + newPlaylistName = "" + showNewPlaylistSheet = false + } + } + .keyboardShortcut(.defaultAction) + .disabled(newPlaylistName.isEmpty) + } + } + .padding(20) + .frame(width: 350) + } + .sheet(isPresented: $showTagInput) { + VStack(spacing: 16) { + Text(L.addTag).font(.headline) + TextField(L.tagName, text: $tagInputText) + .textFieldStyle(.roundedBorder) + HStack { + Button(L.cancel) { showTagInput = false; tagInputText = "" } + Spacer() + Button(L.add) { + if let id = tagInputItemID, !tagInputText.isEmpty { + // 支持逗号分隔多标签 + for tag in tagInputText.split(separator: ",") { + library.addTag(String(tag.trimmingCharacters(in: .whitespaces)), to: id) + } + tagInputText = "" + showTagInput = false + } + } + .keyboardShortcut(.defaultAction) + .disabled(tagInputText.isEmpty) + } + } + .padding(20) + .frame(width: 350) + } + } + + // MARK: - 侧边栏 + + private var sidebarView: some View { + List(selection: $selectedSidebar) { + Section { + Label(L.allMedia, systemImage: "square.grid.2x2") + .tag(SidebarItem.all) + Label(L.liked, systemImage: "heart.fill") + .tag(SidebarItem.liked) + } + + Section(L.playlist) { + ForEach(library.playlists) { pl in + HStack { + Label(pl.name, systemImage: "music.note.list") + Spacer() + Text("\(pl.itemIDs.count)") + .font(.caption2).foregroundColor(.secondary) + } + .tag(SidebarItem.playlist(pl.id)) + .contextMenu { + Button(L.delete, role: .destructive) { + library.deletePlaylist(pl.id) + if case .playlist(let id) = selectedSidebar, id == pl.id { + selectedSidebar = .all + } + } + } + } + Button { + showNewPlaylistSheet = true + } label: { + Label(L.addPlaylist, systemImage: "plus.circle") + } + .buttonStyle(.plain) + } + + if !library.allTags.isEmpty { + Section(L.tags) { + ForEach(library.allTags, id: \.self) { tag in + Label(tag, systemImage: "tag") + .tag(SidebarItem.tag(tag)) + } + } + } + } + .listStyle(.sidebar) + .frame(minWidth: 180) + } + + // MARK: - 内容区 + + private var contentView: some View { + VStack(spacing: 0) { + // 顶部工具栏 + HStack(spacing: 8) { + // 搜索框 + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField(L.searchChannels, text: $searchText) + .textFieldStyle(.plain) + if !searchText.isEmpty { + Button { searchText = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.15))) + .frame(maxWidth: 300) + + Spacer() + + // 导入 M3U + Button { + showImportSheet = true + } label: { + Label(L.importM3U, systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .controlSize(.small) + + // 添加本地文件入库 + Button { + bridge.importLocalToLibrary() + } label: { + Label(L.addLocal, systemImage: "folder.badge.plus") + } + .buttonStyle(.bordered) + .controlSize(.small) + + // 全部加入队列 + if !displayedItems.isEmpty { + Button { + addDisplayedToQueue() + } label: { + Label(L.addAllToQueue, systemImage: "text.badge.plus") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + + Divider() + + // 条目列表 + if displayedItems.isEmpty { + VStack { Spacer() + Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult) + .foregroundColor(.secondary) + Spacer() } + } else { + List(selection: $selectedItems) { + ForEach(displayedItems) { item in + LibraryItemRow(item: item, bridge: bridge, library: library, + showTagInput: $showTagInput, + tagInputItemID: $tagInputItemID) + .tag(item.id) + } + } + .listStyle(.inset) + } + + // 底部状态栏 + HStack { + Text("\(displayedItems.count) \(L.itemsCount)") + .font(.caption).foregroundColor(.secondary) + Spacer() + Text("\(library.count) \(L.totalItems)") + .font(.caption).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 4) + } + } + + // MARK: - 操作 + + private func addDisplayedToQueue() { + for item in displayedItems { + if let url = URL(string: item.url) { + bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id) + } + } + bridge.showToastMsg("\(displayedItems.count) \(L.addedToQueue)") + } +} + +// MARK: - 条目行 + +struct LibraryItemRow: View { + let item: LibraryItem + @ObservedObject var bridge: PlayerBridge + @ObservedObject var library: MediaLibrary + @Binding var showTagInput: Bool + @Binding var tagInputItemID: String? + + var body: some View { + HStack(spacing: 10) { + // 类型图标 + Image(systemName: iconForType(item.type)) + .foregroundColor(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 3) { + Text(item.name).font(.system(size: 13)).lineLimit(1) + HStack(spacing: 4) { + Text(item.url) + .font(.caption2).foregroundColor(.secondary).lineLimit(1) + if let group = item.group, !group.isEmpty { + Text("[\(group)]") + .font(.caption2).foregroundColor(.blue.opacity(0.7)) + } + } + if !item.tags.isEmpty { + HStack(spacing: 4) { + ForEach(item.tags, id: \.self) { tag in + Text(tag) + .font(.system(size: 10)) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor.opacity(0.15))) + } + } + } + } + + Spacer() + + // 喜欢 + Button { + library.toggleLike(item.id) + } label: { + Image(systemName: item.liked ? "heart.fill" : "heart") + .foregroundColor(item.liked ? .red : .secondary) + } + .buttonStyle(.plain) + + // 不喜欢 + Button { + library.toggleDislike(item.id) + } label: { + Image(systemName: item.disliked ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .foregroundColor(item.disliked ? .orange : .secondary) + } + .buttonStyle(.plain) + + // 播放 + Button { + bridge.playFromLibrary(item) + } label: { + Image(systemName: "play.fill") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + .contextMenu { + Button(L.playNow) { bridge.playFromLibrary(item) } + Button(L.addToQueue) { + if let url = URL(string: item.url) { + bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id) + } + } + Divider() + Button(L.addTag) { + tagInputItemID = item.id + showTagInput = true + } + if !item.tags.isEmpty { + Menu(L.removeTag) { + ForEach(item.tags, id: \.self) { tag in + Button(tag) { library.removeTag(tag, from: item.id) } + } + } + } + Divider() + Menu(L.addToPlaylist) { + ForEach(library.playlists) { pl in + Button(pl.name) { library.addToPlaylist(pl.id, itemID: item.id) } + } + } + Divider() + Button(L.delete, role: .destructive) { library.removeItem(item.id) } + } + } + + private func iconForType(_ type: String) -> String { + switch type { + case "stream": return "antenna.radiowaves.left.and.right" + case "file", "local": return "doc.fill" + default: return "link" + } + } +} + +// MARK: - 导入 M3U 弹窗 + +struct ImportM3UView: View { + @ObservedObject var library: MediaLibrary + @ObservedObject var bridge: PlayerBridge + @Environment(\.dismiss) private var dismiss + + @State private var m3uURL = "" + @State private var isLoading = false + @State private var statusMessage = "" + @State private var parsedChannels: [M3UParser.Channel] = [] + @State private var selectedChannels: Set = [] + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 16) { + Text(L.importM3U).font(.headline) + + // URL 输入 + HStack { + TextField("https://example.com/playlist.m3u", text: $m3uURL) + .textFieldStyle(.roundedBorder) + Button(L.fetch) { fetchM3U() } + .disabled(m3uURL.isEmpty || isLoading) + } + + if isLoading { + ProgressView(L.fetching) + } + + if let error = errorMessage { + Text(error).foregroundColor(.red).font(.caption) + } + + if !statusMessage.isEmpty { + Text(statusMessage).font(.caption).foregroundColor(.secondary) + } + + // 频道列表 + if !parsedChannels.isEmpty { + HStack { + Text("\(parsedChannels.count) \(L.channelsFound)") + .font(.caption).foregroundColor(.secondary) + Spacer() + Button(L.selectAll) { + selectedChannels = Set(parsedChannels.map { $0.url }) + } + .font(.caption) + Button(L.deselectAll) { selectedChannels = [] } + .font(.caption) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(parsedChannels, id: \.url) { ch in + HStack { + Image(systemName: selectedChannels.contains(ch.url) ? "checkmark.square.fill" : "square") + .foregroundColor(selectedChannels.contains(ch.url) ? .accentColor : .secondary) + VStack(alignment: .leading) { + Text(ch.name).font(.system(size: 12)).lineLimit(1) + Text(ch.url).font(.system(size: 10)).foregroundColor(.secondary).lineLimit(1) + } + Spacer() + if let group = ch.group { + Text(group).font(.system(size: 10)).foregroundColor(.blue.opacity(0.7)) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { + if selectedChannels.contains(ch.url) { + selectedChannels.remove(ch.url) + } else { + selectedChannels.insert(ch.url) + } + } + } + } + } + .frame(maxHeight: 300) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.08))) + + HStack { + Button(L.cancel) { dismiss() } + Spacer() + Button("\(L.importSelected) (\(selectedChannels.count))") { + importSelected() + } + .keyboardShortcut(.defaultAction) + .disabled(selectedChannels.isEmpty) + } + } else { + HStack { + Spacer() + Button(L.close) { dismiss() } + } + } + } + .padding(20) + .frame(width: 550, height: 500) + } + + private func fetchM3U() { + guard let url = URL(string: m3uURL) else { + errorMessage = L.invalidURL + return + } + isLoading = true + errorMessage = nil + statusMessage = "" + + Task { + do { + let channels = try await M3UParser.fetchAndParse(url: url) + await MainActor.run { + parsedChannels = channels + selectedChannels = Set(channels.map { $0.url }) + isLoading = false + statusMessage = "\(L.fetched) \(channels.count) \(L.channelsFound)" + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = "\(L.fetchFailed): \(error.localizedDescription)" + } + } + } + } + + private func importSelected() { + var newItems: [LibraryItem] = [] + for ch in parsedChannels where selectedChannels.contains(ch.url) { + newItems.append(LibraryItem( + url: ch.url, name: ch.name, type: ch.type, + tags: ch.group.map { [$0] } ?? [], + group: ch.group, logoURL: ch.logoURL + )) + } + let added = library.addItems(newItems) + bridge.showToastMsg("\(L.imported) \(added) \(L.channelsAdded)") + dismiss() + } +} diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 5cc5a03..5314dca 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -103,6 +103,11 @@ struct MiniPlayerApp: App { Divider() Button(L.addURL) { bridge.showURLDialog = true } .keyboardShortcut("u", modifiers: .command) + Divider() + Button(L.mediaLibrary) { bridge.toggleLibraryWindow() } + .keyboardShortcut("l", modifiers: [.command, .shift]) + Button(L.importM3U) { bridge.toggleLibraryWindow() } + .keyboardShortcut("i", modifiers: [.command, .shift]) } } #endif @@ -438,6 +443,9 @@ struct ControlToolbar: View { // 播放列表 TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() } + + // 媒体库 + TB(icon: "books.vertical") { bridge.toggleLibraryWindow(); onTouch() } } } .padding(.horizontal, 12) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 44e4d04..fe237c8 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -11,6 +11,7 @@ struct MediaItem: Identifiable, Equatable { var url: URL var name: String var mediaType: String + var libraryID: String? // 关联媒体库条目ID(用于播放记忆) static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id } } @@ -71,6 +72,7 @@ final class PlayerBridge: ObservableObject { // MARK: - 内部状态 let player = AVPlayer() + let library = MediaLibrary() // 用纯 Swift Timer 代替 addPeriodicTimeObserver // addPeriodicTimeObserver 内部的 FigNotificationCenter weak listener 机制 // 与 autorelease pool drain 存在竞态,导致双重释放野指针崩溃 @@ -83,6 +85,7 @@ final class PlayerBridge: ObservableObject { #if os(macOS) private var fullscreenWindow: NSWindow? private var playlistWindow: NSWindow? + private var libraryWindow: NSWindow? private var fullscreenEventMonitor: Any? let screenRecorder = PlayerRecorder() #endif @@ -128,6 +131,7 @@ final class PlayerBridge: ObservableObject { // MARK: - 播放控制 func togglePlayPause() { if player.timeControlStatus == .playing { + saveCurrentPosition() player.pause() isPlaying = false } else { @@ -156,6 +160,10 @@ final class PlayerBridge: ObservableObject { func playIndex(_ index: Int) { guard index >= 0 && index < queue.count else { return } + + // 保存当前播放位置(播放记忆) + saveCurrentPosition() + currentIndex = index let item = queue[index] print("[MiniPlayer] playIndex(\(index)): \(item.url.absoluteString)") @@ -182,11 +190,26 @@ final class PlayerBridge: ObservableObject { currentTimeText = "00:00" totalTimeText = "00:00" + // 查找媒体库条目,获取保存的位置 + let savedPosition: Double = { + if let libID = item.libraryID { + return library.getPosition(for: libID) + } + return library.findItem(byURL: item.url.absoluteString)?.lastPosition ?? 0 + }() + // KVO 可能从非主线程回调,用 DispatchQueue.main.async itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in print("[MiniPlayer] KVO status changed: \(pi.status.rawValue), error=\(pi.error?.localizedDescription ?? "none")") if pi.status == .readyToPlay { - DispatchQueue.main.async { self?.loadDuration(pi) } + DispatchQueue.main.async { + self?.loadDuration(pi) + // 恢复播放位置(播放记忆) + if savedPosition > 1.0 { + self?.player.seek(to: CMTime(seconds: savedPosition, preferredTimescale: 600)) + print("[MiniPlayer] Resumed at \(savedPosition)s") + } + } } } @@ -405,7 +428,10 @@ final class PlayerBridge: ObservableObject { } func addItem(url: URL, name: String, type: String) { - queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type)) + // 自动入库 + let libItem = library.addItem(url: url.absoluteString, name: name, type: type) + queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, + mediaType: type, libraryID: libItem.id)) if queue.count == 1 { playIndex(0) } } @@ -467,6 +493,98 @@ final class PlayerBridge: ObservableObject { #endif } + // MARK: - 播放位置记忆 + + /// 保存当前播放位置到媒体库 + func saveCurrentPosition() { + guard currentIndex >= 0, currentIndex < queue.count else { return } + let position = player.currentTime().seconds + guard position.isFinite && position > 1.0 else { return } + + let item = queue[currentIndex] + // 优先用 libraryID,否则按 URL 查找 + if let libID = item.libraryID { + library.savePosition(libID, position: position) + } else if let libItem = library.findItem(byURL: item.url.absoluteString) { + library.savePosition(libItem.id, position: position) + } + } + + // MARK: - 媒体库操作 + + /// 从媒体库播放条目(替换当前播放,保存位置) + func playFromLibrary(_ item: LibraryItem) { + guard let url = URL(string: item.url) else { + showToast(L.invalidURL) + return + } + // 添加到队列并播放 + let mediaItem = MediaItem(id: UUID().uuidString, url: url, name: item.name, + mediaType: item.type, libraryID: item.id) + queue.append(mediaItem) + playIndex(queue.count - 1) + } + + /// 添加到队列但不播放(用于批量加入) + func addItemNoPlay(url: URL, name: String, type: String, libraryID: String? = nil) { + queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, + mediaType: type, libraryID: libraryID)) + // 如果队列为空则自动播放第一个 + if queue.count == 1 { playIndex(0) } + } + + /// 导入本地文件到媒体库 + func importLocalToLibrary() { + #if os(macOS) + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [ + .movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio + ] + if panel.runModal() == .OK { + var count = 0 + for url in panel.urls { + library.addItem(url: url.absoluteString, name: url.lastPathComponent, type: "local") + addItemNoPlay(url: url, name: url.lastPathComponent, type: "local", + libraryID: library.findItem(byURL: url.absoluteString)?.id) + count += 1 + } + showToastMsg("\(L.imported) \(count) \(L.localFilesAdded)") + } + #endif + } + + // MARK: - 媒体库窗口 + + func toggleLibraryWindow() { + #if os(macOS) + if let win = libraryWindow, win.isVisible { + win.close(); libraryWindow = nil; return + } + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 550), + styleMask: [.titled, .closable, .resizable, .utilityWindow], backing: .buffered, defer: false + ) + panel.title = L.mediaLibrary + panel.isReleasedWhenClosed = false + panel.isMovableByWindowBackground = true + panel.hidesOnDeactivate = false + panel.becomesKeyOnlyIfNeeded = true + panel.center() + panel.contentView = NSHostingView(rootView: MediaLibraryView(library: library, bridge: self)) + panel.makeKeyAndOrderFront(NSApp) + libraryWindow = panel + #endif + } + + // MARK: - 公开 Toast + + func showToastMsg(_ message: String) { + showToast(message) + } + // MARK: - Toast private func showToast(_ message: String) { toastMessage = message @@ -482,6 +600,10 @@ final class PlayerBridge: ObservableObject { /// 必须在 PlayerBridge 被释放之前调用 func cleanup() { isTearingDown = true + + // 保存当前播放位置(退出前记忆) + saveCurrentPosition() + player.pause() #if os(macOS)