MiniPlayer/Sources/MiniPlayerApp.swift
yumoqing dd64a21404 feat: QR code for current media with channel info, cross-platform
- Add QR generation of the currently playing URL (macOS window / iOS sheet)
- Encode channel metadata (name/type/group/logo) in the QR payload as
  compact JSON; fall back to a plain URL when the payload is too long
  for a reliably scannable QR
- Scanning saves the entry into the local library with its channel
  metadata and starts playback; legacy plain-URL QR codes still work
- Add iOS QR scanner sheet (AVCaptureMetadataOutput) so scanning is no
  longer macOS-only; both scanners share handleScannedCode
- Add optional channel name / group fields to the Add URL dialog and
  thread group/logo through addURL/addItem into the library
- Restore session (queue + position) on launch and save on quit for
  macOS, not just iOS
- Bundle app icons: generate AppIcon.icns from the macOS iconset when
  missing, and compile Assets.xcassets into the iOS app
2026-10-04 12:07:00 +08:00

1099 lines
41 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AVFoundation
import Combine
import SwiftBricks
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
import UniformTypeIdentifiers
#endif
#if os(macOS)
extension Notification.Name {
/// 应用即将退出时发送,用于在终止前保存播放状态
static let miniPlayerWillTerminate = Notification.Name("MiniPlayer.willTerminate")
}
// 主窗口关闭时退出应用
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 {
// 退出前保存播放状态,供下次启动恢复
NotificationCenter.default.post(name: .miniPlayerWillTerminate, object: nil)
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()
// 首次启动时恢复上次播放状态(macOS + iOS 均适用)
if !hasRestored {
hasRestored = true
// 延迟恢复,等 setup 和 KVO 就绪
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
bridge.restoreSessionState()
}
}
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background, .inactive:
// App 进后台/挂起:保存当前队列和播放位置
bridge.saveSessionState()
case .active:
break
@unknown default:
break
}
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: .miniPlayerWillTerminate)) { _ in
// macOS 退出前保存播放状态
bridge.saveSessionState()
}
#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)
// 录制错误覆盖层(显示在界面上不会一闪而过)
#if os(iOS)
if let errMsg = bridge.recorderErrorMessage {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 36))
.foregroundColor(.orange)
Text("Recording Error")
.font(.headline).foregroundColor(.white)
Text(errMsg)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button("Dismiss") {
bridge.recorderErrorMessage = nil
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.75))
}
#endif
// 左上角应用图标 — 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)
.sheet(isPresented: Binding<Bool>(
get: { bridge.qrDisplayURL != nil },
set: { if !$0 { bridge.qrDisplayURL = nil } }
)) {
if let urlStr = bridge.qrDisplayURL {
QRDisplayView(urlString: urlStr) { bridge.qrDisplayURL = nil }
}
}
.sheet(isPresented: $bridge.showQRScanner) {
QRScannerSheet(bridge: bridge) { bridge.showQRScanner = false }
}
#endif
#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() }
TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() }
TB(icon: "qrcode") { bridge.showCurrentQRCode(); 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)
// 音轨
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 = ""
@State private var name = ""
@State private var group = ""
@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)
TextField(L.channelNameField, text: $name)
.textFieldStyle(.roundedBorder)
TextField(L.channelGroupField, text: $group)
.textFieldStyle(.roundedBorder)
HStack {
Button(L.cancel) { dismiss() }
Spacer()
Button(L.add) {
if !url.isEmpty {
bridge.addURL(url,
name: name.isEmpty ? nil : name,
group: group.isEmpty ? nil : group)
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