485 lines
18 KiB
Swift
485 lines
18 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import Combine
|
||
import SwiftBricks
|
||
|
||
@main
|
||
struct MiniPlayerApp: App {
|
||
@StateObject private var bridge = PlayerBridge()
|
||
@StateObject private var engine = BricksEngine()
|
||
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
BricksAppView(bridge: bridge, engine: engine)
|
||
.frame(minWidth: 900, minHeight: 600)
|
||
.onAppear {
|
||
bridge.setup()
|
||
setupBridgeEvents()
|
||
loadI18n()
|
||
}
|
||
}
|
||
#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
|
||
}
|
||
|
||
// 桥接 BricksEngine 事件到 PlayerBridge
|
||
private func setupBridgeEvents() {
|
||
// 将 PlayerBridge 的 AVPlayer 注入 Store,让 VideoPlayerControl 共享
|
||
engine.store.setValue(id: "videoPlayer.player", value: bridge.player)
|
||
|
||
// 播放控制 — 从 Store 读取 Input 值或事件数据
|
||
engine.eventBus.on("videoPlayer.setURL") { [weak bridge, weak engine] data in
|
||
guard let bridge, let engine else { return }
|
||
// 优先从事件数据取 URL,否则从 Input widget 的 store 值取
|
||
var url: String? = data["url"] as? String
|
||
if url == nil || url?.isEmpty == true {
|
||
url = engine.store.getValue(id: "videoUrlInput") as? String
|
||
}
|
||
if let url, !url.isEmpty {
|
||
bridge.addURL(url)
|
||
}
|
||
}
|
||
|
||
// seek — 直接操作共享 AVPlayer
|
||
engine.eventBus.on("videoPlayer.seek") { [weak bridge] data in
|
||
guard let bridge else { return }
|
||
if let timeStr = data["time"] as? String, let time = Double(timeStr) {
|
||
bridge.player.seek(to: CMTime(seconds: time, preferredTimescale: 600))
|
||
}
|
||
}
|
||
|
||
// 音量
|
||
engine.eventBus.on("videoPlayer.setVolume") { [weak bridge] data in
|
||
guard let bridge else { return }
|
||
if let volStr = data["volume"] as? String, let vol = Float(volStr) {
|
||
bridge.setVolume(vol)
|
||
}
|
||
}
|
||
|
||
// 全屏
|
||
engine.eventBus.on("videoPlayer.fullscreen") { [weak bridge] data in
|
||
guard let bridge else { return }
|
||
bridge.toggleFullscreen()
|
||
}
|
||
|
||
// 上一曲/下一曲
|
||
engine.eventBus.on("videoPlayer.prev") { [weak bridge] data in
|
||
guard let bridge else { return }
|
||
bridge.playPrev()
|
||
}
|
||
|
||
engine.eventBus.on("videoPlayer.next") { [weak bridge] data in
|
||
guard let bridge else { return }
|
||
bridge.playNext()
|
||
}
|
||
|
||
// 打开文件
|
||
engine.eventBus.on("videoPlayer.openFile") { [weak bridge] data in
|
||
guard let bridge else { return }
|
||
bridge.openFileDialog()
|
||
}
|
||
}
|
||
|
||
// 加载 i18n 翻译
|
||
private func loadI18n() {
|
||
// 检测系统语言
|
||
let locale = Locale.current.language.languageCode?.identifier ?? "en"
|
||
let langFile = (locale == "zh") ? "zh" : "en"
|
||
|
||
if let url = Bundle.module.url(forResource: "i18n/\(langFile)", withExtension: "json"),
|
||
let data = try? Data(contentsOf: url),
|
||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
|
||
engine.i18n.loadMessages(dict)
|
||
engine.i18n.locale = langFile
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - AVPlayerLayer 直接渲染(无内置控制栏)
|
||
struct PlayerLayerView: NSViewRepresentable {
|
||
let player: AVPlayer
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = PlayerLayerHostView()
|
||
view.playerLayer.player = player
|
||
view.playerLayer.videoGravity = .resizeAspect
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
if let host = nsView as? PlayerLayerHostView {
|
||
host.playerLayer.player = player
|
||
}
|
||
}
|
||
}
|
||
|
||
class PlayerLayerHostView: NSView {
|
||
let playerLayer = AVPlayerLayer()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
wantsLayer = true
|
||
layer = playerLayer
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
playerLayer.frame = bounds
|
||
}
|
||
}
|
||
|
||
// MARK: - 可复用的播放器内容视图(主窗口和全屏共用)
|
||
struct PlayerContentView: View {
|
||
@ObservedObject var bridge: PlayerBridge
|
||
@State private var showToolbar = false
|
||
@State private var isHoveringIcon = false
|
||
@State private var hideTimer: Timer?
|
||
@State private var isHoveringToolbar = false
|
||
|
||
private func resetHideTimer() {
|
||
hideTimer?.invalidate()
|
||
guard showToolbar else { return }
|
||
hideTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in
|
||
withAnimation(.easeInOut(duration: 0.5)) {
|
||
showToolbar = false
|
||
}
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .topLeading) {
|
||
Color.black.ignoresSafeArea()
|
||
// 直接用 AVPlayerLayer 渲染视频(彻底绕过 AVPlayerView 内置控制栏)
|
||
PlayerLayerView(player: bridge.player)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.onTapGesture(count: 2) {
|
||
bridge.toggleFullscreen()
|
||
}
|
||
.onTapGesture {
|
||
bridge.togglePlayPause()
|
||
}
|
||
|
||
// 左上角应用图标 — 点击切换控制栏
|
||
MiniPlayerIcon()
|
||
.frame(width: 32, height: 32)
|
||
.padding(12)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(Color.black.opacity(0.3))
|
||
)
|
||
.opacity(isHoveringIcon ? 1.0 : 0.6)
|
||
.onHover { isHoveringIcon = $0 }
|
||
.contentShape(Rectangle())
|
||
.highPriorityGesture(
|
||
TapGesture().onEnded {
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
showToolbar.toggle()
|
||
}
|
||
resetHideTimer()
|
||
}
|
||
)
|
||
|
||
// 底部控制栏 — 半透明,30秒自动隐藏
|
||
VStack {
|
||
Spacer()
|
||
if showToolbar {
|
||
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) {
|
||
resetHideTimer()
|
||
}
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(Color.black.opacity(0.6))
|
||
)
|
||
.padding(12)
|
||
.onHover { hovering in
|
||
isHoveringToolbar = hovering
|
||
if hovering { resetHideTimer() }
|
||
}
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: showToolbar) { _, newValue in
|
||
if newValue {
|
||
resetHideTimer()
|
||
} else {
|
||
hideTimer?.invalidate()
|
||
}
|
||
}
|
||
.onDisappear {
|
||
hideTimer?.invalidate()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - BricksView wrapper
|
||
struct BricksAppView: View {
|
||
@ObservedObject var bridge: PlayerBridge
|
||
@ObservedObject var engine: BricksEngine
|
||
|
||
var body: some View {
|
||
PlayerContentView(bridge: bridge)
|
||
.sheet(isPresented: $bridge.showURLDialog) {
|
||
URLInputDialog(bridge: bridge)
|
||
}
|
||
.sheet(isPresented: $bridge.showTrackDialog) {
|
||
TrackSelectDialog(bridge: bridge)
|
||
}
|
||
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
||
PlaylistWindowView(bridge: bridge)
|
||
}
|
||
.sheet(isPresented: $bridge.showQRScanner) {
|
||
QRScannerDialog(bridge: bridge)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// 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: 11, design: .monospaced))
|
||
.foregroundColor(.white)
|
||
.frame(width: 45, alignment: .trailing)
|
||
|
||
ProgressSlider(player: bridge.player, bridge: bridge)
|
||
.frame(height: 20)
|
||
|
||
Text(bridge.totalTimeText)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.white)
|
||
.frame(width: 45, alignment: .leading)
|
||
}
|
||
|
||
// 按钮行 — 紧凑布局
|
||
HStack(spacing: 4) {
|
||
TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
|
||
TB(icon: "link") { bridge.showURLDialog = true; onTouch() }
|
||
TB(icon: "qrcode.viewfinder") { bridge.showQRScanner = true; onTouch() }
|
||
TB(icon: "backward.fill") { bridge.playPrev(); onTouch() }
|
||
TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() }
|
||
TB(icon: "forward.fill") { bridge.playNext(); onTouch() }
|
||
|
||
Divider().frame(height: 20).padding(.horizontal, 4)
|
||
|
||
// 音量
|
||
TB(icon: volumeIcon()) { 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
|
||
bridge.setVolume(max(0, min(1, Float(value.location.x / geo.size.width))))
|
||
onTouch()
|
||
}
|
||
)
|
||
}
|
||
.frame(width: 60, height: 20)
|
||
Text("\(Int(bridge.volume * 100))")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.white.opacity(0.7))
|
||
.frame(width: 25)
|
||
|
||
Divider().frame(height: 20).padding(.horizontal, 4)
|
||
|
||
// 音轨
|
||
TB(icon: "music.note") { bridge.showTrackDialog = true; onTouch() }
|
||
|
||
// 循环
|
||
TB(icon: repeatIcon()) { bridge.cycleRepeatMode(); onTouch() }
|
||
|
||
// 全屏
|
||
TB(icon: "arrow.up.left.and.arrow.down.right") { bridge.toggleFullscreen(); onTouch() }
|
||
|
||
// 播放列表
|
||
TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() }
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
}
|
||
|
||
private func volumeIcon() -> String {
|
||
if bridge.volume <= 0 { return "speaker.slash.fill" }
|
||
if bridge.volume < 0.5 { return "speaker.wave.1.fill" }
|
||
return "speaker.wave.3.fill"
|
||
}
|
||
|
||
private func repeatIcon() -> String {
|
||
switch bridge.repeatMode {
|
||
case .none: return "repeat"
|
||
case .single: return "repeat.1"
|
||
case .all: return "repeat"
|
||
}
|
||
}
|
||
}
|
||
|
||
// 图标按钮
|
||
struct TB: View {
|
||
let icon: String
|
||
let action: () -> Void
|
||
@State private var hovering = false
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white)
|
||
.frame(width: 28, height: 28)
|
||
.background(hovering ? Color.white.opacity(0.2) : .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)
|
||
}
|
||
}
|