MiniPlayer/Sources/MiniPlayerApp.swift

313 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}