MiniPlayer: multiplatform video/audio player (macOS/iOS/iPadOS)
This commit is contained in:
commit
c700f27b16
60
README.md
Normal file
60
README.md
Normal file
@ -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 更可控)
|
||||||
114
Sources/ContentView.swift
Normal file
114
Sources/ContentView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Sources/MiniPlayerApp.swift
Normal file
31
Sources/MiniPlayerApp.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Sources/Models.swift
Normal file
42
Sources/Models.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
246
Sources/PlayerEngine.swift
Normal file
246
Sources/PlayerEngine.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Sources/PlayerLayerView.swift
Normal file
68
Sources/PlayerLayerView.swift
Normal file
@ -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
|
||||||
241
Sources/PlayerView.swift
Normal file
241
Sources/PlayerView.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
Sources/PlaylistView.swift
Normal file
158
Sources/PlaylistView.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
project.yml
Normal file
34
project.yml
Normal file
@ -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
|
||||||
23
setup.sh
Executable file
23
setup.sh
Executable file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user