MiniPlayer/Sources/MiniPlayerApp.swift
yumoqing 05afadfd11 feat: 媒体库 - 播放记忆、多播放列表、M3U导入、模糊搜索、喜欢/标签系统
- MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢
- M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性)
- MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI
- PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复)
- 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放
- 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U
2026-06-30 00:28:14 +08:00

593 lines
22 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
// 退
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
@main
struct MiniPlayerApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDel.self) var appDelegate
#endif
@StateObject private var bridge = PlayerBridge()
@StateObject private var engine = BricksEngine()
var body: some Scene {
WindowGroup {
BricksAppView(bridge: bridge, engine: engine)
.frame(minWidth: 900, minHeight: 600)
.onAppear {
bridge.setup()
setupBridgeEvents()
loadI18n()
}
}
#if os(macOS)
.commands {
CommandGroup(replacing: .newItem) {}
CommandMenu(L.playback) {
Button(L.playPause) { bridge.togglePlayPause() }
.keyboardShortcut(" ", modifiers: [])
Divider()
Button(L.prev) { bridge.playPrev() }
.keyboardShortcut("[", modifiers: [])
Button(L.next) { bridge.playNext() }
.keyboardShortcut("]", modifiers: [])
Divider()
Button(L.fullscreen) { bridge.toggleFullscreen() }
.keyboardShortcut("f", modifiers: .command)
Divider()
Button(L.volUp) { bridge.adjustVolume(by: 0.1) }
.keyboardShortcut("=", modifiers: .command)
Button(L.volDown) { bridge.adjustVolume(by: -0.1) }
.keyboardShortcut("-", modifiers: .command)
Divider()
Button(L.repeatMode) { bridge.cycleRepeatMode() }
.keyboardShortcut("r", modifiers: .command)
}
CommandMenu(L.file) {
Button(L.openFile) { bridge.openFileDialog() }
.keyboardShortcut("o", modifiers: .command)
Divider()
Button(L.addURL) { bridge.showURLDialog = true }
.keyboardShortcut("u", modifiers: .command)
Divider()
Button(L.mediaLibrary) { bridge.toggleLibraryWindow() }
.keyboardShortcut("l", modifiers: [.command, .shift])
Button(L.importM3U) { bridge.toggleLibraryWindow() }
.keyboardShortcut("i", modifiers: [.command, .shift])
}
}
#endif
}
// BricksEngine PlayerBridge
private func setupBridgeEvents() {
// PlayerBridge AVPlayer Store VideoPlayerControl
engine.store.setValue(id: "videoPlayer.player", value: bridge.player)
// Store Input
engine.eventBus.on("videoPlayer.setURL") { [weak bridge, weak engine] data in
guard let bridge, let engine else { return }
// URL Input widget store
var url: String? = data["url"] as? String
if url == nil || url?.isEmpty == true {
url = engine.store.getValue(id: "videoUrlInput") as? String
}
if let url, !url.isEmpty {
bridge.addURL(url)
}
}
// seek AVPlayer
engine.eventBus.on("videoPlayer.seek") { [weak bridge] data in
guard let bridge else { return }
if let timeStr = data["time"] as? String, let time = Double(timeStr) {
bridge.player.seek(to: CMTime(seconds: time, preferredTimescale: 600))
}
}
//
engine.eventBus.on("videoPlayer.setVolume") { [weak bridge] data in
guard let bridge else { return }
if let volStr = data["volume"] as? String, let vol = Float(volStr) {
bridge.setVolume(vol)
}
}
//
engine.eventBus.on("videoPlayer.fullscreen") { [weak bridge] data in
guard let bridge else { return }
bridge.toggleFullscreen()
}
// /
engine.eventBus.on("videoPlayer.prev") { [weak bridge] data in
guard let bridge else { return }
bridge.playPrev()
}
engine.eventBus.on("videoPlayer.next") { [weak bridge] data in
guard let bridge else { return }
bridge.playNext()
}
//
engine.eventBus.on("videoPlayer.openFile") { [weak bridge] data in
guard let bridge else { return }
bridge.openFileDialog()
}
}
// i18n
private func loadI18n() {
//
let locale = Locale.current.language.languageCode?.identifier ?? "en"
let langFile = (locale == "zh") ? "zh" : "en"
if let url = Bundle.module.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
struct PlayerLayerView: NSViewRepresentable {
let player: AVPlayer
func makeNSView(context: Context) -> NSView {
let view = PlayerLayerHostView()
view.playerLayer.player = player
view.playerLayer.videoGravity = .resizeAspect
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if let host = nsView as? PlayerLayerHostView {
host.playerLayer.player = player
}
}
}
class PlayerLayerHostView: NSView {
let playerLayer = AVPlayerLayer()
override init(frame: CGRect) {
super.init(frame: frame)
wantsLayer = true
layer = playerLayer
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layout() {
super.layout()
playerLayer.frame = bounds
}
}
// MARK: -
struct PlayerContentView: View {
@ObservedObject var bridge: PlayerBridge
@State private var showToolbar = false
@State private var isHoveringIcon = false
@State private var hideTimer: Timer?
@State private var isHoveringToolbar = false
private func resetHideTimer() {
hideTimer?.invalidate()
guard showToolbar else { return }
hideTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in
withAnimation(.easeInOut(duration: 0.5)) {
showToolbar = false
}
}
}
var body: some View {
ZStack(alignment: .topLeading) {
Color.black.ignoresSafeArea()
// AVPlayerLayer AVPlayerView
PlayerLayerView(player: bridge.player)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture(count: 2) {
bridge.toggleFullscreen()
}
.onTapGesture {
bridge.togglePlayPause()
}
.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
}
//
MiniPlayerIcon()
.frame(width: 32, height: 32)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.black.opacity(0.3))
)
.opacity(isHoveringIcon ? 1.0 : 0.6)
.onHover { isHoveringIcon = $0 }
.contentShape(Rectangle())
.highPriorityGesture(
TapGesture().onEnded {
withAnimation(.easeInOut(duration: 0.25)) {
showToolbar.toggle()
}
resetHideTimer()
}
)
// 30
VStack {
Spacer()
if showToolbar {
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) {
resetHideTimer()
}
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.6))
)
.padding(12)
.onHover { hovering in
isHoveringToolbar = hovering
if hovering { resetHideTimer() }
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.onAppear {
// toolbar
NSApplication.shared.windows.first?.titleVisibility = .hidden
}
.onChange(of: showToolbar) { _, newValue in
// /toolbar
NSApplication.shared.windows.first?.titleVisibility = newValue ? .visible : .hidden
if newValue {
resetHideTimer()
} else {
hideTimer?.invalidate()
}
}
.onDisappear {
hideTimer?.invalidate()
}
}
}
// MARK: - BricksView wrapper
struct BricksAppView: View {
@ObservedObject var bridge: PlayerBridge
@ObservedObject var engine: BricksEngine
var body: some View {
PlayerContentView(bridge: bridge)
.sheet(isPresented: $bridge.showURLDialog) {
URLInputDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showTrackDialog) {
TrackSelectDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge)
}
}
}
// MARK: -
struct ControlToolbar: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isHovering: Bool
let onTouch: () -> Void
var body: some View {
VStack(spacing: 6) {
//
HStack(spacing: 8) {
Text(bridge.currentTimeText)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.frame(width: 45, alignment: .trailing)
ProgressSlider(player: bridge.player, bridge: bridge)
.frame(height: 20)
Text(bridge.totalTimeText)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.frame(width: 45, alignment: .leading)
}
//
HStack(spacing: 10) {
//
if bridge.screenRecorder.isRecording {
HStack(spacing: 4) {
Image(systemName: "stop.circle.fill")
.foregroundColor(.red)
.font(.system(size: 18))
Text(bridge.screenRecorder.durationText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.red)
}
.contentShape(Rectangle())
.onTapGesture { bridge.toggleRecording(); onTouch() }
} else {
TB(icon: "record.circle") {
bridge.toggleRecording(); onTouch()
}
}
Divider().frame(height: 20).padding(.horizontal, 4)
TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
TB(icon: "link") { bridge.showURLDialog = true; onTouch() }
TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); 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)
//
TB(icon: "music.note") { bridge.showTrackDialog = true; onTouch() }
//
TB(icon: repeatIcon()) { bridge.cycleRepeatMode(); onTouch() }
//
TB(icon: "arrow.up.left.and.arrow.down.right") { bridge.toggleFullscreen(); onTouch() }
//
TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() }
//
TB(icon: "books.vertical") { bridge.toggleLibraryWindow(); onTouch() }
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
private func volumeIcon() -> String {
if bridge.volume <= 0 { return "speaker.slash.fill" }
if bridge.volume < 0.5 { return "speaker.wave.1.fill" }
return "speaker.wave.3.fill"
}
private func repeatIcon() -> String {
switch bridge.repeatMode {
case .none: return "repeat"
case .single: return "repeat.1"
case .all: return "repeat"
}
}
}
//
struct TB: View {
let icon: String
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(.white)
.frame(width: 28, height: 28)
.background(hovering ? Color.white.opacity(0.2) : .clear)
.cornerRadius(4)
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
}
}
// MARK: -
struct PlaylistWindowView: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
VStack(spacing: 0) {
HStack {
Button(L.addFile) { bridge.openFileDialog() }
Button(L.addURLBtn) { bridge.showURLDialog = true }
Spacer()
Text("\(bridge.queue.count) \(L.itemsCount)")
.font(.caption).foregroundColor(.secondary)
}
.padding(12)
.background(.regularMaterial)
Divider()
if bridge.queue.isEmpty {
VStack { Spacer(); Text(L.noMedia).foregroundColor(.secondary); Spacer() }
} else {
ScrollView {
LazyVStack(spacing: 2) {
ForEach(Array(bridge.queue.enumerated()), id: \.element.id) { idx, item in
HStack {
if idx == bridge.currentIndex {
Text("").foregroundColor(.accentColor).font(.caption)
}
VStack(alignment: .leading, spacing: 2) {
Text(item.name).font(.system(size: 13)).lineLimit(1)
Text(item.mediaType).font(.caption).foregroundColor(.secondary)
}
Spacer()
Button(role: .destructive) { bridge.removeItem(at: idx) } label: {
Image(systemName: "trash").font(.caption)
}.buttonStyle(.plain)
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : .clear)
.contentShape(Rectangle())
.onTapGesture { bridge.playIndex(idx) }
}
}
}
}
}
.frame(minWidth: 350, minHeight: 400)
}
}
// MARK: - URL
struct URLInputDialog: View {
@ObservedObject var bridge: PlayerBridge
@State private var url = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text(L.addMediaURL).font(.headline)
TextField("https://example.com/video.m3u8", text: $url)
.textFieldStyle(.roundedBorder)
HStack {
Button(L.cancel) { dismiss() }
Spacer()
Button(L.add) {
if !url.isEmpty { bridge.addURL(url); dismiss() }
}
.keyboardShortcut(.defaultAction)
.disabled(url.isEmpty)
}
}
.padding(20)
.frame(width: 400)
}
}
// MARK: -
struct TrackSelectDialog: View {
@ObservedObject var bridge: PlayerBridge
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 12) {
Text(L.selectTrack).font(.headline)
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
HStack {
Text("\(L.track) \(idx + 1)")
Spacer()
if idx == bridge.currentTrackIndex {
Text("").foregroundColor(.green)
}
}
.padding(8).contentShape(Rectangle())
}.buttonStyle(.plain)
}
Divider()
Button(L.close) { dismiss() }
}
.padding(20)
.frame(minWidth: 250)
}
}