fix: replace Combine KVO with NSKeyValueObservation to prevent autorelease crash

- Remove Combine import and cancellables
- Use NSKeyValueObservation for timeControlStatus (controlled teardown)
- Invalidate itemStatusObserver before replacing playerItem
- Store endObserver token for proper removal
- Add cleanup() method called on view disappear
- Proper deinit with direct property cleanup (nonisolated-safe)
This commit is contained in:
yumoqing 2026-06-22 00:36:18 +08:00
parent 1fa6f6a4bb
commit 3e3a990f5e
29 changed files with 612 additions and 11 deletions

View File

@ -0,0 +1,53 @@
import Foundation
// MARK: - i18n
enum L {
//
static let playPause = NSLocalizedString("play_pause", value: "Play/Pause", comment: "")
static let prev = NSLocalizedString("prev", value: "Previous", comment: "")
static let next = NSLocalizedString("next", value: "Next", comment: "")
static let fullscreen = NSLocalizedString("fullscreen", value: "Fullscreen", comment: "")
static let volUp = NSLocalizedString("vol_up", value: "Volume+", comment: "")
static let volDown = NSLocalizedString("vol_down", value: "Volume-", comment: "")
static let repeatMode = NSLocalizedString("repeat_mode", value: "Repeat Mode", comment: "")
static let openFile = NSLocalizedString("open_file", value: "Open File...", comment: "")
static let addURL = NSLocalizedString("add_url", value: "Add URL...", comment: "")
static let playback = NSLocalizedString("menu_playback", value: "Playback", comment: "")
static let file = NSLocalizedString("menu_file", value: "File", comment: "")
//
static let playlist = NSLocalizedString("playlist", value: "Playlist", comment: "")
static let addFile = NSLocalizedString("add_file", value: "Add File", comment: "")
static let addURLBtn = NSLocalizedString("add_url_btn", value: "Add URL", comment: "")
static let itemsCount = NSLocalizedString("items_count", value: "items", comment: "")
static let noMedia = NSLocalizedString("no_media", value: "No media", comment: "")
// URL
static let addMediaURL = NSLocalizedString("add_media_url", value: "Add Media URL", comment: "")
static let cancel = NSLocalizedString("cancel", value: "Cancel", comment: "")
static let add = NSLocalizedString("add", value: "Add", comment: "")
//
static let selectTrack = NSLocalizedString("select_track", value: "Select Track", comment: "")
static let track = NSLocalizedString("track", value: "Track", comment: "")
static let audioTrack = NSLocalizedString("audio_track", value: "Audio Track", comment: "")
static let close = NSLocalizedString("close", value: "Close", comment: "")
//
static let repeatNone = NSLocalizedString("repeat_none", value: "No Repeat", comment: "")
static let repeatSingle = NSLocalizedString("repeat_single", value: "Repeat One", comment: "")
static let repeatAll = NSLocalizedString("repeat_all", value: "Repeat All", comment: "")
// Toast
static func nowPlaying(_ name: String) -> String {
String(format: NSLocalizedString("now_playing", value: "Now Playing: %@", comment: ""), name)
}
static func repeatModeLabel(_ mode: String) -> String {
String(format: NSLocalizedString("repeat_mode_label", value: "Repeat: %@", comment: ""), mode)
}
static let invalidURL = NSLocalizedString("invalid_url", value: "Invalid URL", comment: "")
//
static let open = NSLocalizedString("open", value: "Open", comment: "")
static let list = NSLocalizedString("list", value: "List", comment: "")
}

View File

@ -120,6 +120,9 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
bridge.toggleFullscreen()
}
.onDisappear {
bridge.cleanup()
}
}
}

View File

@ -0,0 +1,105 @@
import SwiftUI
// MARK: - MiniPlayer +
struct MiniPlayerIcon: View {
var body: some View {
GeometryReader { geo in
let size = min(geo.size.width, geo.size.height)
ZStack {
//
RingArc(startAngle: -30, endAngle: 90)
.stroke(Color(red: 1.0, green: 0.23, blue: 0.19), lineWidth: size * 0.07)
.frame(width: size * 0.82, height: size * 0.82)
RingArc(startAngle: 90, endAngle: 210)
.stroke(Color(red: 0.20, green: 0.78, blue: 0.35), lineWidth: size * 0.07)
.frame(width: size * 0.82, height: size * 0.82)
RingArc(startAngle: 210, endAngle: 330)
.stroke(Color(red: 0.0, green: 0.48, blue: 1.0), lineWidth: size * 0.07)
.frame(width: size * 0.82, height: size * 0.82)
//
TriColorPlay(size: size * 0.38)
.offset(x: size * 0.03) //
}
.frame(width: size, height: size)
}
}
}
// MARK: -
struct RingArc: Shape {
let startAngle: Double
let endAngle: Double
func path(in rect: CGRect) -> Path {
var p = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
p.addArc(
center: center,
radius: radius,
startAngle: .degrees(startAngle),
endAngle: .degrees(endAngle),
clockwise: false
)
return p
}
}
// MARK: -
struct TriColorPlay: View {
let size: CGFloat
var body: some View {
Canvas { ctx, canvasSize in
let w = canvasSize.width
let h = canvasSize.height
//
let top = CGPoint(x: w * 0.15, y: 0)
let bottom = CGPoint(x: w * 0.15, y: h)
let right = CGPoint(x: w, y: h * 0.5)
//
let cx = (top.x + bottom.x + right.x) / 3
let cy = (top.y + bottom.y + right.y) / 3
let center = CGPoint(x: cx, y: cy)
//
let red = Path { p in
p.move(to: center)
p.addLine(to: top)
p.addLine(to: right)
p.closeSubpath()
}
let green = Path { p in
p.move(to: center)
p.addLine(to: right)
p.addLine(to: bottom)
p.closeSubpath()
}
let blue = Path { p in
p.move(to: center)
p.addLine(to: bottom)
p.addLine(to: top)
p.closeSubpath()
}
ctx.fill(red, with: .color(Color(red: 1.0, green: 0.23, blue: 0.19)))
ctx.fill(green, with: .color(Color(red: 0.20, green: 0.78, blue: 0.35)))
ctx.fill(blue, with: .color(Color(red: 0.0, green: 0.48, blue: 1.0)))
// 线
let lineW = max(0.5, w * 0.015)
for vertex in [top, bottom, right] {
var lp = Path()
lp.move(to: center)
lp.addLine(to: vertex)
ctx.stroke(lp, with: .color(.white.opacity(0.6)), lineWidth: lineW)
}
}
.frame(width: size, height: size)
}
}

View File

@ -61,9 +61,11 @@ final class PlayerBridge: ObservableObject {
// MARK: -
let player = AVPlayer()
var cancellables = Set<AnyCancellable>()
private var timeObserver: Any?
private var itemStatusObserver: NSKeyValueObservation?
private var cancellables = Set<AnyCancellable>()
private var playbackStatusObserver: NSKeyValueObservation?
private var endObserverToken: NSObjectProtocol?
private var preferredTrackIndex: Int = 0
#if os(macOS)
@ -114,6 +116,10 @@ final class PlayerBridge: ObservableObject {
currentIndex = index
let item = queue[index]
// KVO
itemStatusObserver?.invalidate()
itemStatusObserver = nil
let playerItem = AVPlayerItem(url: item.url)
player.replaceCurrentItem(with: playerItem)
player.play()
@ -136,7 +142,7 @@ final class PlayerBridge: ObservableObject {
// MARK: -
private func setupEndObserver() {
NotificationCenter.default.addObserver(
endObserverToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.onPlaybackEnded() }
@ -330,8 +336,9 @@ final class PlayerBridge: ObservableObject {
// MARK: -
@Published var showPlaylistSheet = false
@Published var lastInteraction = Date()
@Published var isInteracting = false
// @Published onHover
var lastInteraction = Date()
var isInteracting = false
func recordInteraction() { lastInteraction = Date() }
@ -365,17 +372,29 @@ final class PlayerBridge: ObservableObject {
// MARK: -
private func setupPlaybackStatusObserver() {
player.publisher(for: \.timeControlStatus)
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
self?.isPlaying = (status == .playing)
}
.store(in: &cancellables)
playbackStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in
let playing = (p.timeControlStatus == .playing)
Task { @MainActor in self?.isPlaying = playing }
}
}
/// app 退
func cleanup() {
if let obs = timeObserver { player.removeTimeObserver(obs); timeObserver = nil }
itemStatusObserver?.invalidate(); itemStatusObserver = nil
playbackStatusObserver?.invalidate(); playbackStatusObserver = nil
if let token = endObserverToken { NotificationCenter.default.removeObserver(token); endObserverToken = nil }
player.pause()
player.replaceCurrentItem(with: nil)
}
deinit {
if let observer = timeObserver { player.removeTimeObserver(observer) }
if let obs = timeObserver { player.removeTimeObserver(obs) }
itemStatusObserver?.invalidate()
playbackStatusObserver?.invalidate()
if let token = endObserverToken { NotificationCenter.default.removeObserver(token) }
player.pause()
player.replaceCurrentItem(with: nil)
}
}

View File

@ -0,0 +1,31 @@
"play_pause" = "Play/Pause";
"prev" = "Previous";
"next" = "Next";
"fullscreen" = "Fullscreen";
"vol_up" = "Volume+";
"vol_down" = "Volume-";
"repeat_mode" = "Repeat Mode";
"open_file" = "Open File...";
"add_url" = "Add URL...";
"menu_playback" = "Playback";
"menu_file" = "File";
"playlist" = "Playlist";
"add_file" = "📂 Add File";
"add_url_btn" = "🔗 Add URL";
"items_count" = "items";
"no_media" = "No media";
"add_media_url" = "Add Media URL";
"cancel" = "Cancel";
"add" = "Add";
"select_track" = "Select Track";
"track" = "Track";
"audio_track" = "🎵 Audio Track";
"close" = "Close";
"repeat_none" = "No Repeat";
"repeat_single" = "Repeat One";
"repeat_all" = "Repeat All";
"now_playing" = "Now Playing: %@";
"repeat_mode_label" = "Repeat: %@";
"invalid_url" = "Invalid URL";
"open" = "Open";
"list" = "List";

View File

@ -0,0 +1,74 @@
{
"images": [
{
"filename": "icon_40.png",
"idiom": "iphone",
"scale": "2x",
"size": "20x20"
},
{
"filename": "icon_60.png",
"idiom": "iphone",
"scale": "3x",
"size": "20x20"
},
{
"filename": "icon_58.png",
"idiom": "iphone",
"scale": "2x",
"size": "29x29"
},
{
"filename": "icon_87.png",
"idiom": "iphone",
"scale": "3x",
"size": "29x29"
},
{
"filename": "icon_80.png",
"idiom": "iphone",
"scale": "2x",
"size": "40x40"
},
{
"filename": "icon_120.png",
"idiom": "iphone",
"scale": "3x",
"size": "40x40"
},
{
"filename": "icon_120.png",
"idiom": "iphone",
"scale": "2x",
"size": "60x60"
},
{
"filename": "icon_180.png",
"idiom": "iphone",
"scale": "3x",
"size": "60x60"
},
{
"filename": "icon_152.png",
"idiom": "ipad",
"scale": "2x",
"size": "76x76"
},
{
"filename": "icon_167.png",
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5"
},
{
"filename": "icon_1024.png",
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024"
}
],
"info": {
"author": "MiniPlayer",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,68 @@
{
"images": [
{
"filename": "icon_16.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename": "icon_16@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"filename": "icon_32.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"filename": "icon_32@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"filename": "icon_128.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"filename": "icon_128@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"filename": "icon_256.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"filename": "icon_256@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "icon_512.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"filename": "icon_512@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "MiniPlayer",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,31 @@
"play_pause" = "播放/暂停";
"prev" = "上一首";
"next" = "下一首";
"fullscreen" = "全屏";
"vol_up" = "音量+";
"vol_down" = "音量-";
"repeat_mode" = "循环模式";
"open_file" = "打开文件...";
"add_url" = "添加URL...";
"menu_playback" = "播放";
"menu_file" = "文件";
"playlist" = "播放列表";
"add_file" = "📂 添加文件";
"add_url_btn" = "🔗 添加URL";
"items_count" = "项";
"no_media" = "暂无媒体";
"add_media_url" = "添加媒体URL";
"cancel" = "取消";
"add" = "添加";
"select_track" = "选择音轨";
"track" = "音轨";
"audio_track" = "🎵 音轨";
"close" = "关闭";
"repeat_none" = "不循环";
"repeat_single" = "单曲循环";
"repeat_all" = "列表循环";
"now_playing" = "正在播放: %@";
"repeat_mode_label" = "循环模式: %@";
"invalid_url" = "无效URL";
"open" = "打开";
"list" = "列表";

217
generate_icons.py Normal file
View File

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Generate MiniPlayer app icons (tri-color play button + ring)"""
from PIL import Image, ImageDraw
import math
import os
def create_icon(size):
"""Create icon at given size"""
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Background: dark rounded rect
margin = int(size * 0.05)
radius = int(size * 0.18)
draw.rounded_rectangle(
[margin, margin, size - margin, size - margin],
radius=radius,
fill=(28, 28, 30, 255)
)
cx, cy = size / 2, size / 2
ring_radius = size * 0.38
ring_width = size * 0.06
tri_size = size * 0.28
# Colors
RED = (255, 59, 48)
GREEN = (52, 199, 89)
BLUE = (0, 122, 255)
# === Outer Ring: 3 arc segments ===
# Red: 150-270°, Green: 270-30°, Blue: 30-150°
# Draw as thick arcs
inner_r = ring_radius - ring_width / 2
outer_r = ring_radius + ring_width / 2
def draw_arc_segment(color, start_deg, end_deg):
"""Draw a thick arc segment"""
for angle in range(int(start_deg * 10), int(end_deg * 10)):
a = angle / 10.0
rad = math.radians(a)
ix = cx + inner_r * math.cos(rad)
iy = cy + inner_r * math.sin(rad)
ox = cx + outer_r * math.cos(rad)
oy = cy + outer_r * math.sin(rad)
draw.line([(ix, iy), (ox, oy)], fill=color, width=max(1, int(size * 0.005)))
# Ring segments (start from top, clockwise)
# Red: top-left to bottom-left (180° arc, offset)
draw_arc_segment(RED, -60, 60) # right side
draw_arc_segment(GREEN, 60, 180) # bottom
draw_arc_segment(BLUE, 180, 300) # left/top
# === Play Triangle: split into 3 colored sections from center ===
# Triangle pointing right: vertices relative to center
# Shift slightly right for visual centering of play button
offset_x = tri_size * 0.08
# Triangle vertices (pointing right)
top = (cx - tri_size * 0.4 + offset_x, cy - tri_size * 0.55)
bottom = (cx - tri_size * 0.4 + offset_x, cy + tri_size * 0.55)
right = (cx + tri_size * 0.55 + offset_x, cy)
# Center of triangle
tcx = (top[0] + bottom[0] + right[0]) / 3
tcy = (top[1] + bottom[1] + right[1]) / 3
# Draw 3 sections from center to each edge
# Section 1 (Red): center -> top -> right
draw.polygon([
(tcx, tcy),
top,
right
], fill=RED)
# Section 2 (Green): center -> right -> bottom
draw.polygon([
(tcx, tcy),
right,
bottom
], fill=GREEN)
# Section 3 (Blue): center -> bottom -> top
draw.polygon([
(tcx, tcy),
bottom,
top
], fill=BLUE)
# Thin white lines from center to vertices (separators)
line_w = max(1, int(size * 0.004))
draw.line([(tcx, tcy), top], fill=(255, 255, 255, 180), width=line_w)
draw.line([(tcx, tcy), bottom], fill=(255, 255, 255, 180), width=line_w)
draw.line([(tcx, tcy), right], fill=(255, 255, 255, 180), width=line_w)
return img
def generate_macos_icons(output_dir):
"""Generate macOS .appiconset"""
appiconset = os.path.join(output_dir, "MiniPlayer.appiconset")
os.makedirs(appiconset, exist_ok=True)
sizes = {
"icon_16": 16,
"icon_16@2x": 32,
"icon_32": 32,
"icon_32@2x": 64,
"icon_128": 128,
"icon_128@2x": 256,
"icon_256": 256,
"icon_256@2x": 512,
"icon_512": 512,
"icon_512@2x": 1024,
}
images_json = []
for name, px in sizes.items():
fname = f"{name}.png"
icon = create_icon(px)
icon.save(os.path.join(appiconset, fname))
# Parse size and scale
if "@2x" in name:
base = name.replace("@2x", "")
scale = "2x"
sz = base.replace("icon_", "")
else:
scale = "1x"
sz = name.replace("icon_", "")
images_json.append({
"filename": fname,
"idiom": "mac",
"scale": scale,
"size": f"{sz}x{sz}"
})
print(f" {fname}: {px}x{px}")
# Write Contents.json
import json
contents = {
"images": images_json,
"info": {"author": "MiniPlayer", "version": 1}
}
with open(os.path.join(appiconset, "Contents.json"), "w") as f:
json.dump(contents, f, indent=2)
print(f" Contents.json written")
def generate_ios_icons(output_dir):
"""Generate iOS AppIcon set"""
appiconset = os.path.join(output_dir, "AppIcon.appiconset")
os.makedirs(appiconset, exist_ok=True)
# iOS icon sizes (points x scale = pixels)
ios_sizes = [
("20x20", "2x", 40),
("20x20", "3x", 60),
("29x29", "2x", 58),
("29x29", "3x", 87),
("40x40", "2x", 80),
("40x40", "3x", 120),
("60x60", "2x", 120),
("60x60", "3x", 180),
("76x76", "2x", 152),
("83.5x83.5", "2x", 167),
("1024x1024", "1x", 1024),
]
images_json = []
for sz, scale, px in ios_sizes:
fname = f"icon_{px}.png"
fpath = os.path.join(appiconset, fname)
if not os.path.exists(fpath):
icon = create_icon(px)
icon.save(fpath)
images_json.append({
"filename": fname,
"idiom": "ios-marketing" if px == 1024 else "iphone" if "x20" in sz or "x29" in sz or "x40" in sz or "x60" in sz else "ipad",
"scale": scale,
"size": sz
})
print(f" {fname}: {px}x{px}")
import json
contents = {
"images": images_json,
"info": {"author": "MiniPlayer", "version": 1}
}
with open(os.path.join(appiconset, "Contents.json"), "w") as f:
json.dump(contents, f, indent=2)
print(f" Contents.json written")
if __name__ == "__main__":
base = os.path.dirname(os.path.abspath(__file__))
print("=== macOS Icons ===")
macos_dir = os.path.join(base, "Sources", "Resources", "macOS")
generate_macos_icons(macos_dir)
print("\n=== iOS Icons ===")
ios_dir = os.path.join(base, "Sources", "Resources", "iOS")
generate_ios_icons(ios_dir)
# Also save a preview
preview = create_icon(512)
preview_path = os.path.join(base, "build", "icon_preview.png")
os.makedirs(os.path.dirname(preview_path), exist_ok=True)
preview.save(preview_path)
print(f"\nPreview: {preview_path}")
print("\nDone!")