import SwiftUI import AVFoundation import Combine import SwiftBricks #if os(macOS) import AppKit #elseif os(iOS) import UIKit #endif #if os(macOS) // 主窗口关闭时退出应用 class AppDel: NSObject, NSApplicationDelegate { private var closeObserver: Any? func applicationDidFinishLaunching(_ notification: Notification) { // 监听所有窗口关闭事件 closeObserver = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: nil, queue: .main ) { _ in // 延迟检查:等当前关闭事件完成后再判断 DispatchQueue.main.async { let nonPanelWindows = NSApp.windows.filter { !($0 is NSPanel) && $0.isVisible } NSLog("[MiniPlayer] Window closed. Visible non-panel windows: %d", nonPanelWindows.count) if nonPanelWindows.isEmpty { NSLog("[MiniPlayer] No visible main windows, terminating app") NSApp.terminate(nil) } } } } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { if RecorderState.isFinalizing { NSLog("[MiniPlayer] ⏳ Termination blocked: recording is finalizing") RecorderState.pendingTerminate = true // 显示提示 let alert = NSAlert() alert.messageText = "正在保存录制文件..." alert.informativeText = "请等待录制保存完成后再关闭应用。" alert.alertStyle = .informational alert.addButton(withTitle: "确定") alert.runModal() return .terminateLater } return .terminateNow } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } deinit { if let obs = closeObserver { NotificationCenter.default.removeObserver(obs) } } } #endif #if !SWIFT_PACKAGE @main #endif public struct MiniPlayerApp: App { #if os(macOS) @NSApplicationDelegateAdaptor(AppDel.self) var appDelegate #endif @StateObject private var bridge = PlayerBridge() @StateObject private var engine = BricksEngine() public init() {} public var body: some Scene { WindowGroup { BricksAppView(bridge: bridge, engine: engine) #if os(macOS) .frame(minWidth: 900, minHeight: 600) #endif .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) Divider() Button(L.mediaLibrary) { bridge.toggleLibraryWindow() } .keyboardShortcut("l", modifiers: [.command, .shift]) Button(L.importM3U) { bridge.toggleLibraryWindow() } .keyboardShortcut("i", modifiers: [.command, .shift]) } } #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 os(macOS) let bundle: Bundle = { if let mainResources = Bundle.main.url(forResource: "i18n", withExtension: nil) { return Bundle.main } #if SWIFT_PACKAGE return Bundle.module #else return Bundle.main #endif }() #else let bundle = Bundle.main #endif if let url = bundle.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 直接渲染(无内置控制栏) #if os(macOS) 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 } } #endif #if os(iOS) struct PlayerLayerView: UIViewRepresentable { let player: AVPlayer func makeUIView(context: Context) -> PlayerLayerHostViewiOS { let view = PlayerLayerHostViewiOS() view.playerLayer.player = player view.playerLayer.videoGravity = .resizeAspect return view } func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) { uiView.playerLayer.player = player } } class PlayerLayerHostViewiOS: UIView { let playerLayer = AVPlayerLayer() override init(frame: CGRect) { super.init(frame: frame) layer.addSublayer(playerLayer) } required init?(coder: NSCoder) { fatalError() } override func layoutSubviews() { super.layoutSubviews() playerLayer.frame = bounds } } #endif // MARK: - 窗口级鼠标监控(替代 .onHover 和 background tracking area) #if os(macOS) class WindowMouseMonitor { static let shared = WindowMouseMonitor() private var monitor: Any? private var lastState: Bool? func startMonitoring() { guard monitor == nil else { return } monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { [weak self] event in self?.handleMouseMoved(event) return event } } private func handleMouseMoved(_ event: NSEvent) { guard let win = NSApplication.shared.windows.first(where: { $0.isVisible && !($0 is NSPanel) }) else { return } let mouseScreenPos = NSEvent.mouseLocation let isInside = win.frame.contains(mouseScreenPos) guard isInside != lastState else { return } lastState = isInside NSLog("[MiniPlayer] WindowMouseMonitor: mouseInside=%d", isInside) // 防抖:状态没变就不动 let currentlyVisible = win.titleVisibility == .visible guard currentlyVisible != isInside else { return } win.titlebarAppearsTransparent = !isInside win.titleVisibility = isInside ? .visible : .hidden win.standardWindowButton(.closeButton)?.isHidden = !isInside win.standardWindowButton(.miniaturizeButton)?.isHidden = !isInside win.standardWindowButton(.zoomButton)?.isHidden = !isInside } } #endif // 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() } #if os(macOS) .onDrop(of: [.fileURL, .text], isTargeted: nil) { providers in for provider in providers { // 尝试作为文件 URL provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { data, _ in guard let data = data as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } DispatchQueue.main.async { bridge.addItem(url: url, name: url.lastPathComponent, type: "file") } } // 尝试作为文本 URL (http/https) provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in guard let data = data as? Data, let text = String(data: data, encoding: .utf8), let url = URL(string: text), url.scheme == "http" || url.scheme == "https" else { return } DispatchQueue.main.async { let name = url.lastPathComponent.isEmpty ? (url.host ?? text) : url.lastPathComponent bridge.addItem(url: url, name: name, type: "url") } } } return true } #endif // 播放状态指示(loading/buffering/error) PlayerStatusOverlay(bridge: bridge) // 左上角应用图标 — 点击切换控制栏 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)) } } } .onAppear { #if os(macOS) // 初始状态:标题栏完全隐藏(含红绿灯按钮) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { guard let win = NSApplication.shared.windows.first(where: { $0.isVisible }) else { return } win.titleVisibility = .hidden win.titlebarAppearsTransparent = true win.isMovableByWindowBackground = true win.standardWindowButton(.closeButton)?.isHidden = true win.standardWindowButton(.miniaturizeButton)?.isHidden = true win.standardWindowButton(.zoomButton)?.isHidden = true // 启用 mouseMoved 事件(默认不发送) win.acceptsMouseMovedEvents = true // 用全局鼠标事件监听器替代 .onHover WindowMouseMonitor.shared.startMonitoring() } #endif } .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) } } } // 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: 10) { #if os(macOS) // 录制(放最前面,更显眼) if bridge.screenRecorder.isRecording { HStack(spacing: 4) { Image(systemName: "stop.circle.fill") .foregroundColor(.red) .font(.system(size: 18)) Text(bridge.screenRecorder.durationText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.red) } .contentShape(Rectangle()) .onTapGesture { bridge.toggleRecording(); onTouch() } } else { TB(icon: "record.circle") { bridge.toggleRecording(); onTouch() } } Divider().frame(height: 20).padding(.horizontal, 4) #endif TB(icon: "folder") { bridge.openFileDialog(); onTouch() } TB(icon: "link") { bridge.showURLDialog = true; onTouch() } #if os(macOS) TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() } #endif 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() } // 媒体库 TB(icon: "books.vertical") { bridge.toggleLibraryWindow(); 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) } } // MARK: - 播放状态覆盖层 struct PlayerStatusOverlay: View { @ObservedObject var bridge: PlayerBridge var body: some View { ZStack { switch bridge.playerStatus { case .loading: loadingView case .buffering: bufferingView case .error(let msg): errorView(msg) default: EmptyView() } } .allowsHitTesting(isError) .animation(.easeInOut(duration: 0.3), value: bridge.playerStatus) } private var isError: Bool { if case .error = bridge.playerStatus { return true } return false } // 加载中:居中大 spinner + 文字 + 已用时间 private var loadingView: some View { VStack(spacing: 16) { ProgressView() .progressViewStyle(.circular) .scaleEffect(1.5) .frame(width: 48, height: 48) Text(L.statusConnecting) .font(.system(size: 16, weight: .medium)) .foregroundColor(.white) if bridge.loadingElapsed > 0 { Text("\(bridge.loadingElapsed)s") .font(.system(size: 13, design: .monospaced)) .foregroundColor(.white.opacity(0.6)) } if bridge.loadingElapsed > 15 { Text(L.statusTimeout) .font(.system(size: 12)) .foregroundColor(.yellow.opacity(0.8)) .multilineTextAlignment(.center) .padding(.horizontal, 40) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.4)) } // 缓冲中:右上角小指示器 private var bufferingView: some View { VStack { HStack { Spacer() HStack(spacing: 6) { ProgressView() .progressViewStyle(.circular) .scaleEffect(0.7) Text(L.statusBuffering) .font(.system(size: 12)) .foregroundColor(.white.opacity(0.9)) } .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule().fill(Color.black.opacity(0.6)) ) .padding(16) } Spacer() } } // 错误:居中显示错误信息 + 重试按钮 private func errorView(_ msg: String) -> some View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.red.opacity(0.8)) Text(L.statusError) .font(.system(size: 18, weight: .semibold)) .foregroundColor(.white) Text(msg) .font(.system(size: 13)) .foregroundColor(.white.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 60) .lineLimit(3) Button(action: { if bridge.currentIndex >= 0 { bridge.playIndex(bridge.currentIndex) } }) { HStack(spacing: 6) { Image(systemName: "arrow.clockwise") Text(L.statusRetry) } .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 8) .background( Capsule().fill(Color.accentColor.opacity(0.8)) ) } .buttonStyle(.plain) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.6)) } }