Compare commits
No commits in common. "main" and "master" have entirely different histories.
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.build/
|
||||||
|
build/
|
||||||
|
*.xcodeproj
|
||||||
|
.DS_Store
|
||||||
|
MiniPlayer.app/
|
||||||
74
Icons_iOS/AppIcon.appiconset/Contents.json
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "icon_40.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_60.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_58.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_87.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_80.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_120.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_120.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_180.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_152.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_167.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "83.5x83.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_1024.png",
|
||||||
|
"idiom": "ios-marketing",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "MiniPlayer",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Icons_iOS/AppIcon.appiconset/icon_1024.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Icons_iOS/AppIcon.appiconset/icon_120.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Icons_iOS/AppIcon.appiconset/icon_152.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Icons_iOS/AppIcon.appiconset/icon_167.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Icons_iOS/AppIcon.appiconset/icon_180.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Icons_iOS/AppIcon.appiconset/icon_40.png
Normal file
|
After Width: | Height: | Size: 503 B |
BIN
Icons_iOS/AppIcon.appiconset/icon_58.png
Normal file
|
After Width: | Height: | Size: 683 B |
BIN
Icons_iOS/AppIcon.appiconset/icon_60.png
Normal file
|
After Width: | Height: | Size: 696 B |
BIN
Icons_iOS/AppIcon.appiconset/icon_80.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
Icons_iOS/AppIcon.appiconset/icon_87.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
68
Icons_macOS/MiniPlayer.appiconset/Contents.json
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "icon_16.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_16@2x.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_32.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_32@2x.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_128.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_128@2x.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_256.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_256@2x.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_512.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon_512@2x.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "MiniPlayer",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Icons_macOS/MiniPlayer.appiconset/icon_128.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_128@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_16.png
Normal file
|
After Width: | Height: | Size: 271 B |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_16@2x.png
Normal file
|
After Width: | Height: | Size: 445 B |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_256.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_256@2x.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_32.png
Normal file
|
After Width: | Height: | Size: 445 B |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_32@2x.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_512.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
Icons_macOS/MiniPlayer.appiconset/icon_512@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
22
Package.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MiniPlayer",
|
||||||
|
defaultLocalization: "en",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v17),
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
|
||||||
|
],
|
||||||
|
dependencies: [],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "MiniPlayer",
|
||||||
|
dependencies: [],
|
||||||
|
resources: [.process("Resources")]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
58
README.md
@ -1,2 +1,60 @@
|
|||||||
# MiniPlayer
|
# 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 更可控)
|
||||||
|
|||||||
53
Sources/Localization.swift
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - i18n 国际化
|
||||||
|
enum L {
|
||||||
|
// 播放控制
|
||||||
|
static let playPause = NSLocalizedString("play_pause", value: "Play/Pause", comment: "")
|
||||||
|
static let prev = NSLocalizedString("prev", value: "Previous", comment: "")
|
||||||
|
static let next = NSLocalizedString("next", value: "Next", comment: "")
|
||||||
|
static let fullscreen = NSLocalizedString("fullscreen", value: "Fullscreen", comment: "")
|
||||||
|
static let volUp = NSLocalizedString("vol_up", value: "Volume+", comment: "")
|
||||||
|
static let volDown = NSLocalizedString("vol_down", value: "Volume-", comment: "")
|
||||||
|
static let repeatMode = NSLocalizedString("repeat_mode", value: "Repeat Mode", comment: "")
|
||||||
|
static let openFile = NSLocalizedString("open_file", value: "Open File...", comment: "")
|
||||||
|
static let addURL = NSLocalizedString("add_url", value: "Add URL...", comment: "")
|
||||||
|
static let playback = NSLocalizedString("menu_playback", value: "Playback", comment: "")
|
||||||
|
static let file = NSLocalizedString("menu_file", value: "File", comment: "")
|
||||||
|
|
||||||
|
// 播放列表
|
||||||
|
static let playlist = NSLocalizedString("playlist", value: "Playlist", comment: "")
|
||||||
|
static let addFile = NSLocalizedString("add_file", value: "Add File", comment: "")
|
||||||
|
static let addURLBtn = NSLocalizedString("add_url_btn", value: "Add URL", comment: "")
|
||||||
|
static let itemsCount = NSLocalizedString("items_count", value: "items", comment: "")
|
||||||
|
static let noMedia = NSLocalizedString("no_media", value: "No media", comment: "")
|
||||||
|
|
||||||
|
// URL弹窗
|
||||||
|
static let addMediaURL = NSLocalizedString("add_media_url", value: "Add Media URL", comment: "")
|
||||||
|
static let cancel = NSLocalizedString("cancel", value: "Cancel", comment: "")
|
||||||
|
static let add = NSLocalizedString("add", value: "Add", comment: "")
|
||||||
|
|
||||||
|
// 音轨
|
||||||
|
static let selectTrack = NSLocalizedString("select_track", value: "Select Track", comment: "")
|
||||||
|
static let track = NSLocalizedString("track", value: "Track", comment: "")
|
||||||
|
static let audioTrack = NSLocalizedString("audio_track", value: "Audio Track", comment: "")
|
||||||
|
static let close = NSLocalizedString("close", value: "Close", comment: "")
|
||||||
|
|
||||||
|
// 循环模式
|
||||||
|
static let repeatNone = NSLocalizedString("repeat_none", value: "No Repeat", comment: "")
|
||||||
|
static let repeatSingle = NSLocalizedString("repeat_single", value: "Repeat One", comment: "")
|
||||||
|
static let repeatAll = NSLocalizedString("repeat_all", value: "Repeat All", comment: "")
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
static func nowPlaying(_ name: String) -> String {
|
||||||
|
String(format: NSLocalizedString("now_playing", value: "Now Playing: %@", comment: ""), name)
|
||||||
|
}
|
||||||
|
static func repeatModeLabel(_ mode: String) -> String {
|
||||||
|
String(format: NSLocalizedString("repeat_mode_label", value: "Repeat: %@", comment: ""), mode)
|
||||||
|
}
|
||||||
|
static let invalidURL = NSLocalizedString("invalid_url", value: "Invalid URL", comment: "")
|
||||||
|
|
||||||
|
// 打开按钮
|
||||||
|
static let open = NSLocalizedString("open", value: "Open", comment: "")
|
||||||
|
static let list = NSLocalizedString("list", value: "List", comment: "")
|
||||||
|
}
|
||||||
362
Sources/MiniPlayerApp.swift
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@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() }
|
||||||
|
.onDisappear { bridge.cleanup() }
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.commands {
|
||||||
|
CommandGroup(replacing: .newItem) {}
|
||||||
|
CommandMenu(L.playback) {
|
||||||
|
Button(L.playPause) { bridge.togglePlayPause() }
|
||||||
|
.keyboardShortcut(" ", modifiers: [])
|
||||||
|
Divider()
|
||||||
|
Button(L.prev) { bridge.playPrev() }
|
||||||
|
.keyboardShortcut("[", modifiers: [])
|
||||||
|
Button(L.next) { bridge.playNext() }
|
||||||
|
.keyboardShortcut("]", modifiers: [])
|
||||||
|
Divider()
|
||||||
|
Button(L.fullscreen) { bridge.toggleFullscreen() }
|
||||||
|
.keyboardShortcut("f", modifiers: .command)
|
||||||
|
Divider()
|
||||||
|
Button(L.volUp) { bridge.adjustVolume(by: 0.1) }
|
||||||
|
.keyboardShortcut("=", modifiers: .command)
|
||||||
|
Button(L.volDown) { bridge.adjustVolume(by: -0.1) }
|
||||||
|
.keyboardShortcut("-", modifiers: .command)
|
||||||
|
Divider()
|
||||||
|
Button(L.repeatMode) { bridge.cycleRepeatMode() }
|
||||||
|
.keyboardShortcut("r", modifiers: .command)
|
||||||
|
}
|
||||||
|
CommandMenu(L.file) {
|
||||||
|
Button(L.openFile) { bridge.openFileDialog() }
|
||||||
|
.keyboardShortcut("o", modifiers: .command)
|
||||||
|
Divider()
|
||||||
|
Button(L.addURL) { bridge.showURLDialog = true }
|
||||||
|
.keyboardShortcut("u", modifiers: .command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 主内容视图
|
||||||
|
struct ContentView: View {
|
||||||
|
@ObservedObject var bridge: PlayerBridge
|
||||||
|
// 自动隐藏:本地状态,不经过 bridge 的 @Published
|
||||||
|
@State private var toolbarVisible = false
|
||||||
|
@State private var isHoveringToolbar = false
|
||||||
|
@State private var lastInteraction = Date()
|
||||||
|
private let hideTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
||||||
|
private let autoHideInterval: TimeInterval = 60
|
||||||
|
|
||||||
|
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()
|
||||||
|
touchInteraction()
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
|
||||||
|
#endif
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Logo(左上角,始终显示)
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
toolbarVisible.toggle()
|
||||||
|
if toolbarVisible { touchInteraction() }
|
||||||
|
}) {
|
||||||
|
MiniPlayerIcon()
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.padding(6)
|
||||||
|
.background(.black.opacity(0.4))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部 Toolbar(半透明)
|
||||||
|
if toolbarVisible {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar, onTouch: touchInteraction)
|
||||||
|
.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: toolbarVisible)
|
||||||
|
// 自动隐藏 Timer:每2秒检查一次
|
||||||
|
.onReceive(hideTimer) { _ in
|
||||||
|
guard toolbarVisible else { return }
|
||||||
|
if isHoveringToolbar { lastInteraction = Date(); return }
|
||||||
|
if Date().timeIntervalSince(lastInteraction) >= autoHideInterval {
|
||||||
|
toolbarVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touchInteraction() {
|
||||||
|
lastInteraction = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 底部控制栏
|
||||||
|
struct ControlToolbar: View {
|
||||||
|
@ObservedObject var bridge: PlayerBridge
|
||||||
|
@Binding var isHovering: Bool
|
||||||
|
let onTouch: () -> Void
|
||||||
|
|
||||||
|
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: "📂 \(L.open)") { bridge.openFileDialog(); onTouch() }
|
||||||
|
#endif
|
||||||
|
ToolbarButton(label: "⏮") { bridge.playPrev(); onTouch() }
|
||||||
|
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause(); onTouch() }
|
||||||
|
ToolbarButton(label: "⏭") { bridge.playNext(); onTouch() }
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 音量控制
|
||||||
|
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1); onTouch() }
|
||||||
|
|
||||||
|
// 音量条
|
||||||
|
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))
|
||||||
|
onTouch()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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); onTouch() }
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(bridge.currentTrackLabel)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
|
||||||
|
ToolbarButton(label: L.audioTrack) { bridge.showTrackDialog = true; onTouch() }
|
||||||
|
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.displayName)") { bridge.cycleRepeatMode(); onTouch() }
|
||||||
|
ToolbarButton(label: "⛶") { bridge.toggleFullscreen(); onTouch() }
|
||||||
|
ToolbarButton(label: "📋 \(L.list) (\(bridge.queue.count))") { bridge.togglePlaylistWindow(); onTouch() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.black.opacity(0.55))
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovering = hovering
|
||||||
|
if hovering { onTouch() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(L.addFile) { bridge.openFileDialog() }
|
||||||
|
Button(L.addURLBtn) { bridge.showURLDialog = true }
|
||||||
|
Spacer()
|
||||||
|
Text("\(bridge.queue.count) \(L.itemsCount)")
|
||||||
|
.font(.caption).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if bridge.queue.isEmpty {
|
||||||
|
VStack { Spacer(); Text(L.noMedia).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(L.addMediaURL).font(.headline)
|
||||||
|
TextField("https://example.com/video.m3u8", text: $url)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
HStack {
|
||||||
|
Button(L.cancel) { dismiss() }
|
||||||
|
Spacer()
|
||||||
|
Button(L.add) {
|
||||||
|
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(L.selectTrack).font(.headline)
|
||||||
|
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
|
||||||
|
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
|
||||||
|
HStack {
|
||||||
|
Text("\(L.track) \(idx + 1)")
|
||||||
|
Spacer()
|
||||||
|
if idx == bridge.currentTrackIndex {
|
||||||
|
Text("✓").foregroundColor(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8).contentShape(Rectangle())
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(L.close) { dismiss() }
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(minWidth: 250)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
Sources/MiniPlayerIcon.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - MiniPlayer 三色图标(播放键 + 三色圆环)
|
||||||
|
struct MiniPlayerIcon: View {
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let size = min(geo.size.width, geo.size.height)
|
||||||
|
ZStack {
|
||||||
|
// 三色圆环
|
||||||
|
RingArc(startAngle: -30, endAngle: 90)
|
||||||
|
.stroke(Color(red: 1.0, green: 0.23, blue: 0.19), lineWidth: size * 0.07)
|
||||||
|
.frame(width: size * 0.82, height: size * 0.82)
|
||||||
|
|
||||||
|
RingArc(startAngle: 90, endAngle: 210)
|
||||||
|
.stroke(Color(red: 0.20, green: 0.78, blue: 0.35), lineWidth: size * 0.07)
|
||||||
|
.frame(width: size * 0.82, height: size * 0.82)
|
||||||
|
|
||||||
|
RingArc(startAngle: 210, endAngle: 330)
|
||||||
|
.stroke(Color(red: 0.0, green: 0.48, blue: 1.0), lineWidth: size * 0.07)
|
||||||
|
.frame(width: size * 0.82, height: size * 0.82)
|
||||||
|
|
||||||
|
// 三色播放三角
|
||||||
|
TriColorPlay(size: size * 0.38)
|
||||||
|
.offset(x: size * 0.03) // 视觉居中偏移
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 圆弧段
|
||||||
|
struct RingArc: Shape {
|
||||||
|
let startAngle: Double
|
||||||
|
let endAngle: Double
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var p = Path()
|
||||||
|
let center = CGPoint(x: rect.midX, y: rect.midY)
|
||||||
|
let radius = min(rect.width, rect.height) / 2
|
||||||
|
p.addArc(
|
||||||
|
center: center,
|
||||||
|
radius: radius,
|
||||||
|
startAngle: .degrees(startAngle),
|
||||||
|
endAngle: .degrees(endAngle),
|
||||||
|
clockwise: false
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 三色播放三角(中心到三顶点分割)
|
||||||
|
struct TriColorPlay: View {
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { ctx, canvasSize in
|
||||||
|
let w = canvasSize.width
|
||||||
|
let h = canvasSize.height
|
||||||
|
|
||||||
|
// 三角顶点(播放键朝右)
|
||||||
|
let top = CGPoint(x: w * 0.15, y: 0)
|
||||||
|
let bottom = CGPoint(x: w * 0.15, y: h)
|
||||||
|
let right = CGPoint(x: w, y: h * 0.5)
|
||||||
|
|
||||||
|
// 重心
|
||||||
|
let cx = (top.x + bottom.x + right.x) / 3
|
||||||
|
let cy = (top.y + bottom.y + right.y) / 3
|
||||||
|
let center = CGPoint(x: cx, y: cy)
|
||||||
|
|
||||||
|
// 三色分区
|
||||||
|
let red = Path { p in
|
||||||
|
p.move(to: center)
|
||||||
|
p.addLine(to: top)
|
||||||
|
p.addLine(to: right)
|
||||||
|
p.closeSubpath()
|
||||||
|
}
|
||||||
|
let green = Path { p in
|
||||||
|
p.move(to: center)
|
||||||
|
p.addLine(to: right)
|
||||||
|
p.addLine(to: bottom)
|
||||||
|
p.closeSubpath()
|
||||||
|
}
|
||||||
|
let blue = Path { p in
|
||||||
|
p.move(to: center)
|
||||||
|
p.addLine(to: bottom)
|
||||||
|
p.addLine(to: top)
|
||||||
|
p.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fill(red, with: .color(Color(red: 1.0, green: 0.23, blue: 0.19)))
|
||||||
|
ctx.fill(green, with: .color(Color(red: 0.20, green: 0.78, blue: 0.35)))
|
||||||
|
ctx.fill(blue, with: .color(Color(red: 0.0, green: 0.48, blue: 1.0)))
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
let lineW = max(0.5, w * 0.015)
|
||||||
|
for vertex in [top, bottom, right] {
|
||||||
|
var lp = Path()
|
||||||
|
lp.move(to: center)
|
||||||
|
lp.addLine(to: vertex)
|
||||||
|
ctx.stroke(lp, with: .color(.white.opacity(0.6)), lineWidth: lineW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
472
Sources/PlayerBridge.swift
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
#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 = "none"
|
||||||
|
case single = "single"
|
||||||
|
case all = "all"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return L.repeatNone
|
||||||
|
case .single: return L.repeatSingle
|
||||||
|
case .all: return L.repeatAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
// 用纯 Swift Timer 代替 addPeriodicTimeObserver
|
||||||
|
// addPeriodicTimeObserver 内部的 FigNotificationCenter weak listener 机制
|
||||||
|
// 与 autorelease pool drain 存在竞态,导致双重释放野指针崩溃
|
||||||
|
private var updateTimer: Timer?
|
||||||
|
private var itemStatusObserver: NSKeyValueObservation?
|
||||||
|
private var endObserverToken: NSObjectProtocol?
|
||||||
|
private var preferredTrackIndex: Int = 0
|
||||||
|
private var isTearingDown = false
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var fullscreenWindow: NSWindow?
|
||||||
|
private var playlistWindow: NSWindow?
|
||||||
|
private var fullscreenEventMonitor: Any?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - 初始化
|
||||||
|
func setup() {
|
||||||
|
player.volume = volume
|
||||||
|
|
||||||
|
setupTimeObserver()
|
||||||
|
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]
|
||||||
|
|
||||||
|
// 先清除旧的 KVO,避免野指针
|
||||||
|
itemStatusObserver?.invalidate()
|
||||||
|
itemStatusObserver = nil
|
||||||
|
|
||||||
|
let playerItem = AVPlayerItem(url: item.url)
|
||||||
|
player.replaceCurrentItem(with: playerItem)
|
||||||
|
player.play()
|
||||||
|
isPlaying = true
|
||||||
|
|
||||||
|
cachedDuration = 0
|
||||||
|
progressRatio = 0
|
||||||
|
currentTimeText = "00:00"
|
||||||
|
totalTimeText = "00:00"
|
||||||
|
|
||||||
|
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||||||
|
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||||
|
if pi.status == .readyToPlay {
|
||||||
|
DispatchQueue.main.async { self?.loadDuration(pi) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTrackInfo(playerItem)
|
||||||
|
showToast(L.nowPlaying(item.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 播放结束
|
||||||
|
private func setupEndObserver() {
|
||||||
|
// queue: .main → 回调已在主线程,直接调用,不创建 Task
|
||||||
|
endObserverToken = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
Task { @MainActor in self.onPlaybackEnded() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onPlaybackEnded() {
|
||||||
|
guard !isTearingDown else { return }
|
||||||
|
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: - 时间更新(纯 Timer,不经过 CoreMedia)
|
||||||
|
private func setupTimeObserver() {
|
||||||
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
|
// Timer.scheduledTimer 的回调在添加它的 RunLoop 线程上
|
||||||
|
// 由于 PlayerBridge 是 @MainActor 且 setup() 在主线程调用,
|
||||||
|
// Timer 被添加到主线程 RunLoop
|
||||||
|
// 但 Timer 回调不是 @MainActor,所以需要 dispatch
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self = self, !self.isTearingDown else { return }
|
||||||
|
self.updateTimeDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTimeDisplay() {
|
||||||
|
// 只用 player.currentTime() 和 cachedDuration,
|
||||||
|
// 不访问 item.duration(会触发 CoreMedia FigNotificationCenter 竞态)
|
||||||
|
let current = player.currentTime().seconds
|
||||||
|
let total = cachedDuration
|
||||||
|
|
||||||
|
// 更新播放状态(替代 KVO,避免后台线程竞态)
|
||||||
|
isPlaying = (player.rate > 0 && player.error == nil)
|
||||||
|
|
||||||
|
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 = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 循环模式
|
||||||
|
func cycleRepeatMode() {
|
||||||
|
repeatMode = repeatMode.next
|
||||||
|
showToast(L.repeatModeLabel(repeatMode.displayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||||||
|
func toggleFullscreen() {
|
||||||
|
#if os(macOS)
|
||||||
|
if let fw = fullscreenWindow {
|
||||||
|
// 移除事件监控
|
||||||
|
if let monitor = fullscreenEventMonitor {
|
||||||
|
NSEvent.removeMonitor(monitor)
|
||||||
|
fullscreenEventMonitor = nil
|
||||||
|
}
|
||||||
|
// 先断开 player 引用再关闭窗口
|
||||||
|
if let playerView = fw.contentView as? AVPlayerView {
|
||||||
|
playerView.player = nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
// 使用系统 AVPlayerView,Apple 内部处理所有 CoreMedia 生命周期竞态
|
||||||
|
let playerView = AVPlayerView(frame: screen.frame)
|
||||||
|
playerView.player = player
|
||||||
|
playerView.controlsStyle = .none
|
||||||
|
playerView.videoGravity = .resizeAspect
|
||||||
|
win.contentView = playerView
|
||||||
|
win.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
fullscreenWindow = win
|
||||||
|
isFullscreen = true
|
||||||
|
|
||||||
|
// 添加本地事件监控:双击退出全屏、单击暂停/播放、Escape退出
|
||||||
|
fullscreenEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .keyDown]) { [weak self] event in
|
||||||
|
guard let self = self, let fw = self.fullscreenWindow else { return event }
|
||||||
|
// 只处理全屏窗口内的事件
|
||||||
|
if event.window !== fw { return event }
|
||||||
|
|
||||||
|
if event.type == .leftMouseDown {
|
||||||
|
if event.clickCount >= 2 {
|
||||||
|
self.toggleFullscreen()
|
||||||
|
return nil // 消费事件
|
||||||
|
} else {
|
||||||
|
if self.player.timeControlStatus == .playing {
|
||||||
|
self.player.pause()
|
||||||
|
} else {
|
||||||
|
self.player.play()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if event.type == .keyDown {
|
||||||
|
if event.keyCode == 53 { // Escape
|
||||||
|
self.toggleFullscreen()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
#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(L.invalidURL); 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
|
||||||
|
var lastInteraction = Date()
|
||||||
|
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 = "\(L.playlist) (\(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: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate)
|
||||||
|
|
||||||
|
// MARK: - 清理
|
||||||
|
/// 主动清理所有观察者,在 view onDisappear 时调用
|
||||||
|
/// 必须在 PlayerBridge 被释放之前调用
|
||||||
|
func cleanup() {
|
||||||
|
isTearingDown = true
|
||||||
|
player.pause()
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
// 移除全屏事件监控
|
||||||
|
if let monitor = fullscreenEventMonitor {
|
||||||
|
NSEvent.removeMonitor(monitor)
|
||||||
|
fullscreenEventMonitor = nil
|
||||||
|
}
|
||||||
|
// 关闭全屏窗口
|
||||||
|
if let fw = fullscreenWindow {
|
||||||
|
if let pv = fw.contentView as? AVPlayerView { pv.player = nil }
|
||||||
|
fw.close()
|
||||||
|
fullscreenWindow = nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 1. 先停 Timer
|
||||||
|
updateTimer?.invalidate()
|
||||||
|
updateTimer = nil
|
||||||
|
|
||||||
|
// 2. 移除 KVO 观察者(阻止后续回调)
|
||||||
|
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||||||
|
|
||||||
|
// 3. 移除通知观察者
|
||||||
|
if let token = endObserverToken {
|
||||||
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
endObserverToken = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// deinit 是 nonisolated,只做最小安全清理
|
||||||
|
// 大部分清理应在 cleanup() 中完成
|
||||||
|
updateTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Sources/Resources/en.lproj/Localizable.strings
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"play_pause" = "Play/Pause";
|
||||||
|
"prev" = "Previous";
|
||||||
|
"next" = "Next";
|
||||||
|
"fullscreen" = "Fullscreen";
|
||||||
|
"vol_up" = "Volume+";
|
||||||
|
"vol_down" = "Volume-";
|
||||||
|
"repeat_mode" = "Repeat Mode";
|
||||||
|
"open_file" = "Open File...";
|
||||||
|
"add_url" = "Add URL...";
|
||||||
|
"menu_playback" = "Playback";
|
||||||
|
"menu_file" = "File";
|
||||||
|
"playlist" = "Playlist";
|
||||||
|
"add_file" = "📂 Add File";
|
||||||
|
"add_url_btn" = "🔗 Add URL";
|
||||||
|
"items_count" = "items";
|
||||||
|
"no_media" = "No media";
|
||||||
|
"add_media_url" = "Add Media URL";
|
||||||
|
"cancel" = "Cancel";
|
||||||
|
"add" = "Add";
|
||||||
|
"select_track" = "Select Track";
|
||||||
|
"track" = "Track";
|
||||||
|
"audio_track" = "🎵 Audio Track";
|
||||||
|
"close" = "Close";
|
||||||
|
"repeat_none" = "No Repeat";
|
||||||
|
"repeat_single" = "Repeat One";
|
||||||
|
"repeat_all" = "Repeat All";
|
||||||
|
"now_playing" = "Now Playing: %@";
|
||||||
|
"repeat_mode_label" = "Repeat: %@";
|
||||||
|
"invalid_url" = "Invalid URL";
|
||||||
|
"open" = "Open";
|
||||||
|
"list" = "List";
|
||||||
31
Sources/Resources/zh-Hans.lproj/Localizable.strings
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"play_pause" = "播放/暂停";
|
||||||
|
"prev" = "上一首";
|
||||||
|
"next" = "下一首";
|
||||||
|
"fullscreen" = "全屏";
|
||||||
|
"vol_up" = "音量+";
|
||||||
|
"vol_down" = "音量-";
|
||||||
|
"repeat_mode" = "循环模式";
|
||||||
|
"open_file" = "打开文件...";
|
||||||
|
"add_url" = "添加URL...";
|
||||||
|
"menu_playback" = "播放";
|
||||||
|
"menu_file" = "文件";
|
||||||
|
"playlist" = "播放列表";
|
||||||
|
"add_file" = "📂 添加文件";
|
||||||
|
"add_url_btn" = "🔗 添加URL";
|
||||||
|
"items_count" = "项";
|
||||||
|
"no_media" = "暂无媒体";
|
||||||
|
"add_media_url" = "添加媒体URL";
|
||||||
|
"cancel" = "取消";
|
||||||
|
"add" = "添加";
|
||||||
|
"select_track" = "选择音轨";
|
||||||
|
"track" = "音轨";
|
||||||
|
"audio_track" = "🎵 音轨";
|
||||||
|
"close" = "关闭";
|
||||||
|
"repeat_none" = "不循环";
|
||||||
|
"repeat_single" = "单曲循环";
|
||||||
|
"repeat_all" = "列表循环";
|
||||||
|
"now_playing" = "正在播放: %@";
|
||||||
|
"repeat_mode_label" = "循环模式: %@";
|
||||||
|
"invalid_url" = "无效URL";
|
||||||
|
"open" = "打开";
|
||||||
|
"list" = "列表";
|
||||||
48
Sources/VideoPlayerView.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
// MARK: - 跨平台 AVPlayer 渲染(使用系统 AVPlayerView / AVPlayerViewController)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct VideoPlayerRepresentable: UIViewControllerRepresentable {
|
||||||
|
let player: AVPlayer
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
|
let vc = AVPlayerViewController()
|
||||||
|
vc.player = player
|
||||||
|
vc.showsPlaybackControls = false
|
||||||
|
vc.videoGravity = .resizeAspect
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
|
||||||
|
if vc.player !== player {
|
||||||
|
vc.player = player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct VideoPlayerRepresentable: NSViewRepresentable {
|
||||||
|
let player: AVPlayer
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> AVPlayerView {
|
||||||
|
let view = AVPlayerView()
|
||||||
|
view.player = player
|
||||||
|
view.controlsStyle = .none
|
||||||
|
view.videoGravity = .resizeAspect
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: AVPlayerView, context: Context) {
|
||||||
|
if nsView.player !== player {
|
||||||
|
nsView.player = player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
217
generate_icons.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate MiniPlayer app icons (tri-color play button + ring)"""
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
def create_icon(size):
|
||||||
|
"""Create icon at given size"""
|
||||||
|
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Background: dark rounded rect
|
||||||
|
margin = int(size * 0.05)
|
||||||
|
radius = int(size * 0.18)
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[margin, margin, size - margin, size - margin],
|
||||||
|
radius=radius,
|
||||||
|
fill=(28, 28, 30, 255)
|
||||||
|
)
|
||||||
|
|
||||||
|
cx, cy = size / 2, size / 2
|
||||||
|
ring_radius = size * 0.38
|
||||||
|
ring_width = size * 0.06
|
||||||
|
tri_size = size * 0.28
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED = (255, 59, 48)
|
||||||
|
GREEN = (52, 199, 89)
|
||||||
|
BLUE = (0, 122, 255)
|
||||||
|
|
||||||
|
# === Outer Ring: 3 arc segments ===
|
||||||
|
# Red: 150-270°, Green: 270-30°, Blue: 30-150°
|
||||||
|
# Draw as thick arcs
|
||||||
|
inner_r = ring_radius - ring_width / 2
|
||||||
|
outer_r = ring_radius + ring_width / 2
|
||||||
|
|
||||||
|
def draw_arc_segment(color, start_deg, end_deg):
|
||||||
|
"""Draw a thick arc segment"""
|
||||||
|
for angle in range(int(start_deg * 10), int(end_deg * 10)):
|
||||||
|
a = angle / 10.0
|
||||||
|
rad = math.radians(a)
|
||||||
|
ix = cx + inner_r * math.cos(rad)
|
||||||
|
iy = cy + inner_r * math.sin(rad)
|
||||||
|
ox = cx + outer_r * math.cos(rad)
|
||||||
|
oy = cy + outer_r * math.sin(rad)
|
||||||
|
draw.line([(ix, iy), (ox, oy)], fill=color, width=max(1, int(size * 0.005)))
|
||||||
|
|
||||||
|
# Ring segments (start from top, clockwise)
|
||||||
|
# Red: top-left to bottom-left (180° arc, offset)
|
||||||
|
draw_arc_segment(RED, -60, 60) # right side
|
||||||
|
draw_arc_segment(GREEN, 60, 180) # bottom
|
||||||
|
draw_arc_segment(BLUE, 180, 300) # left/top
|
||||||
|
|
||||||
|
# === Play Triangle: split into 3 colored sections from center ===
|
||||||
|
# Triangle pointing right: vertices relative to center
|
||||||
|
# Shift slightly right for visual centering of play button
|
||||||
|
offset_x = tri_size * 0.08
|
||||||
|
|
||||||
|
# Triangle vertices (pointing right)
|
||||||
|
top = (cx - tri_size * 0.4 + offset_x, cy - tri_size * 0.55)
|
||||||
|
bottom = (cx - tri_size * 0.4 + offset_x, cy + tri_size * 0.55)
|
||||||
|
right = (cx + tri_size * 0.55 + offset_x, cy)
|
||||||
|
|
||||||
|
# Center of triangle
|
||||||
|
tcx = (top[0] + bottom[0] + right[0]) / 3
|
||||||
|
tcy = (top[1] + bottom[1] + right[1]) / 3
|
||||||
|
|
||||||
|
# Draw 3 sections from center to each edge
|
||||||
|
# Section 1 (Red): center -> top -> right
|
||||||
|
draw.polygon([
|
||||||
|
(tcx, tcy),
|
||||||
|
top,
|
||||||
|
right
|
||||||
|
], fill=RED)
|
||||||
|
|
||||||
|
# Section 2 (Green): center -> right -> bottom
|
||||||
|
draw.polygon([
|
||||||
|
(tcx, tcy),
|
||||||
|
right,
|
||||||
|
bottom
|
||||||
|
], fill=GREEN)
|
||||||
|
|
||||||
|
# Section 3 (Blue): center -> bottom -> top
|
||||||
|
draw.polygon([
|
||||||
|
(tcx, tcy),
|
||||||
|
bottom,
|
||||||
|
top
|
||||||
|
], fill=BLUE)
|
||||||
|
|
||||||
|
# Thin white lines from center to vertices (separators)
|
||||||
|
line_w = max(1, int(size * 0.004))
|
||||||
|
draw.line([(tcx, tcy), top], fill=(255, 255, 255, 180), width=line_w)
|
||||||
|
draw.line([(tcx, tcy), bottom], fill=(255, 255, 255, 180), width=line_w)
|
||||||
|
draw.line([(tcx, tcy), right], fill=(255, 255, 255, 180), width=line_w)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def generate_macos_icons(output_dir):
|
||||||
|
"""Generate macOS .appiconset"""
|
||||||
|
appiconset = os.path.join(output_dir, "MiniPlayer.appiconset")
|
||||||
|
os.makedirs(appiconset, exist_ok=True)
|
||||||
|
|
||||||
|
sizes = {
|
||||||
|
"icon_16": 16,
|
||||||
|
"icon_16@2x": 32,
|
||||||
|
"icon_32": 32,
|
||||||
|
"icon_32@2x": 64,
|
||||||
|
"icon_128": 128,
|
||||||
|
"icon_128@2x": 256,
|
||||||
|
"icon_256": 256,
|
||||||
|
"icon_256@2x": 512,
|
||||||
|
"icon_512": 512,
|
||||||
|
"icon_512@2x": 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
images_json = []
|
||||||
|
for name, px in sizes.items():
|
||||||
|
fname = f"{name}.png"
|
||||||
|
icon = create_icon(px)
|
||||||
|
icon.save(os.path.join(appiconset, fname))
|
||||||
|
|
||||||
|
# Parse size and scale
|
||||||
|
if "@2x" in name:
|
||||||
|
base = name.replace("@2x", "")
|
||||||
|
scale = "2x"
|
||||||
|
sz = base.replace("icon_", "")
|
||||||
|
else:
|
||||||
|
scale = "1x"
|
||||||
|
sz = name.replace("icon_", "")
|
||||||
|
|
||||||
|
images_json.append({
|
||||||
|
"filename": fname,
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": scale,
|
||||||
|
"size": f"{sz}x{sz}"
|
||||||
|
})
|
||||||
|
print(f" {fname}: {px}x{px}")
|
||||||
|
|
||||||
|
# Write Contents.json
|
||||||
|
import json
|
||||||
|
contents = {
|
||||||
|
"images": images_json,
|
||||||
|
"info": {"author": "MiniPlayer", "version": 1}
|
||||||
|
}
|
||||||
|
with open(os.path.join(appiconset, "Contents.json"), "w") as f:
|
||||||
|
json.dump(contents, f, indent=2)
|
||||||
|
|
||||||
|
print(f" Contents.json written")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ios_icons(output_dir):
|
||||||
|
"""Generate iOS AppIcon set"""
|
||||||
|
appiconset = os.path.join(output_dir, "AppIcon.appiconset")
|
||||||
|
os.makedirs(appiconset, exist_ok=True)
|
||||||
|
|
||||||
|
# iOS icon sizes (points x scale = pixels)
|
||||||
|
ios_sizes = [
|
||||||
|
("20x20", "2x", 40),
|
||||||
|
("20x20", "3x", 60),
|
||||||
|
("29x29", "2x", 58),
|
||||||
|
("29x29", "3x", 87),
|
||||||
|
("40x40", "2x", 80),
|
||||||
|
("40x40", "3x", 120),
|
||||||
|
("60x60", "2x", 120),
|
||||||
|
("60x60", "3x", 180),
|
||||||
|
("76x76", "2x", 152),
|
||||||
|
("83.5x83.5", "2x", 167),
|
||||||
|
("1024x1024", "1x", 1024),
|
||||||
|
]
|
||||||
|
|
||||||
|
images_json = []
|
||||||
|
for sz, scale, px in ios_sizes:
|
||||||
|
fname = f"icon_{px}.png"
|
||||||
|
fpath = os.path.join(appiconset, fname)
|
||||||
|
if not os.path.exists(fpath):
|
||||||
|
icon = create_icon(px)
|
||||||
|
icon.save(fpath)
|
||||||
|
images_json.append({
|
||||||
|
"filename": fname,
|
||||||
|
"idiom": "ios-marketing" if px == 1024 else "iphone" if "x20" in sz or "x29" in sz or "x40" in sz or "x60" in sz else "ipad",
|
||||||
|
"scale": scale,
|
||||||
|
"size": sz
|
||||||
|
})
|
||||||
|
print(f" {fname}: {px}x{px}")
|
||||||
|
|
||||||
|
import json
|
||||||
|
contents = {
|
||||||
|
"images": images_json,
|
||||||
|
"info": {"author": "MiniPlayer", "version": 1}
|
||||||
|
}
|
||||||
|
with open(os.path.join(appiconset, "Contents.json"), "w") as f:
|
||||||
|
json.dump(contents, f, indent=2)
|
||||||
|
|
||||||
|
print(f" Contents.json written")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
base = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
print("=== macOS Icons ===")
|
||||||
|
macos_dir = os.path.join(base, "Sources", "Resources", "macOS")
|
||||||
|
generate_macos_icons(macos_dir)
|
||||||
|
|
||||||
|
print("\n=== iOS Icons ===")
|
||||||
|
ios_dir = os.path.join(base, "Sources", "Resources", "iOS")
|
||||||
|
generate_ios_icons(ios_dir)
|
||||||
|
|
||||||
|
# Also save a preview
|
||||||
|
preview = create_icon(512)
|
||||||
|
preview_path = os.path.join(base, "build", "icon_preview.png")
|
||||||
|
os.makedirs(os.path.dirname(preview_path), exist_ok=True)
|
||||||
|
preview.save(preview_path)
|
||||||
|
print(f"\nPreview: {preview_path}")
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
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
@ -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
|
||||||