MiniPlayer/Sources/MiniPlayerApp.swift
yumoqing 6811572b7e fix: rewrite UI as pure SwiftUI, fix crash/fullscreen/height issues
- Skip BricksView, render video directly in SwiftUI (fixes 1/3 height)
- Fullscreen uses plain NSView+AVPlayerLayer (fixes objc_release crash)
- Remove NSApp.hide(nil) (fixes fullscreen not showing)
- Add volume +/- buttons and volume slider indicator
- Add iOS/iPadOS support with #if os guards
- ProgressSlider decoupled from BricksEngine
- PlayerBridge no longer depends on player.ui JSON
2026-06-22 00:04:06 +08:00

332 lines
12 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 AVFoundation
@main
struct MiniPlayerApp: App {
@StateObject private var bridge = PlayerBridge()
var body: some Scene {
WindowGroup {
ContentView(bridge: bridge)
.frame(minWidth: 900, minHeight: 600)
.onAppear { bridge.setup() }
}
#if os(macOS)
.commands {
CommandGroup(replacing: .newItem) {}
CommandMenu("播放") {
Button("播放/暂停") { bridge.togglePlayPause() }
.keyboardShortcut(" ", modifiers: [])
Divider()
Button("上一首") { bridge.playPrev() }
.keyboardShortcut("[", modifiers: [])
Button("下一首") { bridge.playNext() }
.keyboardShortcut("]", modifiers: [])
Divider()
Button("全屏") { bridge.toggleFullscreen() }
.keyboardShortcut("f", modifiers: .command)
Divider()
Button("音量+") { bridge.adjustVolume(by: 0.1) }
.keyboardShortcut("=", modifiers: .command)
Button("音量-") { bridge.adjustVolume(by: -0.1) }
.keyboardShortcut("-", modifiers: .command)
Divider()
Button("循环模式") { bridge.cycleRepeatMode() }
.keyboardShortcut("r", modifiers: .command)
}
CommandMenu("文件") {
Button("打开文件...") { bridge.openFileDialog() }
.keyboardShortcut("o", modifiers: .command)
Divider()
Button("添加URL...") { bridge.showURLDialog = true }
.keyboardShortcut("u", modifiers: .command)
}
}
#endif
}
}
// MARK: - SwiftUIBricksView
struct ContentView: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
ZStack {
//
VideoPlayerRepresentable(player: bridge.player)
.background(Color.black)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
// / +
Color.clear
.contentShape(Rectangle())
.onTapGesture { bridge.togglePlayPause() }
#if os(macOS)
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
#endif
.ignoresSafeArea()
// Logo
VStack {
HStack {
Button(action: { bridge.showToolbar.toggle() }) {
Text("🎬")
.font(.system(size: 28))
.padding(8)
.background(.black.opacity(0.4))
.cornerRadius(10)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.leading, 16)
.padding(.top, 12)
Spacer()
}
// Toolbarlogo
if bridge.showToolbar {
VStack {
Spacer()
ControlToolbar(bridge: bridge)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
// Toast
if let msg = bridge.toastMessage {
VStack {
Spacer()
Text(msg)
.padding(8)
.background(.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(6)
.padding(.bottom, 100)
}
}
}
.sheet(isPresented: $bridge.showURLDialog) {
URLInputDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showTrackDialog) {
TrackSelectDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge)
}
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
.onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
bridge.toggleFullscreen()
}
}
}
// MARK: -
struct ControlToolbar: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
VStack(spacing: 6) {
//
HStack(spacing: 8) {
Text(bridge.currentTimeText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white)
.frame(width: 50, alignment: .trailing)
ProgressSlider(player: bridge.player, bridge: bridge)
.frame(height: 20)
Text(bridge.totalTimeText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white)
.frame(width: 50, alignment: .leading)
}
//
HStack(spacing: 10) {
#if os(macOS)
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
#endif
ToolbarButton(label: "") { bridge.playPrev() }
ToolbarButton(label: bridge.isPlaying ? "" : "▶️") { bridge.togglePlayPause() }
ToolbarButton(label: "") { bridge.playNext() }
Spacer()
//
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1) }
//
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.white.opacity(0.2))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: geo.size.width * CGFloat(bridge.volume), height: 4)
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let ratio = max(0, min(1, value.location.x / geo.size.width))
bridge.setVolume(Float(ratio))
}
)
}
.frame(width: 80, height: 20)
Text("\(Int(bridge.volume * 100))")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 30)
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1) }
Spacer()
Text(bridge.currentTrackLabel)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.7))
ToolbarButton(label: "🎵 音轨") { bridge.showTrackDialog = true }
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.rawValue)") { bridge.cycleRepeatMode() }
ToolbarButton(label: "") { bridge.toggleFullscreen() }
ToolbarButton(label: "📋 列表 (\(bridge.queue.count))") { bridge.togglePlaylistWindow() }
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.black.opacity(0.55))
}
}
// MARK: -
struct ToolbarButton: View {
let label: String
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 13))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(hovering ? .white.opacity(0.15) : .clear)
.cornerRadius(4)
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
}
}
// MARK: -
struct PlaylistWindowView: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
VStack(spacing: 0) {
HStack {
Button("📂 添加文件") { bridge.openFileDialog() }
Button("🔗 添加URL") { bridge.showURLDialog = true }
Spacer()
Text("\(bridge.queue.count)")
.font(.caption).foregroundColor(.secondary)
}
.padding(12)
.background(.regularMaterial)
Divider()
if bridge.queue.isEmpty {
VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() }
} else {
ScrollView {
LazyVStack(spacing: 2) {
ForEach(Array(bridge.queue.enumerated()), id: \.element.id) { idx, item in
HStack {
if idx == bridge.currentIndex {
Text("").foregroundColor(.accentColor).font(.caption)
}
VStack(alignment: .leading, spacing: 2) {
Text(item.name).font(.system(size: 13)).lineLimit(1)
Text(item.mediaType).font(.caption).foregroundColor(.secondary)
}
Spacer()
Button(role: .destructive) { bridge.removeItem(at: idx) } label: {
Image(systemName: "trash").font(.caption)
}.buttonStyle(.plain)
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : .clear)
.contentShape(Rectangle())
.onTapGesture { bridge.playIndex(idx) }
}
}
}
}
}
.frame(minWidth: 350, minHeight: 400)
}
}
// MARK: - URL
struct URLInputDialog: View {
@ObservedObject var bridge: PlayerBridge
@State private var url = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("添加媒体URL").font(.headline)
TextField("https://example.com/video.m3u8", text: $url)
.textFieldStyle(.roundedBorder)
HStack {
Button("取消") { dismiss() }
Spacer()
Button("添加") {
if !url.isEmpty { bridge.addURL(url); dismiss() }
}
.keyboardShortcut(.defaultAction)
.disabled(url.isEmpty)
}
}
.padding(20)
.frame(width: 400)
}
}
// MARK: -
struct TrackSelectDialog: View {
@ObservedObject var bridge: PlayerBridge
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 12) {
Text("选择音轨").font(.headline)
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
HStack {
Text("Track \(idx + 1)")
Spacer()
if idx == bridge.currentTrackIndex {
Text("").foregroundColor(.green)
}
}
.padding(8).contentShape(Rectangle())
}.buttonStyle(.plain)
}
Divider()
Button("关闭") { dismiss() }
}
.padding(20)
.frame(minWidth: 250)
}
}