Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

9 changed files with 995 additions and 0 deletions

25
Package.swift Normal file
View File

@ -0,0 +1,25 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MiniPlayer",
platforms: [
.iOS(.v17),
.macOS(.v14)
],
products: [
.library(name: "MiniPlayer", targets: ["MiniPlayer"])
],
dependencies: [
.package(path: "../SwiftBricks")
],
targets: [
.executableTarget(
name: "MiniPlayer",
dependencies: ["SwiftBricks"],
resources: [
.copy("Resources")
]
)
]
)

View File

@ -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 更可控)

138
Sources/MiniPlayerApp.swift Normal file
View File

@ -0,0 +1,138 @@
import SwiftUI
import SwiftBricks
@main
struct MiniPlayerApp: App {
@StateObject private var bridge = PlayerBridge()
var body: some Scene {
WindowGroup {
ContentView(bridge: bridge)
.frame(minWidth: 640, minHeight: 480)
.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.cycleRepeatMode() }
.keyboardShortcut("r", modifiers: .command)
}
CommandMenu("文件") {
Button("打开文件...") { bridge.openFileDialog() }
.keyboardShortcut("o", modifiers: .command)
Divider()
Button("添加URL...") { bridge.showURLDialog = true }
.keyboardShortcut("u", modifiers: .command)
}
}
#endif
}
}
/// BricksJSON + widget
struct ContentView: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
Group {
if let engine = bridge.engine {
BricksView(schema: bridge.schema!, engine: engine)
.overlay(alignment: .top) {
if let error = bridge.toastMessage {
Text(error)
.padding(8)
.background(.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(6)
.padding(.top, 8)
.transition(.opacity)
}
}
.sheet(isPresented: $bridge.showURLDialog) {
URLInputDialog(bridge: bridge)
}
#if os(macOS)
.sheet(isPresented: $bridge.showTrackDialog) {
TrackSelectDialog(bridge: bridge)
}
#endif
} else {
ProgressView("初始化...")
}
}
}
}
/// 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)
}
}
///
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)
}
}

439
Sources/PlayerBridge.swift Normal file
View File

@ -0,0 +1,439 @@
import SwiftUI
import AVFoundation
import Combine
import SwiftBricks
///
struct MediaItem: Identifiable, Equatable {
let id: String
var url: URL
var name: String
var mediaType: String // video/audio/stream
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
}
}
}
/// PlayerBridge AVPlayerBricksEngine
///
@MainActor
final class PlayerBridge: ObservableObject {
// MARK: - Published
@Published var engine: BricksEngine?
@Published var schema: ControlSchema?
@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
// MARK: -
let player = AVPlayer()
var queue: [MediaItem] = []
var currentIndex: Int = -1
var repeatMode: RepeatMode = .all
private var timeObserver: Any?
private var endObserver: Any?
private var cancellables = Set<AnyCancellable>()
// MARK: -
func setup() {
let eng = BricksEngine()
// widget
eng.registerWidget(type: "VideoPlayer") { [weak self] schema, engine in
AnyView(VideoPlayerWidget(bridge: self!, schema: schema, engine: engine))
}
eng.registerWidget(type: "ProgressSlider") { [weak self] schema, engine in
AnyView(ProgressSliderWidget(bridge: self!, schema: schema, engine: engine))
}
engine = eng
// player.ui
loadPlayerUI(engine: eng)
//
registerEvents(engine: eng)
//
setupEndObserver()
//
setupTimeObserver()
}
// MARK: - UI
private func loadPlayerUI(engine: BricksEngine) {
// Bundleplayer.ui
let bundle = Bundle.module
if let url = bundle.url(forResource: "player", withExtension: "ui"),
let data = try? Data(contentsOf: url),
let json = String(data: data, encoding: .utf8) {
do {
try engine.loadJSON(json)
schema = engine.rootSchema
} catch {
showToast("JSON加载失败: \(error.localizedDescription)")
}
} else {
// Fallback: JSON
let fallback = """
{"id":"app","widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[
{"widgettype":"VideoPlayer","id":"video_player","options":{"width":"100%","bgcolor":"#000"}},
{"widgettype":"HBox","options":{"spacing":8,"alignItems":"center","padding":"8px"},
"subwidgets":[
{"widgettype":"Button","id":"btn_prev","options":{"label":"","css":"text"},
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.prev"}]},
{"widgettype":"Button","id":"btn_play","options":{"label":"▶️","css":"text"},
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.toggle"}]},
{"widgettype":"Button","id":"btn_next","options":{"label":"","css":"text"},
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.next"}]},
{"widgettype":"Filler"},
{"widgettype":"Button","id":"btn_repeat","options":{"label":"🔁","css":"text"},
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.cycle_repeat"}]},
{"widgettype":"Button","id":"btn_fullscreen","options":{"label":"","css":"text"},
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.fullscreen"}]}
]},
{"widgettype":"Text","id":"playlist_info","options":{"text":"播放列表: 空"}}
]}
"""
do {
try engine.loadJSON(fallback)
schema = engine.rootSchema
} catch {
showToast("Fallback JSON失败: \(error)")
}
}
}
// MARK: -
private func registerEvents(engine: BricksEngine) {
let bus = engine.eventBus
bus.on("player.toggle") { [weak self] _ in
self?.togglePlayPause()
}
bus.on("player.prev") { [weak self] _ in
self?.playPrev()
}
bus.on("player.next") { [weak self] _ in
self?.playNext()
}
bus.on("player.cycle_repeat") { [weak self] _ in
self?.cycleRepeatMode()
}
bus.on("player.fullscreen") { [weak self] _ in
self?.toggleFullscreen()
}
bus.on("player.show_tracks") { [weak self] _ in
self?.showTrackDialog = true
}
bus.on("player.add_url") { [weak self] data in
if let url = data["url"] as? String, !url.isEmpty {
self?.addURL(url)
}
}
bus.on("player.open_file") { [weak self] _ in
self?.openFileDialog()
}
bus.on("player.play_selected") { [weak self] data in
if let index = data["index"] as? Int {
self?.playIndex(index)
}
}
}
// MARK: -
func togglePlayPause() {
if player.timeControlStatus == .playing {
player.pause()
} else {
player.play()
}
}
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:
let idx = (currentIndex + 1) % queue.count
playIndex(idx)
}
}
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()
//
loadTrackInfo(playerItem)
// UI
updatePlayButton(isPlaying: true)
updatePlaylistHighlight()
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 {
updatePlayButton(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) {
guard let eng = engine, let item = player.currentItem else { return }
let current = time.seconds
let total = item.duration.seconds
if total.isFinite && total > 0 {
eng.store.setValue(id: "time_current", value: formatTime(current))
eng.store.setValue(id: "time_total", value: formatTime(total))
eng.store.setValue(id: "progress_slider", value: "\(current)/\(total)")
}
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
let m = Int(seconds) / 60
let s = Int(seconds) % 60
return String(format: "%02d:%02d", m, s)
}
// 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)" }
currentTrackIndex = 0
//
if let defaultOpt = group.defaultOption,
let idx = options.firstIndex(of: defaultOpt) {
currentTrackIndex = idx
}
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
updateTrackLabel()
}
}
private func updateTrackLabel() {
let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
engine?.store.setValue(id: "track_label", value: label)
}
// MARK: -
func cycleRepeatMode() {
repeatMode = repeatMode.next
let label = "\(repeatMode.icon) \(repeatMode.rawValue)"
engine?.store.setValue(id: "btn_repeat", value: label)
showToast("循环模式: \(repeatMode.rawValue)")
}
// MARK: -
func toggleFullscreen() {
isFullscreen.toggle()
#if os(macOS)
if let window = NSApp.keyWindow {
window.toggleFullScreen(nil)
}
#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")
}
}
#elseif os(iOS)
showURLDialog = true // iOSURL
#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) {
let item = MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type)
queue.append(item)
updatePlaylist()
//
if queue.count == 1 {
playIndex(0)
}
}
// MARK: - UI
private func updatePlaylist() {
guard let eng = engine else { return }
// playlist_panel
if queue.isEmpty {
eng.store.setValue(id: "playlist_empty", value: "暂无媒体请添加文件或URL")
} else {
let list = queue.enumerated().map { idx, item in
"\(idx + 1). \(item.name) [\(item.mediaType)]"
}.joined(separator: "\n")
eng.store.setValue(id: "playlist_empty", value: list)
}
eng.store.setValue(id: "playlist_info", value: "播放列表: \(queue.count)")
}
private func updatePlayButton(isPlaying: Bool) {
let label = isPlaying ? "" : "▶️"
engine?.store.setValue(id: "btn_play", value: label)
}
private func updatePlaylistHighlight() {
//
}
// MARK: - Toast
private func showToast(_ message: String) {
toastMessage = message
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self.toastMessage == message {
self.toastMessage = nil
}
}
}
deinit {
if let observer = timeObserver {
player.removeTimeObserver(observer)
}
}
}

View File

@ -0,0 +1,72 @@
import SwiftUI
import AVFoundation
import SwiftBricks
/// ProgressSliderWidget +seek
struct ProgressSliderWidget: View {
let bridge: PlayerBridge
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var progress: Double = 0
@State private var duration: Double = 0
@State private var isDragging: Bool = 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 * progressRatio, height: 4)
//
Circle()
.fill(Color.accentColor)
.frame(width: 14, height: 14)
.offset(x: geo.size.width * 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 duration > 0 {
let seekTime = ratio * duration
bridge.player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
}
}
.onEnded { _ in
isDragging = false
}
)
}
.frame(height: 20)
.onReceive(engine.store.$values) { values in
if let val = values["progress_slider"] as? String {
let parts = val.split(separator: "/")
if parts.count == 2,
let cur = Double(parts[0]),
let total = Double(parts[1]) {
if !isDragging {
progress = cur
duration = total
}
}
}
}
}
private var progressRatio: Double {
guard duration > 0 else { return 0 }
return max(0, min(1, progress / duration))
}
}

109
Sources/Resources/player.ui Normal file
View File

@ -0,0 +1,109 @@
{
"id": "app",
"widgettype": "VBox",
"options": { "width": "100%", "height": "100%", "spacing": 0, "padding": "0" },
"subwidgets": [
{
"widgettype": "VideoPlayer",
"id": "video_player",
"options": { "width": "100%", "bgcolor": "#000000" },
"binds": [
{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.toggle" }
]
},
{
"widgettype": "VBox",
"options": { "width": "100%", "css": "card", "padding": "8px", "spacing": 4 },
"subwidgets": [
{
"widgettype": "HBox",
"options": { "width": "100%", "spacing": 6, "alignItems": "center" },
"subwidgets": [
{ "widgettype": "Text", "id": "time_current", "options": { "text": "00:00", "i18n": false } },
{ "widgettype": "ProgressSlider", "id": "progress_slider", "options": { "width": "100%" } },
{ "widgettype": "Text", "id": "time_total", "options": { "text": "00:00", "i18n": false } }
]
},
{
"widgettype": "HBox",
"options": { "width": "100%", "spacing": 4, "alignItems": "center" },
"subwidgets": [
{
"widgettype": "Button", "id": "btn_prev",
"options": { "label": "⏮", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.prev" }]
},
{
"widgettype": "Button", "id": "btn_play",
"options": { "label": "▶️", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.toggle" }]
},
{
"widgettype": "Button", "id": "btn_next",
"options": { "label": "⏭", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.next" }]
},
{ "widgettype": "Filler" },
{
"widgettype": "Text", "id": "track_label",
"options": { "text": "🎵 Track 1", "i18n": false }
},
{
"widgettype": "Button", "id": "btn_track",
"options": { "label": "音轨", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.show_tracks" }]
},
{
"widgettype": "Button", "id": "btn_repeat",
"options": { "label": "🔁 列表循环", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.cycle_repeat" }]
},
{
"widgettype": "Button", "id": "btn_fullscreen",
"options": { "label": "⛶", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.fullscreen" }]
}
]
}
]
},
{
"widgettype": "HBox",
"options": { "width": "100%", "padding": "4px", "spacing": 6, "alignItems": "center" },
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "add_form",
"options": {
"show_label": false,
"submit_label": "添加URL",
"fields": [
{ "name": "url", "placeholder": "M3U8 / 视频URL", "uitype": "str", "cwidth": 30 }
]
},
"binds": [{ "wid": "self", "event": "submit", "actiontype": "event", "target": "player.add_url" }]
},
{
"widgettype": "Button", "id": "btn_open_file",
"options": { "label": "📂 打开文件", "css": "text" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.open_file" }]
}
]
},
{
"widgettype": "VScrollPanel",
"id": "playlist_panel",
"options": { "width": "100%", "css": "filler" },
"subwidgets": [
{
"widgettype": "Title5",
"options": { "text": "播放列表", "i18n": true }
},
{
"widgettype": "Text", "id": "playlist_empty",
"options": { "text": "暂无媒体请添加文件或URL", "i18n": true, "color": "#888888" }
}
]
}
]
}

View File

@ -0,0 +1,97 @@
import SwiftUI
import AVFoundation
import SwiftBricks
/// VideoPlayerWidget BricksEngine"VideoPlayer"
struct VideoPlayerWidget: View {
let bridge: PlayerBridge
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
VideoPlayerRepresentable(player: bridge.player)
.background(Color.black)
.frame(maxWidth: .infinity)
.aspectRatio(16/9, contentMode: .fit)
.onTapGesture(count: 2) {
bridge.toggleFullscreen()
}
}
}
// 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
View 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
View 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