Compare commits
No commits in common. "main" and "master" have entirely different histories.
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.build/
|
||||
build/
|
||||
*.xcodeproj
|
||||
.DS_Store
|
||||
20
Package.swift
Normal file
20
Package.swift
Normal file
@ -0,0 +1,20 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MiniPlayer",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MiniPlayer",
|
||||
dependencies: []
|
||||
)
|
||||
]
|
||||
)
|
||||
58
README.md
58
README.md
@ -1,2 +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 更可控)
|
||||
|
||||
336
Sources/MiniPlayerApp.swift
Normal file
336
Sources/MiniPlayerApp.swift
Normal file
@ -0,0 +1,336 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
@main
|
||||
struct MiniPlayerApp: App {
|
||||
@StateObject private var bridge = PlayerBridge()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(bridge: bridge)
|
||||
.frame(minWidth: 900, minHeight: 600)
|
||||
.onAppear { bridge.setup() }
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
CommandMenu("播放") {
|
||||
Button("播放/暂停") { bridge.togglePlayPause() }
|
||||
.keyboardShortcut(" ", modifiers: [])
|
||||
Divider()
|
||||
Button("上一首") { bridge.playPrev() }
|
||||
.keyboardShortcut("[", modifiers: [])
|
||||
Button("下一首") { bridge.playNext() }
|
||||
.keyboardShortcut("]", modifiers: [])
|
||||
Divider()
|
||||
Button("全屏") { bridge.toggleFullscreen() }
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
Divider()
|
||||
Button("音量+") { bridge.adjustVolume(by: 0.1) }
|
||||
.keyboardShortcut("=", modifiers: .command)
|
||||
Button("音量-") { bridge.adjustVolume(by: -0.1) }
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
Divider()
|
||||
Button("循环模式") { bridge.cycleRepeatMode() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
}
|
||||
CommandMenu("文件") {
|
||||
Button("打开文件...") { bridge.openFileDialog() }
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
Divider()
|
||||
Button("添加URL...") { bridge.showURLDialog = true }
|
||||
.keyboardShortcut("u", modifiers: .command)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主内容视图(直接SwiftUI,不经过BricksView)
|
||||
struct ContentView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 视频占满全部
|
||||
VideoPlayerRepresentable(player: bridge.player)
|
||||
.background(Color.black)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// 单击播放/暂停 + 双击全屏
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { bridge.togglePlayPause() }
|
||||
#if os(macOS)
|
||||
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
|
||||
#endif
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Logo(左上角,始终显示)
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: { bridge.showToolbar.toggle() }) {
|
||||
Text("🎬")
|
||||
.font(.system(size: 28))
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 12)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 底部 Toolbar(半透明,点击logo才显示)
|
||||
if bridge.showToolbar {
|
||||
VStack {
|
||||
Spacer()
|
||||
ControlToolbar(bridge: bridge)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
// Toast
|
||||
if let msg = bridge.toastMessage {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(msg)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.7))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $bridge.showURLDialog) {
|
||||
URLInputDialog(bridge: bridge)
|
||||
}
|
||||
.sheet(isPresented: $bridge.showTrackDialog) {
|
||||
TrackSelectDialog(bridge: bridge)
|
||||
}
|
||||
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
||||
PlaylistWindowView(bridge: bridge)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
|
||||
bridge.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 底部控制栏
|
||||
struct ControlToolbar: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
// 进度条行
|
||||
HStack(spacing: 8) {
|
||||
Text(bridge.currentTimeText)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
|
||||
ProgressSlider(player: bridge.player, bridge: bridge)
|
||||
.frame(height: 20)
|
||||
|
||||
Text(bridge.totalTimeText)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
HStack(spacing: 10) {
|
||||
#if os(macOS)
|
||||
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
|
||||
#endif
|
||||
ToolbarButton(label: "⏮") { bridge.playPrev() }
|
||||
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() }
|
||||
ToolbarButton(label: "⏭") { bridge.playNext() }
|
||||
|
||||
Spacer()
|
||||
|
||||
// 音量控制
|
||||
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1) }
|
||||
|
||||
// 音量条
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 4)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: geo.size.width * CGFloat(bridge.volume), height: 4)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||
bridge.setVolume(Float(ratio))
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: 80, height: 20)
|
||||
|
||||
Text("\(Int(bridge.volume * 100))")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.frame(width: 30)
|
||||
|
||||
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1) }
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(bridge.currentTrackLabel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
|
||||
ToolbarButton(label: "🎵 音轨") { bridge.showTrackDialog = true }
|
||||
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.rawValue)") { bridge.cycleRepeatMode() }
|
||||
ToolbarButton(label: "⛶") { bridge.toggleFullscreen() }
|
||||
ToolbarButton(label: "📋 列表 (\(bridge.queue.count))") { bridge.togglePlaylistWindow() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.black.opacity(0.55))
|
||||
.onHover { hovering in
|
||||
bridge.isInteracting = hovering
|
||||
if hovering { bridge.recordInteraction() }
|
||||
}
|
||||
.onTapGesture { bridge.recordInteraction() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 半透明按钮
|
||||
struct ToolbarButton: View {
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(hovering ? .white.opacity(0.15) : .clear)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放列表弹窗
|
||||
struct PlaylistWindowView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button("📂 添加文件") { bridge.openFileDialog() }
|
||||
Button("🔗 添加URL") { bridge.showURLDialog = true }
|
||||
Spacer()
|
||||
Text("\(bridge.queue.count) 项")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.regularMaterial)
|
||||
|
||||
Divider()
|
||||
|
||||
if bridge.queue.isEmpty {
|
||||
VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() }
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(Array(bridge.queue.enumerated()), id: \.element.id) { idx, item in
|
||||
HStack {
|
||||
if idx == bridge.currentIndex {
|
||||
Text("▶").foregroundColor(.accentColor).font(.caption)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.name).font(.system(size: 13)).lineLimit(1)
|
||||
Text(item.mediaType).font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) { bridge.removeItem(at: idx) } label: {
|
||||
Image(systemName: "trash").font(.caption)
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : .clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { bridge.playIndex(idx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 350, minHeight: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL输入弹窗
|
||||
struct URLInputDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@State private var url = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("添加媒体URL").font(.headline)
|
||||
TextField("https://example.com/video.m3u8", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button("取消") { dismiss() }
|
||||
Spacer()
|
||||
Button("添加") {
|
||||
if !url.isEmpty { bridge.addURL(url); dismiss() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(url.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 音轨选择弹窗
|
||||
struct TrackSelectDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("选择音轨").font(.headline)
|
||||
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
|
||||
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
|
||||
HStack {
|
||||
Text("Track \(idx + 1)")
|
||||
Spacer()
|
||||
if idx == bridge.currentTrackIndex {
|
||||
Text("✓").foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.padding(8).contentShape(Rectangle())
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
Divider()
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 250)
|
||||
}
|
||||
}
|
||||
427
Sources/PlayerBridge.swift
Normal file
427
Sources/PlayerBridge.swift
Normal file
@ -0,0 +1,427 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Combine
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// 播放队列项
|
||||
struct MediaItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
var url: URL
|
||||
var name: String
|
||||
var mediaType: String
|
||||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
|
||||
/// 循环模式
|
||||
enum RepeatMode: String, CaseIterable {
|
||||
case none = "不循环"
|
||||
case single = "单曲循环"
|
||||
case all = "列表循环"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .none: return "➡️"
|
||||
case .single: return "🔂"
|
||||
case .all: return "🔁"
|
||||
}
|
||||
}
|
||||
|
||||
var next: RepeatMode {
|
||||
switch self {
|
||||
case .none: return .single
|
||||
case .single: return .all
|
||||
case .all: return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PlayerBridge: ObservableObject {
|
||||
|
||||
// MARK: - Published状态
|
||||
@Published var showURLDialog = false
|
||||
@Published var showTrackDialog = false
|
||||
@Published var toastMessage: String?
|
||||
@Published var availableTracks: [String] = []
|
||||
@Published var currentTrackIndex: Int = 0
|
||||
@Published var isFullscreen = false
|
||||
@Published var showToolbar = false
|
||||
@Published var isPlaying = false
|
||||
@Published var currentTimeText = "00:00"
|
||||
@Published var totalTimeText = "00:00"
|
||||
@Published var currentTrackLabel = "🎵 Track 1"
|
||||
@Published var volume: Float = 1.0
|
||||
@Published var progressRatio: Double = 0
|
||||
@Published var cachedDuration: Double = 0
|
||||
@Published var queue: [MediaItem] = []
|
||||
@Published var currentIndex: Int = -1
|
||||
@Published var repeatMode: RepeatMode = .all
|
||||
|
||||
// MARK: - 内部状态
|
||||
let player = AVPlayer()
|
||||
private var timeObserver: Any?
|
||||
private var itemStatusObserver: NSKeyValueObservation?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var preferredTrackIndex: Int = 0
|
||||
|
||||
#if os(macOS)
|
||||
private var fullscreenWindow: NSWindow?
|
||||
private var playlistWindow: NSWindow?
|
||||
#endif
|
||||
|
||||
// MARK: - 初始化
|
||||
func setup() {
|
||||
player.volume = volume
|
||||
|
||||
setupTimeObserver()
|
||||
setupPlaybackStatusObserver()
|
||||
setupEndObserver()
|
||||
}
|
||||
|
||||
// MARK: - 播放控制
|
||||
func togglePlayPause() {
|
||||
if player.timeControlStatus == .playing {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
} else {
|
||||
player.play()
|
||||
isPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
func playPrev() {
|
||||
guard !queue.isEmpty else { return }
|
||||
let idx = (currentIndex - 1 + queue.count) % queue.count
|
||||
playIndex(idx)
|
||||
}
|
||||
|
||||
func playNext() {
|
||||
guard !queue.isEmpty else { return }
|
||||
switch repeatMode {
|
||||
case .none:
|
||||
if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) }
|
||||
case .single:
|
||||
player.seek(to: .zero); player.play()
|
||||
case .all:
|
||||
playIndex((currentIndex + 1) % queue.count)
|
||||
}
|
||||
}
|
||||
|
||||
func playIndex(_ index: Int) {
|
||||
guard index >= 0 && index < queue.count else { return }
|
||||
currentIndex = index
|
||||
let item = queue[index]
|
||||
|
||||
let playerItem = AVPlayerItem(url: item.url)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
player.play()
|
||||
isPlaying = true
|
||||
|
||||
cachedDuration = 0
|
||||
progressRatio = 0
|
||||
currentTimeText = "00:00"
|
||||
totalTimeText = "00:00"
|
||||
|
||||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||
if pi.status == .readyToPlay {
|
||||
Task { @MainActor in self?.loadDuration(pi) }
|
||||
}
|
||||
}
|
||||
|
||||
loadTrackInfo(playerItem)
|
||||
showToast("正在播放: \(item.name)")
|
||||
}
|
||||
|
||||
// MARK: - 播放结束
|
||||
private func setupEndObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.onPlaybackEnded() }
|
||||
}
|
||||
}
|
||||
|
||||
private func onPlaybackEnded() {
|
||||
switch repeatMode {
|
||||
case .none:
|
||||
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
|
||||
case .single:
|
||||
player.seek(to: .zero); player.play()
|
||||
case .all:
|
||||
playNext()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 时间更新
|
||||
private func setupTimeObserver() {
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
Task { @MainActor in self?.updateTimeDisplay(time: time) }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTimeDisplay(time: CMTime) {
|
||||
let current = time.seconds
|
||||
var total = player.currentItem?.duration.seconds ?? 0
|
||||
|
||||
if !total.isFinite || total <= 0 { total = cachedDuration }
|
||||
else { cachedDuration = total }
|
||||
|
||||
currentTimeText = formatTime(current)
|
||||
if total.isFinite && total > 0 {
|
||||
totalTimeText = formatTime(total)
|
||||
progressRatio = max(0, min(1, current / total))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDuration(_ item: AVPlayerItem) {
|
||||
Task {
|
||||
if let dur = try? await item.asset.load(.duration) {
|
||||
let secs = dur.seconds
|
||||
if secs.isFinite && secs > 0 {
|
||||
cachedDuration = secs
|
||||
totalTimeText = formatTime(secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite && seconds >= 0 else { return "00: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: "%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
// MARK: - 音量
|
||||
func adjustVolume(by delta: Float) {
|
||||
volume = max(0, min(1, volume + delta))
|
||||
player.volume = volume
|
||||
}
|
||||
|
||||
func setVolume(_ val: Float) {
|
||||
volume = max(0, min(1, val))
|
||||
player.volume = volume
|
||||
}
|
||||
|
||||
// MARK: - 音轨
|
||||
private func loadTrackInfo(_ item: AVPlayerItem) {
|
||||
Task {
|
||||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
|
||||
availableTracks = ["Track 1"]
|
||||
currentTrackIndex = 0
|
||||
updateTrackLabel()
|
||||
return
|
||||
}
|
||||
let options = group.options
|
||||
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
|
||||
var targetIndex = preferredTrackIndex
|
||||
if targetIndex >= options.count { targetIndex = 0 }
|
||||
item.select(options[targetIndex], in: group)
|
||||
currentTrackIndex = targetIndex
|
||||
updateTrackLabel()
|
||||
}
|
||||
}
|
||||
|
||||
func selectTrack(index: Int) {
|
||||
guard let item = player.currentItem else { return }
|
||||
Task {
|
||||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible),
|
||||
index < group.options.count else { return }
|
||||
item.select(group.options[index], in: group)
|
||||
currentTrackIndex = index
|
||||
preferredTrackIndex = index
|
||||
updateTrackLabel()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTrackLabel() {
|
||||
currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||
}
|
||||
|
||||
// MARK: - 循环模式
|
||||
func cycleRepeatMode() {
|
||||
repeatMode = repeatMode.next
|
||||
showToast("循环模式: \(repeatMode.rawValue)")
|
||||
}
|
||||
|
||||
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||||
func toggleFullscreen() {
|
||||
#if os(macOS)
|
||||
if let fw = fullscreenWindow {
|
||||
fw.close()
|
||||
fullscreenWindow = nil
|
||||
isFullscreen = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let screen = NSScreen.main else { return }
|
||||
let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
win.level = .screenSaver
|
||||
win.backgroundColor = .black
|
||||
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
win.hasShadow = false
|
||||
|
||||
// 使用纯 NSView + AVPlayerLayer,避免 SwiftUI 桥接崩溃
|
||||
let playerView = FullscreenPlayerView(frame: screen.frame)
|
||||
playerView.player = player
|
||||
win.contentView = playerView
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
// 确保键盘事件(Escape退出)能被接收
|
||||
win.makeFirstResponder(playerView)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
fullscreenWindow = win
|
||||
isFullscreen = true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - 文件操作
|
||||
func openFileDialog() {
|
||||
#if os(macOS)
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.allowedContentTypes = [
|
||||
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio
|
||||
]
|
||||
if panel.runModal() == .OK {
|
||||
for url in panel.urls {
|
||||
addItem(url: url, name: url.lastPathComponent, type: "file")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func addURL(_ urlString: String) {
|
||||
guard let url = URL(string: urlString) else { showToast("无效URL"); return }
|
||||
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||||
addItem(url: url, name: name, type: type)
|
||||
}
|
||||
|
||||
private func addItem(url: URL, name: String, type: String) {
|
||||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
|
||||
if queue.count == 1 { playIndex(0) }
|
||||
}
|
||||
|
||||
func removeItem(at index: Int) {
|
||||
guard index >= 0 && index < queue.count else { return }
|
||||
let wasPlaying = (index == currentIndex)
|
||||
queue.remove(at: index)
|
||||
if wasPlaying {
|
||||
if queue.isEmpty {
|
||||
currentIndex = -1
|
||||
player.replaceCurrentItem(with: nil)
|
||||
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
|
||||
} else {
|
||||
currentIndex = min(index, queue.count - 1)
|
||||
playIndex(currentIndex)
|
||||
}
|
||||
} else if index < currentIndex {
|
||||
currentIndex -= 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放列表窗口
|
||||
@Published var showPlaylistSheet = false
|
||||
@Published var lastInteraction = Date()
|
||||
@Published var isInteracting = false
|
||||
|
||||
func recordInteraction() { lastInteraction = Date() }
|
||||
|
||||
func togglePlaylistWindow() {
|
||||
#if os(macOS)
|
||||
if let win = playlistWindow, win.isVisible {
|
||||
win.close(); playlistWindow = nil; return
|
||||
}
|
||||
let win = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
|
||||
)
|
||||
win.title = "播放列表 (\(queue.count))"
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
playlistWindow = win
|
||||
#else
|
||||
showPlaylistSheet.toggle()
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Toast
|
||||
private func showToast(_ message: String) {
|
||||
toastMessage = message
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
if self?.toastMessage == message { self?.toastMessage = nil }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放状态
|
||||
private func setupPlaybackStatusObserver() {
|
||||
player.publisher(for: \.timeControlStatus)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
self?.isPlaying = (status == .playing)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = timeObserver { player.removeTimeObserver(observer) }
|
||||
itemStatusObserver?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 全屏专用 NSView(纯 AppKit,不经过 SwiftUI)
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
class FullscreenPlayerView: NSView {
|
||||
var player: AVPlayer? {
|
||||
get { (layer as? AVPlayerLayer)?.player }
|
||||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||||
}
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
let playerLayer = AVPlayerLayer()
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
layer = playerLayer
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
(layer as? AVPlayerLayer)?.frame = bounds
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
if event.clickCount == 2 {
|
||||
// 双击退出全屏 — 通过通知
|
||||
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||||
} else {
|
||||
// 单击播放/暂停
|
||||
if let p = player {
|
||||
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if event.keyCode == 53 { // Escape
|
||||
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
46
Sources/ProgressSlider.swift
Normal file
46
Sources/ProgressSlider.swift
Normal file
@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
/// ProgressSlider — 纯SwiftUI进度条,拖动seek
|
||||
struct ProgressSlider: View {
|
||||
let player: AVPlayer
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@State private var isDragging = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(height: 4)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: geo.size.width * bridge.progressRatio, height: 4)
|
||||
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 14, height: 14)
|
||||
.offset(x: geo.size.width * bridge.progressRatio - 7)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
.frame(height: 20)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||
if bridge.cachedDuration > 0 {
|
||||
let seekTime = ratio * bridge.cachedDuration
|
||||
player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
isDragging = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 20)
|
||||
}
|
||||
}
|
||||
79
Sources/VideoPlayerView.swift
Normal file
79
Sources/VideoPlayerView.swift
Normal file
@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
// MARK: - 跨平台AVPlayer渲染
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct VideoPlayerRepresentable: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerUIView {
|
||||
let view = PlayerUIView()
|
||||
view.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerUIView, context: Context) {
|
||||
uiView.player = player
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerUIView: UIView {
|
||||
override static var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
|
||||
var player: AVPlayer? {
|
||||
get { (layer as? AVPlayerLayer)?.player }
|
||||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||||
}
|
||||
|
||||
override var contentMode: UIView.ContentMode {
|
||||
get { (layer as? AVPlayerLayer)?.videoGravity == .resizeAspectFill ? .scaleAspectFill : .scaleAspectFit }
|
||||
set {
|
||||
(layer as? AVPlayerLayer)?.videoGravity = newValue == .scaleAspectFill ? .resizeAspectFill : .resizeAspect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
struct VideoPlayerRepresentable: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeNSView(context: Context) -> PlayerNSView {
|
||||
let view = PlayerNSView()
|
||||
view.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PlayerNSView, context: Context) {
|
||||
nsView.player = player
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerNSView: NSView {
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
let playerLayer = AVPlayerLayer()
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
layer = playerLayer
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
(layer as? AVPlayerLayer)?.frame = bounds
|
||||
}
|
||||
|
||||
var player: AVPlayer? {
|
||||
get { (layer as? AVPlayerLayer)?.player }
|
||||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
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