NSLog returns Void which doesn't conform to View in SwiftUI ViewBuilder. Wrapped in inline closure to avoid compilation error.
1044 lines
39 KiB
Swift
1044 lines
39 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import Combine
|
||
import SwiftBricks
|
||
#if os(macOS)
|
||
import AppKit
|
||
#elseif os(iOS)
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
#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()
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@State private var hasRestored = false
|
||
|
||
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()
|
||
// iOS: 首次启动时恢复上次播放状态
|
||
#if os(iOS)
|
||
if !hasRestored {
|
||
hasRestored = true
|
||
// 延迟恢复,等 setup 和 KVO 就绪
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
bridge.restoreSessionState()
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.onChange(of: scenePhase) { _, newPhase in
|
||
#if os(iOS)
|
||
switch newPhase {
|
||
case .background, .inactive:
|
||
// App 进后台/挂起:保存当前队列和播放位置
|
||
bridge.saveSessionState()
|
||
case .active:
|
||
break
|
||
@unknown default:
|
||
break
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
#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
|
||
let onPlayerLayerReady: ((AVPlayerLayer) -> Void)?
|
||
|
||
init(player: AVPlayer, onPlayerLayerReady: ((AVPlayerLayer) -> Void)? = nil) {
|
||
self.player = player
|
||
self.onPlayerLayerReady = onPlayerLayerReady
|
||
}
|
||
|
||
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
|
||
let onPlayerLayerReady: ((AVPlayerLayer) -> Void)?
|
||
|
||
init(player: AVPlayer, onPlayerLayerReady: ((AVPlayerLayer) -> Void)? = nil) {
|
||
self.player = player
|
||
self.onPlayerLayerReady = onPlayerLayerReady
|
||
}
|
||
|
||
func makeUIView(context: Context) -> PlayerLayerHostViewiOS {
|
||
let view = PlayerLayerHostViewiOS()
|
||
view.playerLayer.player = player
|
||
view.playerLayer.videoGravity = .resizeAspect
|
||
// 通知外部 playerLayer 已就绪(用于 PiP 初始化)
|
||
DispatchQueue.main.async {
|
||
onPlayerLayerReady?(view.playerLayer)
|
||
}
|
||
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
|
||
#if os(iOS)
|
||
@State private var isLocked = false
|
||
@State private var lastInteraction = Date()
|
||
@State private var lockCheckTimer: Timer?
|
||
#endif
|
||
|
||
private func resetHideTimer() {
|
||
hideTimer?.invalidate()
|
||
guard showToolbar else { return }
|
||
hideTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in
|
||
withAnimation(.easeInOut(duration: 0.5)) {
|
||
showToolbar = false
|
||
}
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
private func recordInteraction() {
|
||
lastInteraction = Date()
|
||
if isLocked {
|
||
// 有交互时不解锁,等待用户点击图标解锁
|
||
// 仅重置交互时间推迟自动锁定
|
||
}
|
||
resetHideTimer()
|
||
}
|
||
#endif
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .topLeading) {
|
||
Color.black.ignoresSafeArea()
|
||
// 直接用 AVPlayerLayer 渲染视频(彻底绕过 AVPlayerView 内置控制栏)
|
||
PlayerLayerView(player: bridge.player) { playerLayer in
|
||
#if os(iOS)
|
||
// PlayerLayer 就绪后初始化 PiP 控制器
|
||
bridge.pipController.setup(with: playerLayer)
|
||
#endif
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
#if os(iOS)
|
||
.allowsHitTesting(!isLocked)
|
||
#endif
|
||
.onTapGesture(count: 2) {
|
||
#if os(iOS)
|
||
recordInteraction()
|
||
#endif
|
||
bridge.toggleFullscreen()
|
||
}
|
||
.onTapGesture {
|
||
#if os(iOS)
|
||
recordInteraction()
|
||
#endif
|
||
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)
|
||
|
||
// 左上角应用图标 — iOS 锁屏控制 / macOS 切换控制栏
|
||
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 {
|
||
#if os(iOS)
|
||
lastInteraction = Date()
|
||
if isLocked {
|
||
isLocked = false
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
showToolbar = true
|
||
}
|
||
resetHideTimer()
|
||
} else {
|
||
isLocked = true
|
||
showToolbar = false
|
||
}
|
||
#else
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
showToolbar.toggle()
|
||
}
|
||
resetHideTimer()
|
||
#endif
|
||
}
|
||
)
|
||
|
||
// 底部控制栏 — 半透明,30秒自动隐藏
|
||
VStack {
|
||
Spacer()
|
||
if showToolbar {
|
||
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) {
|
||
#if os(iOS)
|
||
recordInteraction()
|
||
#else
|
||
resetHideTimer()
|
||
#endif
|
||
}
|
||
.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))
|
||
#if os(iOS)
|
||
.allowsHitTesting(!isLocked)
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
.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
|
||
|
||
#if os(iOS)
|
||
// 锁屏自动检测定时器(每5秒检查,超过30秒无交互则锁定)
|
||
lockCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
||
DispatchQueue.main.async {
|
||
if !isLocked && Date().timeIntervalSince(lastInteraction) > 30 {
|
||
isLocked = true
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.onChange(of: showToolbar) { _, newValue in
|
||
if newValue {
|
||
resetHideTimer()
|
||
} else {
|
||
hideTimer?.invalidate()
|
||
}
|
||
}
|
||
.onDisappear {
|
||
hideTimer?.invalidate()
|
||
#if os(iOS)
|
||
lockCheckTimer?.invalidate()
|
||
lockCheckTimer = nil
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.showLibrarySheet) {
|
||
MediaLibraryView(library: bridge.library, bridge: bridge)
|
||
}
|
||
.sheet(isPresented: $bridge.showNetworkBrowser) {
|
||
NetworkFilePanel(bridge: bridge, isPresented: $bridge.showNetworkBrowser)
|
||
}
|
||
#if os(iOS)
|
||
.fileImporter(
|
||
isPresented: $bridge.showFileImporter,
|
||
allowedContentTypes: [.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, .wav, .mpeg4Audio, .plainText, .item],
|
||
allowsMultipleSelection: true
|
||
) { result in
|
||
switch result {
|
||
case .success(let urls):
|
||
bridge.handleFileImport(urls)
|
||
case .failure(let error):
|
||
bridge.showToastMsg(error.localizedDescription)
|
||
}
|
||
}
|
||
.sheet(isPresented: Binding<Bool>(
|
||
get: {
|
||
let has = bridge.pendingExportURL != nil
|
||
if has { NSLog("[MiniPlayer] Sheet binding: pendingExportURL=%@", bridge.pendingExportURL?.lastPathComponent ?? "nil") }
|
||
return has
|
||
},
|
||
set: { if !$0 { bridge.pendingExportURL = nil } }
|
||
)) {
|
||
if let url = bridge.pendingExportURL {
|
||
let _ = { NSLog("[MiniPlayer] Presenting ActivityShareSheet for: %@", url.lastPathComponent) }()
|
||
ActivityShareSheet(url: url) {
|
||
bridge.pendingExportURL = nil
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
|
||
// 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()
|
||
}
|
||
}
|
||
#elseif os(iOS)
|
||
if bridge.hlsRecorder.isRecording {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "stop.circle.fill")
|
||
.foregroundColor(.red)
|
||
.font(.system(size: 18))
|
||
Text(bridge.hlsRecorder.durationText)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.red)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { bridge.toggleRecording(); onTouch() }
|
||
} else {
|
||
TB(icon: "record.circle") {
|
||
bridge.toggleRecording(); onTouch()
|
||
}
|
||
}
|
||
#endif
|
||
|
||
Divider().frame(height: 20).padding(.horizontal, 4)
|
||
|
||
TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
|
||
TB(icon: "network") { bridge.toggleNetworkBrowser(); 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)
|
||
|
||
// 音轨
|
||
if bridge.availableTracks.count <= 1 {
|
||
Button(action: {}) {
|
||
Text("🎵")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.gray.opacity(0.4))
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(true)
|
||
} else {
|
||
Button(action: { bridge.cycleTrack(); onTouch() }) {
|
||
Text(bridge.currentTrackLabel)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.white)
|
||
.frame(height: 28)
|
||
.padding(.horizontal, 4)
|
||
.background(Color.white.opacity(0.15))
|
||
.cornerRadius(4)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
// 循环
|
||
TB(icon: repeatIcon()) { bridge.cycleRepeatMode(); onTouch() }
|
||
|
||
// 全屏
|
||
TB(icon: "arrow.up.left.and.arrow.down.right") { bridge.toggleFullscreen(); onTouch() }
|
||
|
||
#if os(iOS)
|
||
// 画中画(小屏播放)
|
||
if bridge.pipController.isPiPSupported {
|
||
TB(icon: bridge.pipController.isPiPActive ? "pip.exit" : "pip.enter") {
|
||
bridge.pipController.togglePiP()
|
||
onTouch()
|
||
}
|
||
}
|
||
|
||
// 投屏(AirPlay)
|
||
AirPlayButton()
|
||
.frame(width: 28, height: 28)
|
||
#endif
|
||
|
||
// 播放列表
|
||
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))
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
// MARK: - URL Identifiable 扩展(用于 .sheet(item:))
|
||
extension URL: @retroactive Identifiable {
|
||
public var id: String { absoluteString }
|
||
}
|
||
|
||
// MARK: - 系统分享面板(UIViewControllerRepresentable 包装)
|
||
struct ActivityShareSheet: UIViewControllerRepresentable {
|
||
let url: URL
|
||
let onDismiss: () -> Void
|
||
|
||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||
let vc = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||
vc.completionWithItemsHandler = { _, _, _, _ in
|
||
onDismiss()
|
||
}
|
||
return vc
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||
}
|
||
#endif
|