313 lines
11 KiB
Swift
313 lines
11 KiB
Swift
import SwiftUI
|
||
import SwiftBricks
|
||
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.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: - 主内容视图
|
||
struct ContentView: View {
|
||
@ObservedObject var bridge: PlayerBridge
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// 视频背景
|
||
if let engine = bridge.engine, let schema = bridge.schema {
|
||
BricksView(schema: schema, engine: engine)
|
||
} else {
|
||
Color.black
|
||
}
|
||
|
||
// 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(半透明)
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
.sheet(isPresented: $bridge.showURLDialog) {
|
||
URLInputDialog(bridge: bridge)
|
||
}
|
||
#if os(macOS)
|
||
.sheet(isPresented: $bridge.showTrackDialog) {
|
||
TrackSelectDialog(bridge: bridge)
|
||
}
|
||
#endif
|
||
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
|
||
}
|
||
}
|
||
|
||
// 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: 44, alignment: .trailing)
|
||
|
||
ProgressSliderWidget(bridge: bridge, schema: dummySchema, engine: bridge.engine!)
|
||
.frame(height: 20)
|
||
|
||
Text(bridge.totalTimeText)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.white)
|
||
.frame(width: 44, alignment: .leading)
|
||
}
|
||
|
||
// 按钮行
|
||
HStack(spacing: 10) {
|
||
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
|
||
ToolbarButton(label: "⏮") { bridge.playPrev() }
|
||
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() }
|
||
ToolbarButton(label: "⏭") { bridge.playNext() }
|
||
|
||
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))
|
||
}
|
||
|
||
private var dummySchema: ControlSchema {
|
||
ControlSchema(id: "progress_slider", widgettype: "ProgressSlider", options: ControlOptions(), binds: nil, subwidgets: nil)
|
||
}
|
||
}
|
||
|
||
// 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("暂无媒体,请添加文件或URL")
|
||
.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) : Color.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)
|
||
}
|
||
}
|