995 lines
38 KiB
Swift
995 lines
38 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - 媒体库面板主视图
|
||
|
||
struct MediaLibraryView: View {
|
||
@ObservedObject var library: MediaLibrary
|
||
@ObservedObject var bridge: PlayerBridge
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@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 displayLimit: Int = 100
|
||
@State private var showUnavailable: Bool = false
|
||
|
||
enum SidebarItem: Hashable {
|
||
case all
|
||
case liked
|
||
case playlist(String)
|
||
case tag(String)
|
||
}
|
||
|
||
// 根据侧边栏选择 + 搜索获取显示列表
|
||
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 {
|
||
#if os(iOS)
|
||
iOSBody
|
||
#else
|
||
macOSBody
|
||
#endif
|
||
}
|
||
|
||
// MARK: - iOS 专用布局
|
||
|
||
@ViewBuilder
|
||
private var iOSBody: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 0) {
|
||
// 分类切换(替代侧边栏)
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
CategoryChip(label: L.allMedia, icon: "square.grid.2x2",
|
||
isSelected: selectedSidebar == .all) {
|
||
selectedSidebar = .all
|
||
}
|
||
CategoryChip(label: L.liked, icon: "heart.fill",
|
||
isSelected: selectedSidebar == .liked) {
|
||
selectedSidebar = .liked
|
||
}
|
||
ForEach(library.playlists) { pl in
|
||
CategoryChip(label: pl.name, icon: "music.note.list",
|
||
isSelected: selectedSidebar == .playlist(pl.id)) {
|
||
selectedSidebar = .playlist(pl.id)
|
||
}
|
||
}
|
||
ForEach(library.allTags, id: \.self) { tag in
|
||
CategoryChip(label: tag, icon: "tag",
|
||
isSelected: selectedSidebar == .tag(tag)) {
|
||
selectedSidebar = .tag(tag)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
}
|
||
.padding(.vertical, 8)
|
||
|
||
// 搜索框
|
||
HStack(spacing: 8) {
|
||
HStack {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundColor(.secondary)
|
||
.font(.system(size: 14))
|
||
TextField(L.searchChannels, text: $searchText)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 14))
|
||
.submitLabel(.search)
|
||
if !searchText.isEmpty {
|
||
Button { searchText = "" } label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 10).padding(.vertical, 8)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.12)))
|
||
|
||
// 工具按钮
|
||
Button {
|
||
showImportSheet = true
|
||
} label: {
|
||
Image(systemName: "square.and.arrow.down")
|
||
.font(.system(size: 14))
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button {
|
||
bridge.importLocalToLibrary()
|
||
} label: {
|
||
Image(systemName: "folder.badge.plus")
|
||
.font(.system(size: 14))
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
|
||
// 检测进度
|
||
if library.isChecking {
|
||
VStack(spacing: 2) {
|
||
ProgressView(value: library.checkProgress)
|
||
.progressViewStyle(.linear)
|
||
HStack {
|
||
Text(library.checkStatusText)
|
||
.font(.caption2).foregroundColor(.secondary)
|
||
Spacer()
|
||
Button(L.cancelCheck) { library.cancelCheck() }
|
||
.font(.caption2)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12).padding(.vertical, 4)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// 条目列表
|
||
if displayedItems.isEmpty {
|
||
VStack { Spacer()
|
||
Image(systemName: "tray")
|
||
.font(.system(size: 36))
|
||
.foregroundColor(.secondary.opacity(0.5))
|
||
Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult)
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 4)
|
||
Spacer() }
|
||
} else {
|
||
List {
|
||
ForEach(displayedItems) { item in
|
||
iOSLibraryRow(item: item)
|
||
.listRowSeparator(.visible)
|
||
.swipeActions(edge: .trailing) {
|
||
Button(role: .destructive) {
|
||
library.removeItem(item.id)
|
||
} label: {
|
||
Label(L.delete, systemImage: "trash")
|
||
}
|
||
Button {
|
||
if let url = URL(string: item.url) {
|
||
bridge.addItemNoPlay(url: url, name: item.name, type: item.type, libraryID: item.id)
|
||
}
|
||
} label: {
|
||
Label(L.addToQueue, systemImage: "text.badge.plus")
|
||
}
|
||
.tint(.blue)
|
||
}
|
||
.onAppear {
|
||
if item == displayedItems.last, allFilteredItems.count > displayLimit {
|
||
displayLimit += 100
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollDismissesKeyboard(.interactively)
|
||
.onTapGesture {
|
||
#if os(iOS)
|
||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// 底部状态栏
|
||
HStack(spacing: 8) {
|
||
Text("\(allFilteredItems.count) / \(library.count)")
|
||
.font(.caption).foregroundColor(.secondary)
|
||
Spacer()
|
||
if library.isChecking {
|
||
Button { } label: {
|
||
ProgressView().controlSize(.mini)
|
||
}
|
||
} else {
|
||
Button {
|
||
library.checkAllStreams()
|
||
} label: {
|
||
Image(systemName: "network")
|
||
.font(.caption)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
Button {
|
||
showUnavailable.toggle()
|
||
} label: {
|
||
Image(systemName: showUnavailable ? "eye.slash" : "eye")
|
||
.font(.caption)
|
||
.foregroundColor(showUnavailable ? .orange : .secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||
.background(.thinMaterial)
|
||
}
|
||
.navigationTitle(L.mediaLibrary)
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(L.close) { dismiss() }
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.sheet(isPresented: $showImportSheet) {
|
||
ImportM3UView(library: library, bridge: bridge)
|
||
}
|
||
.sheet(isPresented: $showNewPlaylistSheet) {
|
||
NavigationStack {
|
||
VStack(spacing: 16) {
|
||
TextField(L.playlistName, text: $newPlaylistName)
|
||
.textFieldStyle(.roundedBorder)
|
||
Spacer()
|
||
}
|
||
.padding(20)
|
||
.navigationTitle(L.newPlaylist)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(L.cancel) { showNewPlaylistSheet = false; newPlaylistName = "" }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(L.create) {
|
||
if !newPlaylistName.isEmpty {
|
||
_ = library.createPlaylist(name: newPlaylistName)
|
||
newPlaylistName = ""
|
||
showNewPlaylistSheet = false
|
||
}
|
||
}
|
||
.disabled(newPlaylistName.isEmpty)
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium])
|
||
}
|
||
.sheet(isPresented: $showTagInput) {
|
||
NavigationStack {
|
||
VStack(spacing: 16) {
|
||
TextField(L.tagName, text: $tagInputText)
|
||
.textFieldStyle(.roundedBorder)
|
||
Spacer()
|
||
}
|
||
.padding(20)
|
||
.navigationTitle(L.addTag)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(L.cancel) { showTagInput = false; tagInputText = "" }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
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
|
||
}
|
||
}
|
||
.disabled(tagInputText.isEmpty)
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium])
|
||
}
|
||
}
|
||
|
||
// MARK: - iOS 条目行
|
||
|
||
private func iOSLibraryRow(item: LibraryItem) -> some View {
|
||
HStack(spacing: 10) {
|
||
// 类型图标 + 可用性
|
||
ZStack(alignment: .topTrailing) {
|
||
Image(systemName: iconForType(item.type))
|
||
.foregroundColor(.secondary)
|
||
.font(.system(size: 16))
|
||
.frame(width: 24, height: 24)
|
||
|
||
switch item.availability {
|
||
case .available:
|
||
Image(systemName: "circle.fill")
|
||
.foregroundColor(.green)
|
||
.font(.system(size: 6))
|
||
.offset(x: 2, y: -2)
|
||
case .forbidden:
|
||
Image(systemName: "lock.fill")
|
||
.foregroundColor(.red)
|
||
.font(.system(size: 8))
|
||
.offset(x: 4, y: -2)
|
||
case .unavailable:
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundColor(.gray)
|
||
.font(.system(size: 8))
|
||
.offset(x: 4, y: -2)
|
||
case .unknown:
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(item.name)
|
||
.font(.system(size: 14))
|
||
.lineLimit(1)
|
||
HStack(spacing: 4) {
|
||
if let group = item.group, !group.isEmpty {
|
||
Text(group)
|
||
.font(.caption2)
|
||
.padding(.horizontal, 4).padding(.vertical, 1)
|
||
.background(Capsule().fill(Color.blue.opacity(0.1)))
|
||
.foregroundColor(.blue)
|
||
}
|
||
ForEach(item.tags.prefix(3), id: \.self) { tag in
|
||
Text(tag)
|
||
.font(.system(size: 10))
|
||
.padding(.horizontal, 4).padding(.vertical, 1)
|
||
.background(Capsule().fill(Color.accentColor.opacity(0.1)))
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// 喜欢
|
||
Button {
|
||
library.toggleLike(item.id)
|
||
} label: {
|
||
Image(systemName: item.liked ? "heart.fill" : "heart")
|
||
.foregroundColor(item.liked ? .red : .secondary)
|
||
.font(.system(size: 14))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// 播放
|
||
Button {
|
||
bridge.playFromLibrary(item)
|
||
dismiss()
|
||
} label: {
|
||
Image(systemName: "play.circle.fill")
|
||
.foregroundColor(.accentColor)
|
||
.font(.system(size: 22))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.vertical, 2)
|
||
.contentShape(Rectangle())
|
||
.contextMenu {
|
||
Button(L.playNow) { bridge.playFromLibrary(item); dismiss() }
|
||
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) }
|
||
}
|
||
}
|
||
|
||
// MARK: - macOS 布局(保持不变)
|
||
|
||
@ViewBuilder
|
||
private var macOSBody: some View {
|
||
NavigationSplitView {
|
||
sidebarView
|
||
} detail: {
|
||
macOSContentView
|
||
}
|
||
.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: - macOS 侧边栏
|
||
|
||
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: - macOS 内容区
|
||
|
||
private var macOSContentView: 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()
|
||
|
||
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)")
|
||
}
|
||
|
||
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: - 分类切换按钮(iOS)
|
||
|
||
struct CategoryChip: View {
|
||
let label: String
|
||
let icon: String
|
||
let isSelected: Bool
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: icon).font(.system(size: 11))
|
||
Text(label).font(.system(size: 13))
|
||
}
|
||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||
.background(
|
||
Capsule().fill(isSelected ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.08))
|
||
)
|
||
.foregroundColor(isSelected ? .accentColor : .primary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - macOS 条目行(保持原有)
|
||
|
||
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 {
|
||
NavigationStack {
|
||
VStack(spacing: 12) {
|
||
// URL 输入
|
||
HStack {
|
||
TextField("https://example.com/playlist.m3u", text: $m3uURL)
|
||
.textFieldStyle(.roundedBorder)
|
||
.autocorrectionDisabled()
|
||
#if os(iOS)
|
||
.textInputAutocapitalization(.never)
|
||
.keyboardType(.URL)
|
||
#endif
|
||
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)
|
||
}
|
||
|
||
List {
|
||
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: 13)).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))
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
if selectedChannels.contains(ch.url) {
|
||
selectedChannels.remove(ch.url)
|
||
} else {
|
||
selectedChannels.insert(ch.url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
#if os(iOS)
|
||
.scrollDismissesKeyboard(.interactively)
|
||
#endif
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding(16)
|
||
.navigationTitle(L.importM3U)
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
#endif
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(L.cancel) { dismiss() }
|
||
}
|
||
if !selectedChannels.isEmpty {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("\(L.importSelected) (\(selectedChannels.count))") {
|
||
importSelected()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|