fix(iOS): fix recording, playback compatibility, and media library UI
Recording: - stopRecording() now only sets stopRequested=true, no longer cancels task - Task exits polling loop naturally → merges TS segments → exports MP4 - System share sheet + Photos save shown on completion - Added HEAD request to detect HLS Content-Type for non-.m3u8 URLs - Faster exit when stopRequested && segments exist Playback: - Copy imported files to app Documents sandbox (security-scoped URLs expire after defer) - Remove Referer header that blocks some streams; use iPhone UA only - AVURLAsset options only for http/https schemes Media Library UI (iOS): - Replace NavigationSplitView with VStack + horizontal category chips - Search bar at top, swipe actions for queue/delete - scrollDismissesKeyboard(.interactively) on list - Tap gesture sends resignFirstResponder to dismiss keyboard - Compact rows with context menu, inline play button - Close button in navigation bar - presentationDetents(.medium) for tag/playlist sheets
This commit is contained in:
parent
88f1ecbe9c
commit
ce5878957e
@ -58,23 +58,32 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
|
// 不 cancel 任务,只设 stopRequested 让轮询循环正常退出
|
||||||
|
// 任务会合并已下载片段 → MP4 → 弹出分享面板
|
||||||
stopRequested = true
|
stopRequested = true
|
||||||
recordTask?.cancel()
|
|
||||||
recordTask = nil
|
|
||||||
isRecording = false
|
|
||||||
timer?.invalidate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 核心录制逻辑
|
// MARK: - 核心录制逻辑
|
||||||
|
|
||||||
private func recordStream(url: URL) async throws -> URL {
|
private func recordStream(url: URL) async throws -> URL {
|
||||||
// 判断是否为 HLS
|
// 判断是否为 HLS(m3u8 扩展名或 URL 含 m3u8 参数)
|
||||||
let isHLS = url.pathExtension.lowercased() == "m3u8"
|
let ext = url.pathExtension.lowercased()
|
||||||
|
let isHLS = ext == "m3u8"
|
||||||
|| url.absoluteString.contains(".m3u8")
|
|| url.absoluteString.contains(".m3u8")
|
||||||
|
|| ext == "m3u"
|
||||||
|
|
||||||
if isHLS {
|
if isHLS {
|
||||||
return try await recordHLS(url: url)
|
return try await recordHLS(url: url)
|
||||||
} else {
|
} else {
|
||||||
|
// 先尝试 HEAD 请求判断 Content-Type
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "HEAD"
|
||||||
|
if let (_, resp) = try? await URLSession.shared.data(for: req),
|
||||||
|
let httpResp = resp as? HTTPURLResponse,
|
||||||
|
let ct = httpResp.allHeaderFields["Content-Type"] as? String,
|
||||||
|
ct.contains("mpegurl") || ct.contains("m3u8") || ct.contains("apple.mpegurl") {
|
||||||
|
return try await recordHLS(url: url)
|
||||||
|
}
|
||||||
return try await downloadFile(url: url)
|
return try await downloadFile(url: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,14 +176,20 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是 VOD(已播完)或者已有足够片段且 stop 了
|
// 如果是 VOD(已播完)或者 stop 了且有片段
|
||||||
if !isLive && newSegments.isEmpty {
|
if !isLive && newSegments.isEmpty {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stopRequested && !downloadedSegments.isEmpty {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
||||||
if !stopRequested {
|
if !stopRequested {
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
struct MediaLibraryView: View {
|
struct MediaLibraryView: View {
|
||||||
@ObservedObject var library: MediaLibrary
|
@ObservedObject var library: MediaLibrary
|
||||||
@ObservedObject var bridge: PlayerBridge
|
@ObservedObject var bridge: PlayerBridge
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var selectedSidebar: SidebarItem = .all
|
@State private var selectedSidebar: SidebarItem = .all
|
||||||
@ -14,15 +15,14 @@ struct MediaLibraryView: View {
|
|||||||
@State private var tagInputItemID: String?
|
@State private var tagInputItemID: String?
|
||||||
@State private var tagInputText = ""
|
@State private var tagInputText = ""
|
||||||
@State private var newPlaylistName = ""
|
@State private var newPlaylistName = ""
|
||||||
@State private var selectedItems: Set<String> = []
|
@State private var displayLimit: Int = 100
|
||||||
@State private var displayLimit: Int = 100 // 分页:先显示100条,滚动加载更多
|
@State private var showUnavailable: Bool = false
|
||||||
@State private var showUnavailable: Bool = false // 是否显示不可用频道
|
|
||||||
|
|
||||||
enum SidebarItem: Hashable {
|
enum SidebarItem: Hashable {
|
||||||
case all
|
case all
|
||||||
case liked
|
case liked
|
||||||
case playlist(String) // playlist ID
|
case playlist(String)
|
||||||
case tag(String) // tag name
|
case tag(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据侧边栏选择 + 搜索获取显示列表
|
// 根据侧边栏选择 + 搜索获取显示列表
|
||||||
@ -38,7 +38,6 @@ struct MediaLibraryView: View {
|
|||||||
case .tag(let tag):
|
case .tag(let tag):
|
||||||
result = library.search("", tag: tag)
|
result = library.search("", tag: tag)
|
||||||
}
|
}
|
||||||
// 过滤不可用频道(默认隐藏)
|
|
||||||
if !showUnavailable {
|
if !showUnavailable {
|
||||||
result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable }
|
result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable }
|
||||||
}
|
}
|
||||||
@ -54,16 +53,373 @@ struct MediaLibraryView: View {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页显示
|
|
||||||
private var displayedItems: [LibraryItem] {
|
private var displayedItems: [LibraryItem] {
|
||||||
Array(allFilteredItems.prefix(displayLimit))
|
Array(allFilteredItems.prefix(displayLimit))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部状态栏
|
||||||
|
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 {
|
NavigationSplitView {
|
||||||
sidebarView
|
sidebarView
|
||||||
} detail: {
|
} detail: {
|
||||||
contentView
|
macOSContentView
|
||||||
}
|
}
|
||||||
.frame(minWidth: 650, minHeight: 450)
|
.frame(minWidth: 650, minHeight: 450)
|
||||||
.onChange(of: selectedSidebar) { _ in displayLimit = 100 }
|
.onChange(of: selectedSidebar) { _ in displayLimit = 100 }
|
||||||
@ -103,7 +459,6 @@ struct MediaLibraryView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button(L.add) {
|
Button(L.add) {
|
||||||
if let id = tagInputItemID, !tagInputText.isEmpty {
|
if let id = tagInputItemID, !tagInputText.isEmpty {
|
||||||
// 支持逗号分隔多标签
|
|
||||||
for tag in tagInputText.split(separator: ",") {
|
for tag in tagInputText.split(separator: ",") {
|
||||||
library.addTag(String(tag.trimmingCharacters(in: .whitespaces)), to: id)
|
library.addTag(String(tag.trimmingCharacters(in: .whitespaces)), to: id)
|
||||||
}
|
}
|
||||||
@ -120,7 +475,7 @@ struct MediaLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 侧边栏
|
// MARK: - macOS 侧边栏
|
||||||
|
|
||||||
private var sidebarView: some View {
|
private var sidebarView: some View {
|
||||||
List {
|
List {
|
||||||
@ -174,13 +529,11 @@ struct MediaLibraryView: View {
|
|||||||
.frame(minWidth: 180)
|
.frame(minWidth: 180)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 内容区
|
// MARK: - macOS 内容区
|
||||||
|
|
||||||
private var contentView: some View {
|
private var macOSContentView: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// 顶部工具栏
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
// 搜索框
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -200,7 +553,6 @@ struct MediaLibraryView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 导入 M3U
|
|
||||||
Button {
|
Button {
|
||||||
showImportSheet = true
|
showImportSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
@ -209,7 +561,6 @@ struct MediaLibraryView: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
|
||||||
// 添加本地文件入库
|
|
||||||
Button {
|
Button {
|
||||||
bridge.importLocalToLibrary()
|
bridge.importLocalToLibrary()
|
||||||
} label: {
|
} label: {
|
||||||
@ -218,7 +569,6 @@ struct MediaLibraryView: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
|
||||||
// 全部加入队列
|
|
||||||
if !displayedItems.isEmpty {
|
if !displayedItems.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
addDisplayedToQueue()
|
addDisplayedToQueue()
|
||||||
@ -229,7 +579,6 @@ struct MediaLibraryView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测流可用性
|
|
||||||
if library.isChecking {
|
if library.isChecking {
|
||||||
Button {
|
Button {
|
||||||
library.cancelCheck()
|
library.cancelCheck()
|
||||||
@ -251,7 +600,6 @@ struct MediaLibraryView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示/隐藏不可用
|
|
||||||
Button {
|
Button {
|
||||||
showUnavailable.toggle()
|
showUnavailable.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
@ -265,7 +613,6 @@ struct MediaLibraryView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
|
|
||||||
// 检测进度条
|
|
||||||
if library.isChecking {
|
if library.isChecking {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
ProgressView(value: library.checkProgress)
|
ProgressView(value: library.checkProgress)
|
||||||
@ -278,7 +625,6 @@ struct MediaLibraryView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// 条目列表
|
|
||||||
if displayedItems.isEmpty {
|
if displayedItems.isEmpty {
|
||||||
VStack { Spacer()
|
VStack { Spacer()
|
||||||
Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult)
|
Text(searchText.isEmpty ? L.libraryEmpty : L.noSearchResult)
|
||||||
@ -291,7 +637,6 @@ struct MediaLibraryView: View {
|
|||||||
showTagInput: $showTagInput,
|
showTagInput: $showTagInput,
|
||||||
tagInputItemID: $tagInputItemID)
|
tagInputItemID: $tagInputItemID)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// 滚动到底部时加载更多
|
|
||||||
if item == displayedItems.last, allFilteredItems.count > displayLimit {
|
if item == displayedItems.last, allFilteredItems.count > displayLimit {
|
||||||
displayLimit += 100
|
displayLimit += 100
|
||||||
}
|
}
|
||||||
@ -311,7 +656,6 @@ struct MediaLibraryView: View {
|
|||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底部状态栏
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(displayedItems.count) \(L.itemsCount)")
|
Text("\(displayedItems.count) \(L.itemsCount)")
|
||||||
.font(.caption).foregroundColor(.secondary)
|
.font(.caption).foregroundColor(.secondary)
|
||||||
@ -340,9 +684,41 @@ struct MediaLibraryView: View {
|
|||||||
}
|
}
|
||||||
bridge.showToastMsg("\(displayedItems.count) \(L.addedToQueue)")
|
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: - 条目行
|
// 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 {
|
struct LibraryItemRow: View {
|
||||||
let item: LibraryItem
|
let item: LibraryItem
|
||||||
@ -353,7 +729,6 @@ struct LibraryItemRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// 类型图标
|
|
||||||
Image(systemName: iconForType(item.type))
|
Image(systemName: iconForType(item.type))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
@ -382,7 +757,6 @@ struct LibraryItemRow: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 可用性状态
|
|
||||||
switch item.availability {
|
switch item.availability {
|
||||||
case .available:
|
case .available:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
@ -403,7 +777,6 @@ struct LibraryItemRow: View {
|
|||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 喜欢
|
|
||||||
Button {
|
Button {
|
||||||
library.toggleLike(item.id)
|
library.toggleLike(item.id)
|
||||||
} label: {
|
} label: {
|
||||||
@ -412,7 +785,6 @@ struct LibraryItemRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// 不喜欢
|
|
||||||
Button {
|
Button {
|
||||||
library.toggleDislike(item.id)
|
library.toggleDislike(item.id)
|
||||||
} label: {
|
} label: {
|
||||||
@ -421,7 +793,6 @@ struct LibraryItemRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// 播放
|
|
||||||
Button {
|
Button {
|
||||||
bridge.playFromLibrary(item)
|
bridge.playFromLibrary(item)
|
||||||
} label: {
|
} label: {
|
||||||
@ -485,51 +856,54 @@ struct ImportM3UView: View {
|
|||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
NavigationStack {
|
||||||
Text(L.importM3U).font(.headline)
|
VStack(spacing: 12) {
|
||||||
|
// URL 输入
|
||||||
// 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 {
|
HStack {
|
||||||
Text("\(parsedChannels.count) \(L.channelsFound)")
|
TextField("https://example.com/playlist.m3u", text: $m3uURL)
|
||||||
.font(.caption).foregroundColor(.secondary)
|
.textFieldStyle(.roundedBorder)
|
||||||
Spacer()
|
.autocorrectionDisabled()
|
||||||
Button(L.selectAll) {
|
#if os(iOS)
|
||||||
selectedChannels = Set(parsedChannels.map { $0.url })
|
.textInputAutocapitalization(.never)
|
||||||
}
|
.keyboardType(.URL)
|
||||||
.font(.caption)
|
#endif
|
||||||
Button(L.deselectAll) { selectedChannels = [] }
|
Button(L.fetch) { fetchM3U() }
|
||||||
.font(.caption)
|
.disabled(m3uURL.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
if isLoading {
|
||||||
LazyVStack(alignment: .leading, spacing: 4) {
|
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
|
ForEach(parsedChannels, id: \.url) { ch in
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: selectedChannels.contains(ch.url) ? "checkmark.square.fill" : "square")
|
Image(systemName: selectedChannels.contains(ch.url) ? "checkmark.square.fill" : "square")
|
||||||
.foregroundColor(selectedChannels.contains(ch.url) ? .accentColor : .secondary)
|
.foregroundColor(selectedChannels.contains(ch.url) ? .accentColor : .secondary)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(ch.name).font(.system(size: 12)).lineLimit(1)
|
Text(ch.name).font(.system(size: 13)).lineLimit(1)
|
||||||
Text(ch.url).font(.system(size: 10)).foregroundColor(.secondary).lineLimit(1)
|
Text(ch.url).font(.system(size: 10)).foregroundColor(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -537,7 +911,6 @@ struct ImportM3UView: View {
|
|||||||
Text(group).font(.system(size: 10)).foregroundColor(.blue.opacity(0.7))
|
Text(group).font(.system(size: 10)).foregroundColor(.blue.opacity(0.7))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if selectedChannels.contains(ch.url) {
|
if selectedChannels.contains(ch.url) {
|
||||||
@ -548,28 +921,32 @@ struct ImportM3UView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
#if os(iOS)
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 300)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.08)))
|
|
||||||
|
|
||||||
HStack {
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.navigationTitle(L.importM3U)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(L.cancel) { dismiss() }
|
Button(L.cancel) { dismiss() }
|
||||||
Spacer()
|
|
||||||
Button("\(L.importSelected) (\(selectedChannels.count))") {
|
|
||||||
importSelected()
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.defaultAction)
|
|
||||||
.disabled(selectedChannels.isEmpty)
|
|
||||||
}
|
}
|
||||||
} else {
|
if !selectedChannels.isEmpty {
|
||||||
HStack {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Spacer()
|
Button("\(L.importSelected) (\(selectedChannels.count))") {
|
||||||
Button(L.close) { dismiss() }
|
importSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
|
||||||
.frame(width: 550, height: 500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchM3U() {
|
private func fetchM3U() {
|
||||||
|
|||||||
@ -224,11 +224,14 @@ final class PlayerBridge: ObservableObject {
|
|||||||
itemStatusObserver = nil
|
itemStatusObserver = nil
|
||||||
|
|
||||||
// 用 AVURLAsset 支持自定义 HTTP headers(某些 HLS 流需要 Referer/UA)
|
// 用 AVURLAsset 支持自定义 HTTP headers(某些 HLS 流需要 Referer/UA)
|
||||||
let headers: [String: String] = [
|
var assetOptions: [String: Any] = [:]
|
||||||
"Referer": item.url.deletingLastPathComponent().absoluteString,
|
if item.url.scheme == "http" || item.url.scheme == "https" {
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
let headers: [String: String] = [
|
||||||
]
|
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||||
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
]
|
||||||
|
assetOptions["AVURLAssetHTTPHeaderFieldsKey"] = headers
|
||||||
|
}
|
||||||
|
let asset = AVURLAsset(url: item.url, options: assetOptions.isEmpty ? nil : assetOptions)
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
|
|
||||||
player.replaceCurrentItem(with: playerItem)
|
player.replaceCurrentItem(with: playerItem)
|
||||||
@ -681,12 +684,32 @@ final class PlayerBridge: ObservableObject {
|
|||||||
/// 处理 iOS 文件选择器结果
|
/// 处理 iOS 文件选择器结果
|
||||||
func handleFileImport(_ urls: [URL]) {
|
func handleFileImport(_ urls: [URL]) {
|
||||||
for url in urls {
|
for url in urls {
|
||||||
// iOS: 需要 startAccessingSecurityScopedResource 获取沙盒文件访问权限
|
|
||||||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
|
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
|
||||||
}
|
}
|
||||||
addItem(url: url, name: url.lastPathComponent, type: "file")
|
|
||||||
|
// 复制文件到 app Documents 目录(security-scoped URL 在 defer 后会失效)
|
||||||
|
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let localURL = docsDir.appendingPathComponent(url.lastPathComponent)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 如果同名文件已存在,加时间戳
|
||||||
|
if FileManager.default.fileExists(atPath: localURL.path) {
|
||||||
|
let ts = Int(Date().timeIntervalSince1970)
|
||||||
|
let nameWithoutExt = url.deletingPathExtension().lastPathComponent
|
||||||
|
let ext = url.pathExtension
|
||||||
|
let newURL = docsDir.appendingPathComponent("\(nameWithoutExt)_\(ts).\(ext)")
|
||||||
|
try FileManager.default.copyItem(at: url, to: newURL)
|
||||||
|
addItem(url: newURL, name: url.lastPathComponent, type: "file")
|
||||||
|
} else {
|
||||||
|
try FileManager.default.copyItem(at: url, to: localURL)
|
||||||
|
addItem(url: localURL, name: url.lastPathComponent, type: "file")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 复制失败则直接用原始 URL(可能是 iCloud 文件等)
|
||||||
|
addItem(url: url, name: url.lastPathComponent, type: "file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user