From c700f27b161c8bd23408521255c80795f45d182a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 21 Jun 2026 11:41:07 +0800 Subject: [PATCH] MiniPlayer: multiplatform video/audio player (macOS/iOS/iPadOS) --- README.md | 60 +++++++++ Sources/ContentView.swift | 114 ++++++++++++++++ Sources/MiniPlayerApp.swift | 31 +++++ Sources/Models.swift | 42 ++++++ Sources/PlayerEngine.swift | 246 ++++++++++++++++++++++++++++++++++ Sources/PlayerLayerView.swift | 68 ++++++++++ Sources/PlayerView.swift | 241 +++++++++++++++++++++++++++++++++ Sources/PlaylistView.swift | 158 ++++++++++++++++++++++ project.yml | 34 +++++ setup.sh | 23 ++++ 10 files changed, 1017 insertions(+) create mode 100644 README.md create mode 100644 Sources/ContentView.swift create mode 100644 Sources/MiniPlayerApp.swift create mode 100644 Sources/Models.swift create mode 100644 Sources/PlayerEngine.swift create mode 100644 Sources/PlayerLayerView.swift create mode 100644 Sources/PlayerView.swift create mode 100644 Sources/PlaylistView.swift create mode 100644 project.yml create mode 100755 setup.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..288a16b --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# MiniPlayer + +简洁多平台视频/音频播放器,支持 macOS、iPhone、iPad。 + +## 功能 + +- 播放大多数视频/音频格式 (MP4, MKV, AVI, MOV, MP3, FLAC, WAV, OGG, AAC 等) +- 支持 M3U8 流媒体播放 +- 音轨切换 +- 全屏播放 +- 播放列表管理(拖拽排序、删除) +- 自动播放下一首 +- 单曲循环 / 列表循环 / 不循环 +- 快进快退 10 秒 +- 本地文件 + URL 流媒体双入口 + +## 系统要求 + +- macOS 14+ (Sonoma) +- iOS / iPadOS 17+ +- Xcode 15+ + +## 构建 + +```bash +# 安装 XcodeGen(如未安装) +brew install xcodegen + +# 生成项目并打开 +bash setup.sh +``` + +或在 Xcode 中:File → New → Project → Multiplatform → App,将 Sources/ 目录的文件添加进去。 + +## 快捷键 + +| 快捷键 | 功能 | +|--------|------| +| Space | 播放/暂停 | +| → / ← | 快进/快退 10 秒 | +| ⌘→ / ⌘← | 下一个/上一个 | +| ⌘F | 全屏切换 | +| ESC | 退出全屏 | + +## 架构 + +``` +Sources/ +├── MiniPlayerApp.swift # App 入口 +├── ContentView.swift # 主布局 (NavigationSplitView) +├── PlayerView.swift # 视频显示 + 控制层 +├── PlaylistView.swift # 播放列表侧边栏 +├── PlayerEngine.swift # AVPlayer 播放引擎 +├── PlayerLayerView.swift # 跨平台视频渲染层 +└── Models.swift # 数据模型 +``` + +- SwiftUI + AVFoundation +- 单一代码库,`#if os(iOS)` / `#if os(macOS)` 处理平台差异 +- AVPlayerLayer 自定义渲染(比 VideoPlayer 更可控) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift new file mode 100644 index 0000000..430939d --- /dev/null +++ b/Sources/ContentView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var engine: PlayerEngine + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showURLInput = false + @State private var urlInput = "" + + var body: some View { + Group { + if engine.isFullscreen { + PlayerView() + .onTapGesture(count: 2) { + engine.toggleFullscreen() + } + } else { + NavigationSplitView(columnVisibility: $columnVisibility) { + playlistSidebar + } detail: { + PlayerView() + } + .navigationSplitViewStyle(.balanced) + .navigationSplitViewColumnWidth(min: 240, ideal: 300, max: 400) + } + } + .alert("添加流媒体 URL", isPresented: $showURLInput) { + TextField("M3U8 或其他流媒体 URL", text: $urlInput) + Button("添加") { + addStreamURL() + } + Button("取消", role: .cancel) {} + } message: { + Text("输入 M3U8、MP4 或其他流媒体地址") + } + .keyboardShortcut(.escape, action: { + if engine.isFullscreen { engine.toggleFullscreen() } + }) + } + + // MARK: - 侧边栏 + + private var playlistSidebar: some View { + VStack(spacing: 0) { + PlaylistView() + + Divider() + + // 底部工具栏: 添加流URL + 循环模式 + HStack(spacing: 12) { + Button { + urlInput = "" + showURLInput = true + } label: { + Label("添加流 URL", systemImage: "link") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + + Spacer() + + // 循环模式 + Button { + engine.repeatMode = engine.repeatMode.next + } label: { + HStack(spacing: 4) { + Image(systemName: engine.repeatMode.icon) + Text(repeatModeText) + } + .font(.caption) + .foregroundStyle(engine.repeatMode == .none ? .secondary : .blue) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + } + + private var repeatModeText: String { + switch engine.repeatMode { + case .none: return "不循环" + case .single: return "单曲" + case .all: return "列表" + } + } + + private func addStreamURL() { + guard !urlInput.isEmpty, let url = URL(string: urlInput) else { return } + engine.addToQueue(urls: [url]) + } +} + +// ESC 快捷键辅助 +extension View { + func keyboardShortcut(_ key: KeyEquivalent, action: @escaping () -> Void) -> some View { + self.background( + KeyboardShortcutView(key: key, action: action) + ) + } +} + +struct KeyboardShortcutView: View { + let key: KeyEquivalent + let action: () -> Void + + var body: some View { + Button(action: action) { + EmptyView() + } + .keyboardShortcut(key, modifiers: []) + .hidden() + } +} diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift new file mode 100644 index 0000000..dd81e73 --- /dev/null +++ b/Sources/MiniPlayerApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct MiniPlayerApp: App { + @StateObject private var engine = PlayerEngine() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(engine) + } + #if os(macOS) + .commands { + CommandGroup(replacing: .newItem) {} + CommandMenu("播放") { + Button("下一个") { engine.playNext() } + .keyboardShortcut(.rightArrow, modifiers: [.command]) + Button("上一个") { engine.playPrevious() } + .keyboardShortcut(.leftArrow, modifiers: [.command]) + Divider() + Button("全屏") { engine.toggleFullscreen() } + .keyboardShortcut("f", modifiers: [.command]) + Divider() + Button("循环: 单曲") { engine.repeatMode = .single } + Button("循环: 列表") { engine.repeatMode = .all } + Button("循环: 关闭") { engine.repeatMode = .none } + } + } + #endif + } +} diff --git a/Sources/Models.swift b/Sources/Models.swift new file mode 100644 index 0000000..4b63017 --- /dev/null +++ b/Sources/Models.swift @@ -0,0 +1,42 @@ +import Foundation +import AVFoundation + +// 媒体类型 +enum MediaType: String, Codable { + case video, audio, stream +} + +// 循环模式 +enum RepeatMode: String, CaseIterable { + case none // 不循环 + case single // 单曲循环 + case all // 列表循环 + + var icon: String { + switch self { + case .none: return "repeat" + case .single: return "repeat.1" + case .all: return "repeat" + } + } + + var next: RepeatMode { + switch self { + case .none: return .single + case .single: return .all + case .all: return .none + } + } +} + +// 媒体项 +struct MediaItem: Identifiable, Equatable { + let id = UUID() + let url: URL + let name: String + let type: MediaType + + static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/PlayerEngine.swift b/Sources/PlayerEngine.swift new file mode 100644 index 0000000..ddf2db5 --- /dev/null +++ b/Sources/PlayerEngine.swift @@ -0,0 +1,246 @@ +import Foundation +import AVFoundation +import Combine +import SwiftUI + +@MainActor +final class PlayerEngine: ObservableObject { + let player = AVPlayer() + + @Published var queue: [MediaItem] = [] + @Published var currentIndex: Int = -1 + @Published var isPlaying: Bool = false + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var repeatMode: RepeatMode = .all + @Published var isFullscreen: Bool = false + + // 音轨 + @Published var audioTracks: [AVMediaSelectionOption] = [] + @Published var selectedAudioTrack: AVMediaSelectionOption? + + private var currentAsset: AVAsset? + private var timeObserver: Any? + private var itemObserver: NSObjectProtocol? + private var bookmarks: [URL: Data] = [:] + + var currentItem: MediaItem? { + guard currentIndex >= 0, currentIndex < queue.count else { return nil } + return queue[currentIndex] + } + + init() { + let interval = CMTime(seconds: 0.25, preferredTimescale: 600) + timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + guard let self else { return } + Task { @MainActor in + self.currentTime = time.seconds + if let dur = self.player.currentItem?.duration.seconds, dur.isFinite && dur > 0 { + self.duration = dur + } + } + } + + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.onItemFinished() } + } + } + + deinit { + if let obs = timeObserver { player.removeTimeObserver(obs) } + if let obs = itemObserver { NotificationCenter.default.removeObserver(obs) } + } + + // MARK: - 播放控制 + + func play() { + player.play() + isPlaying = true + } + + func pause() { + player.pause() + isPlaying = false + } + + func togglePlay() { + if isPlaying { pause() } else { play() } + } + + func seek(to seconds: Double) { + player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) + } + + func seekForward(_ seconds: Double = 10) { + seek(to: min(currentTime + seconds, duration)) + } + + func seekBackward(_ seconds: Double = 10) { + seek(to: max(currentTime - seconds, 0)) + } + + // MARK: - 列表控制 + + func play(index: Int) { + guard index >= 0, index < queue.count else { return } + currentIndex = index + loadAndPlay(item: queue[index]) + } + + func playNext() { + guard !queue.isEmpty else { return } + let next = (currentIndex + 1) % queue.count + play(index: next) + } + + func playPrevious() { + guard !queue.isEmpty else { return } + if currentTime > 3 { + seek(to: 0) + return + } + let prev = (currentIndex - 1 + queue.count) % queue.count + play(index: prev) + } + + func addToQueue(urls: [URL]) { + for url in urls { + let type: MediaType = { + let ext = url.pathExtension.lowercased() + if ["m3u8", "m3u"].contains(ext) { return .stream } + if ["mp3", "aac", "flac", "wav", "ogg", "m4a", "wma", "opus"].contains(ext) { return .audio } + return .video + }() + let name = url.deletingPathExtension().lastPathComponent + queue.append(MediaItem(url: url, name: name, type: type)) + } + if currentIndex == -1, let first = queue.first { + currentIndex = 0 + loadAndPlay(item: first) + } + } + + func remove(at offsets: IndexSet) { + let removingCurrent = offsets.contains(currentIndex) + queue.remove(atOffsets: offsets) + if queue.isEmpty { + currentIndex = -1 + pause() + player.replaceCurrentItem(with: nil) + duration = 0 + currentTime = 0 + } else if removingCurrent { + currentIndex = min(currentIndex, queue.count - 1) + play(index: currentIndex) + } else { + // Adjust index if items before current were removed + let before = offsets.filter { $0 < currentIndex }.count + currentIndex -= before + } + } + + func move(from source: IndexSet, to destination: Int) { + let oldCurrent = currentIndex + queue.move(fromOffsets: source, toOffset: destination) + // Recalculate currentIndex + if let oldPos = queue.firstIndex(where: { $0.url == currentItem?.url }) { + currentIndex = oldPos + } else { + currentIndex = oldCurrent + } + } + + // MARK: - 音轨 + + func selectAudioTrack(_ option: AVMediaSelectionOption) { + guard let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .audible) else { return } + player.currentItem?.select(option, in: group) + selectedAudioTrack = option + } + + // MARK: - 全屏 + + func toggleFullscreen() { + isFullscreen.toggle() + } + + // MARK: - 内部 + + private func onItemFinished() { + switch repeatMode { + case .single: + seek(to: 0) + play() + case .all: + playNext() + case .none: + if currentIndex < queue.count - 1 { + playNext() + } else { + pause() + } + } + } + + private func loadAndPlay(item: MediaItem) { + #if os(iOS) + // Start accessing security-scoped resource for local files + if item.url.startAccessingSecurityScopedResource() { + // Store bookmark for persistent access + if let bookmark = try? item.url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) { + bookmarks[item.url] = bookmark + } + } + #endif + + let asset: AVAsset + if item.type == .stream { + asset = AVURLAsset(url: item.url) + } else { + asset = AVURLAsset(url: item.url) + } + + currentAsset = asset + audioTracks = [] + selectedAudioTrack = nil + + let playerItem = AVPlayerItem(asset: asset) + player.replaceCurrentItem(with: playerItem) + + // Load duration and audio tracks + Task { + do { + let dur = try await asset.load(.duration) + if dur.seconds.isFinite && dur.seconds > 0 { + self.duration = dur.seconds + } + + let group = try await asset.loadMediaSelectionGroup(for: .audible) + if let group { + let options = group.options + self.audioTracks = options + self.selectedAudioTrack = options.first + } + } catch { + print("Asset load error: \(error)") + } + } + + play() + } + + // MARK: - 时间格式化 + + static func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite && seconds >= 0 else { return "0:00" } + let h = Int(seconds) / 3600 + let m = (Int(seconds) % 3600) / 60 + let s = Int(seconds) % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%d:%02d", m, s) + } +} diff --git a/Sources/PlayerLayerView.swift b/Sources/PlayerLayerView.swift new file mode 100644 index 0000000..ef5196d --- /dev/null +++ b/Sources/PlayerLayerView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import AVFoundation + +// 跨平台视频渲染层 +#if os(iOS) +struct PlayerLayerView: UIViewRepresentable { + let player: AVPlayer + + func makeUIView(context: Context) -> PlayerUIView { + let view = PlayerUIView() + view.playerLayer.player = player + return view + } + + func updateUIView(_ uiView: PlayerUIView, context: Context) { + uiView.playerLayer.player = player + } + + class PlayerUIView: UIView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } + + override init(frame: CGRect) { + super.init(frame: frame) + playerLayer.videoGravity = .resizeAspect + backgroundColor = .black + } + required init?(coder: NSCoder) { fatalError() } + + override func layoutSubviews() { + super.layoutSubviews() + playerLayer.frame = bounds + } + } +} +#else +struct PlayerLayerView: NSViewRepresentable { + let player: AVPlayer + + func makeNSView(context: Context) -> PlayerNSView { + let view = PlayerNSView() + view.playerLayer.player = player + return view + } + + func updateNSView(_ nsView: PlayerNSView, context: Context) { + nsView.playerLayer.player = player + } + + class PlayerNSView: NSView { + let playerLayer = AVPlayerLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + playerLayer.videoGravity = .resizeAspect + wantsLayer = true + layer = playerLayer + layer?.backgroundColor = NSColor.black.cgColor + } + required init?(coder: NSCoder) { fatalError() } + + override func layout() { + super.layout() + playerLayer.frame = bounds + } + } +} +#endif diff --git a/Sources/PlayerView.swift b/Sources/PlayerView.swift new file mode 100644 index 0000000..3ef77d5 --- /dev/null +++ b/Sources/PlayerView.swift @@ -0,0 +1,241 @@ +import SwiftUI +import AVFoundation + +struct PlayerView: View { + @EnvironmentObject var engine: PlayerEngine + @State private var showControls = true + @State private var isSeeking = false + @State private var controlsTimer: Timer? + @FocusState private var focused: Bool + + var body: some View { + ZStack { + Color.black + + if engine.currentItem != nil { + PlayerLayerView(player: engine.player) + .onTapGesture { + toggleControls() + } + + if showControls { + controlsOverlay + .transition(.opacity) + } + } else { + emptyState + } + } + .ignoresSafeArea(.all, edges: engine.isFullscreen ? .all : []) + .onAppear { + focused = true + resetControlsTimer() + } + } + + // MARK: - 空状态 + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "play.rectangle.on.rectangle") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + Text("添加文件开始播放") + .font(.title3) + .foregroundStyle(.secondary) + Text("支持 MP4, MKV, AVI, M4V, MOV, MP3, FLAC, M3U8 等格式") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + // MARK: - 控制层 + + private var controlsOverlay: some View { + VStack { + // 顶部栏: 标题 + 全屏按钮 + HStack { + if let item = engine.currentItem { + Text(item.name) + .font(.headline) + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + } + Spacer() + + Button { + engine.toggleFullscreen() + } label: { + Image(systemName: engine.isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .font(.title3) + .padding(10) + .background(.ultraThinMaterial, in: Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 12) + + Spacer() + + // 底部控制区 + VStack(spacing: 12) { + // 进度条 + progressSection + // 按钮行 + buttonRow + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + .padding(.vertical, 12) + .background( + LinearGradient( + colors: [.clear, .black.opacity(0.6)], + startPoint: .top, endPoint: .bottom + ) + ) + } + } + + // MARK: - 进度条 + + private var progressSection: some View { + VStack(spacing: 4) { + Slider( + value: Binding( + get: { isSeeking ? engine.currentTime : engine.currentTime }, + set: { newValue in + engine.currentTime = newValue + isSeeking = true + } + ), + in: 0...max(engine.duration, 0.1) + ) { editing in + if !editing { + engine.seek(to: engine.currentTime) + isSeeking = false + } + } + #if os(iOS) + .tint(.white) + #else + .tint(.accentColor) + #endif + + HStack { + Text(PlayerEngine.formatTime(engine.currentTime)) + .font(.caption.monospacedDigit()) + Spacer() + Text(PlayerEngine.formatTime(engine.duration)) + .font(.caption.monospacedDigit()) + } + .foregroundStyle(.white.opacity(0.8)) + } + } + + // MARK: - 按钮行 + + private var buttonRow: some View { + HStack(spacing: 24) { + // 音轨 + Menu { + ForEach(Array(engine.audioTracks.enumerated()), id: \.offset) { idx, track in + Button { + engine.selectAudioTrack(track) + } label: { + HStack { + Text(track.displayName.isEmpty ? "音轨 \(idx + 1)" : track.displayName) + if track == engine.selectedAudioTrack { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Image(systemName: "waveform") + .font(.title3) + } + .buttonStyle(.plain) + .disabled(engine.audioTracks.count <= 1) + + Spacer() + + // 上一个 + Button { engine.playPrevious() } label: { + Image(systemName: "backward.fill") + .font(.title2) + } + .buttonStyle(.plain) + + // 快退 + Button { engine.seekBackward() } label: { + Image(systemName: "gobackward.10") + .font(.title2) + } + .buttonStyle(.plain) + + // 播放/暂停 + Button { engine.togglePlay() } label: { + Image(systemName: engine.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 44)) + } + .buttonStyle(.plain) + + // 快进 + Button { engine.seekForward() } label: { + Image(systemName: "goforward.10") + .font(.title2) + } + .buttonStyle(.plain) + + // 下一个 + Button { engine.playNext() } label: { + Image(systemName: "forward.fill") + .font(.title2) + } + .buttonStyle(.plain) + + Spacer() + + // 循环 + Button { + engine.repeatMode = engine.repeatMode.next + } label: { + Image(systemName: engine.repeatMode.icon) + .font(.title3) + .overlay(alignment: .bottom) { + if engine.repeatMode != .none { + Circle() + .fill(.blue) + .frame(width: 4, height: 4) + .offset(y: -4) + } + } + } + .buttonStyle(.plain) + } + .foregroundStyle(.white) + } + + // MARK: - 辅助 + + private func toggleControls() { + withAnimation(.easeInOut(duration: 0.2)) { + showControls.toggle() + } + resetControlsTimer() + } + + private func resetControlsTimer() { + controlsTimer?.invalidate() + controlsTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { _ in + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.3)) { + showControls = false + } + } + } + } +} diff --git a/Sources/PlaylistView.swift b/Sources/PlaylistView.swift new file mode 100644 index 0000000..6c79218 --- /dev/null +++ b/Sources/PlaylistView.swift @@ -0,0 +1,158 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct PlaylistView: View { + @EnvironmentObject var engine: PlayerEngine + @State private var showFileImporter = false + + var body: some View { + VStack(spacing: 0) { + // 顶部工具栏 + HStack { + Text("播放列表") + .font(.headline) + + Spacer() + + Text("\(engine.queue.count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + + Button { + showFileImporter = true + } label: { + Image(systemName: "plus.circle.fill") + .font(.title3) + } + .buttonStyle(.plain) + + if !engine.queue.isEmpty { + Button { + engine.queue.removeAll() + engine.currentIndex = -1 + engine.pause() + engine.player.replaceCurrentItem(with: nil) + } label: { + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + + // 列表 + if engine.queue.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: "music.note.list") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text("列表为空") + .font(.subheadline) + .foregroundStyle(.tertiary) + Button("添加文件") { + showFileImporter = true + } + .font(.caption) + } + Spacer() + } else { + List { + ForEach(Array(engine.queue.enumerated()), id: \.element.id) { idx, item in + playlistRow(item: item, index: idx) + .contentShape(Rectangle()) + .onTapGesture { + engine.play(index: idx) + } + } + .onDelete { offsets in + engine.remove(at: offsets) + } + .onMove { source, destination in + engine.move(from: source, to: destination) + } + } + .listStyle(.plain) + } + } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: supportedTypes, + allowsMultipleSelection: true + ) { result in + if case .success(let urls) = result { + engine.addToQueue(urls: urls) + } + } + } + + // MARK: - 列表行 + + @ViewBuilder + private func playlistRow(item: MediaItem, index: Int) -> some View { + let isCurrent = index == engine.currentIndex + + HStack(spacing: 10) { + // 序号/播放指示 + ZStack { + if isCurrent { + Image(systemName: engine.isPlaying ? "waveform" : "pause.fill") + .font(.caption) + .foregroundStyle(.blue) + .symbolEffect(.variableColor.iterative, isActive: engine.isPlaying && isCurrent) + } else { + Text("\(index + 1)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .frame(width: 20) + + // 类型图标 + Image(systemName: iconName(for: item.type)) + .font(.caption) + .foregroundStyle(isCurrent ? .blue : .secondary) + + // 文件名 + Text(item.name) + .font(.system(size: 13)) + .lineLimit(1) + .foregroundStyle(isCurrent ? .primary : .secondary) + + Spacer() + } + .padding(.vertical, 4) + .listRowBackground(isCurrent ? Color.accentColor.opacity(0.1) : Color.clear) + } + + private func iconName(for type: MediaType) -> String { + switch type { + case .video: return "film" + case .audio: return "music.note" + case .stream: return "dot.radiowaves.left.and.right" + } + } + + // 支持的文件类型 + private var supportedTypes: [UTType] { + var types: [UTType] = [ + .movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, + .mpeg4Audio, .avi, .url + ] + // 扩展常见格式 + for ext in ["mkv", "flac", "wav", "ogg", "m3u8", "m3u", "ts", "mov", "webm"] { + if let uttype = UTType(filenameExtension: ext) { + types.append(uttype) + } + } + return types + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..fbadb99 --- /dev/null +++ b/project.yml @@ -0,0 +1,34 @@ +name: MiniPlayer +options: + bundleIdPrefix: com.miniplayer + deploymentTargets: + iOS: "17.0" + macOS: "14.0" + xcodeVersion: "15.0" + generateEmptyDirectories: true + groupSortPosition: top + +targets: + MiniPlayer: + type: application + platform: [iOS, macOS] + sources: + - path: Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.miniplayer.app + PRODUCT_NAME: MiniPlayer + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: 1 + DEVELOPMENT_TEAM: "" + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.entertainment" + INFOPLIST_KEY_CFBundleDisplayName: "MiniPlayer" + INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright 2024. All rights reserved." + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "5.9" + SWIFT_EMIT_LOC_STRINGS: YES + ASSETCATALOG_COMPILER_APPICON_NAME: "AppIcon" + ENABLE_HARDENED_RUNTIME: YES diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..595b706 --- /dev/null +++ b/setup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# MiniPlayer 项目初始化脚本 +# 在 macOS 上运行,需要已安装 Xcode 和 XcodeGen +set -e + +cd "$(dirname "$0")" + +echo "=== MiniPlayer 项目初始化 ===" + +# 检查 XcodeGen +if ! command -v xcodegen &> /dev/null; then + echo "XcodeGen 未安装,正在通过 Homebrew 安装..." + brew install xcodegen +fi + +echo "生成 Xcode 项目..." +xcodegen generate + +echo "" +echo "✅ 项目已生成: MiniPlayer.xcodeproj" +echo "" +echo "打开项目:" +open MiniPlayer.xcodeproj