1037 lines
38 KiB
Swift
1037 lines
38 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)
|
||
.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
|
||
} else {
|
||
isLocked = true
|
||
}
|
||
#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))
|
||
}
|
||
}
|
||
|
||
// iOS 锁屏遮罩层(拦截触摸,仅左上角图标可点击解锁)
|
||
#if os(iOS)
|
||
if isLocked {
|
||
Color.black.opacity(0.01)
|
||
.ignoresSafeArea()
|
||
.allowsHitTesting(true)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
// 吞掉所有触摸
|
||
}
|
||
}
|
||
#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)
|
||
}
|
||
#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: { bridge.hlsRecorder.pendingExportURL != nil },
|
||
set: { if !$0 { bridge.hlsRecorder.pendingExportURL = nil } }
|
||
)) {
|
||
if let url = bridge.hlsRecorder.pendingExportURL {
|
||
ActivityShareSheet(url: url) {
|
||
bridge.hlsRecorder.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: "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
|