MiniPlayer/Sources/MediaLibraryView.swift
yumoqing af710e4e4f feat: 流媒体URL并发可用性检测
- 后台并发检测所有远程流URL(最多10并发)
- 5秒超时,403标记forbidden,连接失败标记unavailable
- 默认过滤不可用频道,眼睛图标切换显示
- 进度条+状态文本实时显示检测进度
- 行内状态图标:绿✓可用/红🔒无权限/灰✗不可用
- 支持取消检测
2026-06-30 22:41:04 +08:00

606 lines
22 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(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)
}
//
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)
.help(showUnavailable ? L.hideUnavailable : L.showUnavailable)
}
.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)
.help(L.statusForbidden)
case .unavailable:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray).font(.caption)
.help(L.statusUnavailable)
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()
}
}