MiniPlayer/Sources/MiniPlayerApp.swift
yumoqing cae63c6588 fix: persistent fullscreen window — never destroy AVPlayerView
Root cause: every fullscreen exit destroyed the AVPlayerView, triggering
CoreMedia's FigNotificationCenterRemoveWeakListeners which races with
the main thread's autorelease pool drain. No amount of nil-ing the
player reference or delaying the close could prevent this race.

Solution: the fullscreen NSWindow + AVPlayerView are created ONCE on
first use and never destroyed. Toggle uses orderFront/orderOut instead
of makeKeyAndOrderFront/close. The AVPlayerView stays alive for the
entire app lifetime, so CoreMedia teardown never happens during toggle.

Also:
- Removed onDisappear { bridge.cleanup() } — cleanup during app
  termination races with autorelease pool drain. Process exit handles
  all teardown correctly.
- Double-click exit uses DispatchQueue.main.async to avoid calling
  toggleFullscreen from inside the event monitor callback.
- isReleasedWhenClosed = false on fullscreen window
2026-06-22 09:01:04 +08:00

362 lines
13 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
@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)
// Timer2
.onReceive(hideTimer) { _ in
guard toolbarVisible else { return }
if isHoveringToolbar { lastInteraction = Date(); return }
if Date().timeIntervalSince(lastInteraction) >= autoHideInterval {
toolbarVisible = false
}
}
}
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)
}
}