import SwiftUI import AVFoundation import Combine @main struct MiniPlayerApp: App { @StateObject private var bridge = PlayerBridge() var body: some Scene { WindowGroup { ContentView(bridge: bridge) .frame(minWidth: 900, minHeight: 600) .onAppear { bridge.setup() } } #if os(macOS) .commands { CommandGroup(replacing: .newItem) {} CommandMenu(L.playback) { Button(L.playPause) { bridge.togglePlayPause() } .keyboardShortcut(" ", modifiers: []) Divider() Button(L.prev) { bridge.playPrev() } .keyboardShortcut("[", modifiers: []) Button(L.next) { bridge.playNext() } .keyboardShortcut("]", modifiers: []) Divider() Button(L.fullscreen) { bridge.toggleFullscreen() } .keyboardShortcut("f", modifiers: .command) Divider() Button(L.volUp) { bridge.adjustVolume(by: 0.1) } .keyboardShortcut("=", modifiers: .command) Button(L.volDown) { bridge.adjustVolume(by: -0.1) } .keyboardShortcut("-", modifiers: .command) Divider() Button(L.repeatMode) { bridge.cycleRepeatMode() } .keyboardShortcut("r", modifiers: .command) } CommandMenu(L.file) { Button(L.openFile) { bridge.openFileDialog() } .keyboardShortcut("o", modifiers: .command) Divider() Button(L.addURL) { bridge.showURLDialog = true } .keyboardShortcut("u", modifiers: .command) } } #endif } } // MARK: - 主内容视图 struct ContentView: View { @ObservedObject var bridge: PlayerBridge // 自动隐藏:本地状态,不经过 bridge 的 @Published @State private var toolbarVisible = false @State private var isHoveringToolbar = false @State private var lastInteraction = Date() private let hideTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() private let autoHideInterval: TimeInterval = 60 var body: some View { ZStack { // 视频占满全部 VideoPlayerRepresentable(player: bridge.player) .background(Color.black) .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() // 单击播放/暂停 + 双击全屏 Color.clear .contentShape(Rectangle()) .onTapGesture { bridge.togglePlayPause() touchInteraction() } #if os(macOS) .onTapGesture(count: 2) { bridge.toggleFullscreen() } #endif .ignoresSafeArea() // Logo(左上角,始终显示) VStack { HStack { Button(action: { toolbarVisible.toggle() if toolbarVisible { touchInteraction() } }) { MiniPlayerIcon() .frame(width: 36, height: 36) .padding(6) .background(.black.opacity(0.4)) .cornerRadius(10) } .buttonStyle(.plain) Spacer() } .padding(.leading, 16) .padding(.top, 12) Spacer() } // 底部 Toolbar(半透明) if toolbarVisible { VStack { Spacer() ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar, onTouch: touchInteraction) .transition(.move(edge: .bottom).combined(with: .opacity)) } } // Toast if let msg = bridge.toastMessage { VStack { Spacer() Text(msg) .padding(8) .background(.black.opacity(0.7)) .foregroundColor(.white) .cornerRadius(6) .padding(.bottom, 100) } } } .sheet(isPresented: $bridge.showURLDialog) { URLInputDialog(bridge: bridge) } .sheet(isPresented: $bridge.showTrackDialog) { TrackSelectDialog(bridge: bridge) } .sheet(isPresented: $bridge.showPlaylistSheet) { PlaylistWindowView(bridge: bridge) } .animation(.easeInOut(duration: 0.25), value: toolbarVisible) // 自动隐藏 Timer:每2秒检查一次 .onReceive(hideTimer) { _ in guard toolbarVisible else { return } if isHoveringToolbar { lastInteraction = Date(); return } if Date().timeIntervalSince(lastInteraction) >= autoHideInterval { toolbarVisible = false } } // 全屏退出通知 — async 避免在 SwiftUI 更新周期内操作 NSWindow .onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in DispatchQueue.main.async { [bridge] in bridge.toggleFullscreen() } } } private func touchInteraction() { lastInteraction = Date() } } // 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: 12, design: .monospaced)) .foregroundColor(.white) .frame(width: 50, alignment: .trailing) ProgressSlider(player: bridge.player, bridge: bridge) .frame(height: 20) Text(bridge.totalTimeText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.white) .frame(width: 50, alignment: .leading) } // 按钮行 HStack(spacing: 10) { #if os(macOS) ToolbarButton(label: "📂 \(L.open)") { bridge.openFileDialog(); onTouch() } #endif ToolbarButton(label: "⏮") { bridge.playPrev(); onTouch() } ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause(); onTouch() } ToolbarButton(label: "⏭") { bridge.playNext(); onTouch() } Spacer() // 音量控制 ToolbarButton(label: "🔈") { 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 let ratio = max(0, min(1, value.location.x / geo.size.width)) bridge.setVolume(Float(ratio)) onTouch() } ) } .frame(width: 80, height: 20) Text("\(Int(bridge.volume * 100))") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.white.opacity(0.7)) .frame(width: 30) ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1); onTouch() } Spacer() Text(bridge.currentTrackLabel) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.7)) ToolbarButton(label: L.audioTrack) { bridge.showTrackDialog = true; onTouch() } ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.displayName)") { bridge.cycleRepeatMode(); onTouch() } ToolbarButton(label: "⛶") { bridge.toggleFullscreen(); onTouch() } ToolbarButton(label: "📋 \(L.list) (\(bridge.queue.count))") { bridge.togglePlaylistWindow(); onTouch() } } } .padding(.horizontal, 16) .padding(.vertical, 8) .background(.black.opacity(0.55)) .onHover { hovering in isHovering = hovering if hovering { onTouch() } } } } // MARK: - 半透明按钮 struct ToolbarButton: View { let label: String let action: () -> Void @State private var hovering = false var body: some View { Button(action: action) { Text(label) .font(.system(size: 13)) .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(hovering ? .white.opacity(0.15) : .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) } }