feat: 媒体库 - 播放记忆、多播放列表、M3U导入、模糊搜索、喜欢/标签系统
- MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢 - M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性) - MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI - PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复) - 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放 - 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U
This commit is contained in:
parent
49948049c0
commit
05afadfd11
@ -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: "")
|
||||
}
|
||||
|
||||
117
Sources/M3UParser.swift
Normal file
117
Sources/M3UParser.swift
Normal file
@ -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文件中未找到频道"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
Sources/MediaLibrary.swift
Normal file
244
Sources/MediaLibrary.swift
Normal file
@ -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 }
|
||||
}
|
||||
511
Sources/MediaLibraryView.swift
Normal file
511
Sources/MediaLibraryView.swift
Normal file
@ -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<String> = []
|
||||
|
||||
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<String> = []
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user