MiniPlayer/Sources/MediaLibraryView.swift
yumoqing d22a5037b8 feat: iOS/macOS 双平台打包支持
- Package.swift: 重构为 MiniPlayerCore(共享库) + MiniPlayer(macOS) + MiniPlayeriOS(iOS) 三 target
- 所有源文件添加 #if os(macOS) / #if os(iOS) 条件编译
- HLSRecorder.swift: iOS 纯 Swift HLS 录制器(下载TS+合并+AVAssetExportSession转MP4)
- scripts/build-macos.sh: macOS 打包脚本 (.app + .dmg)
- scripts/build-ios.sh: iOS 打包脚本 (xcodegen + xcodebuild archive → .ipa)
- .gitignore: 排除构建产物
2026-07-01 08:29:16 +08:00

616 lines
23 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 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> = []
@State private var displayLimit: Int = 100 // 100
@State private var showUnavailable: Bool = false //
enum SidebarItem: Hashable {
case all
case liked
case playlist(String) // playlist ID
case tag(String) // tag name
}
// +
private var allFilteredItems: [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 !showUnavailable {
result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable }
}
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
}
//
private var displayedItems: [LibraryItem] {
Array(allFilteredItems.prefix(displayLimit))
}
var body: some View {
NavigationSplitView {
sidebarView
} detail: {
contentView
}
.frame(minWidth: 650, minHeight: 450)
.onChange(of: selectedSidebar) { _ in displayLimit = 100 }
.onChange(of: searchText) { _ in displayLimit = 100 }
.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 {
Section {
Label(L.allMedia, systemImage: "square.grid.2x2")
.tag(SidebarItem.all)
.onTapGesture { selectedSidebar = .all }
Label(L.liked, systemImage: "heart.fill")
.tag(SidebarItem.liked)
.onTapGesture { selectedSidebar = .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))
.onTapGesture { selectedSidebar = .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))
.onTapGesture { selectedSidebar = .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)
}
//
if library.isChecking {
Button {
library.cancelCheck()
} label: {
HStack(spacing: 4) {
ProgressView().controlSize(.mini)
Text(L.cancelCheck)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Button {
library.checkAllStreams()
} label: {
Label(L.checkStreams, systemImage: "network")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// /
Button {
showUnavailable.toggle()
} label: {
Image(systemName: showUnavailable ? "eye.slash" : "eye")
.foregroundColor(showUnavailable ? .orange : .secondary)
}
.buttonStyle(.plain)
#if os(macOS)
.help(showUnavailable ? L.hideUnavailable : L.showUnavailable)
#endif
}
.padding(.horizontal, 12).padding(.vertical, 8)
//
if library.isChecking {
VStack(spacing: 2) {
ProgressView(value: library.checkProgress)
.progressViewStyle(.linear)
Text(library.checkStatusText)
.font(.caption2).foregroundColor(.secondary)
}
.padding(.horizontal, 12)
}
Divider()
//
if displayedItems.isEmpty {
VStack { Spacer()
Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult)
.foregroundColor(.secondary)
Spacer() }
} else {
List {
ForEach(displayedItems) { item in
LibraryItemRow(item: item, bridge: bridge, library: library,
showTagInput: $showTagInput,
tagInputItemID: $tagInputItemID)
.onAppear {
//
if item == displayedItems.last, allFilteredItems.count > displayLimit {
displayLimit += 100
}
}
}
if allFilteredItems.count > displayLimit {
HStack {
Spacer()
ProgressView()
Text("Loading more... (\(allFilteredItems.count - displayLimit) remaining)")
.font(.caption).foregroundColor(.secondary)
Spacer()
}
.padding(.vertical, 8)
}
}
.listStyle(.inset)
}
//
HStack {
Text("\(displayedItems.count) \(L.itemsCount)")
.font(.caption).foregroundColor(.secondary)
if !showUnavailable {
let badCount = library.items.filter { $0.availability == .forbidden || $0.availability == .unavailable }.count
if badCount > 0 {
Text("(\(badCount) \(L.hiddenUnavailable))")
.font(.caption).foregroundColor(.orange)
}
}
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()
//
switch item.availability {
case .available:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green).font(.caption)
case .forbidden:
Image(systemName: "lock.fill")
.foregroundColor(.red).font(.caption)
#if os(macOS)
.help(L.statusForbidden)
#endif
case .unavailable:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray).font(.caption)
#if os(macOS)
.help(L.statusUnavailable)
#endif
case .unknown:
EmptyView()
}
//
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()
}
}