diff --git a/Sources/Localization.swift b/Sources/Localization.swift new file mode 100644 index 0000000..f17baa0 --- /dev/null +++ b/Sources/Localization.swift @@ -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: "") +} diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index c8d2712..51f9a64 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -120,6 +120,9 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in bridge.toggleFullscreen() } + .onDisappear { + bridge.cleanup() + } } } diff --git a/Sources/MiniPlayerIcon.swift b/Sources/MiniPlayerIcon.swift new file mode 100644 index 0000000..89b32e5 --- /dev/null +++ b/Sources/MiniPlayerIcon.swift @@ -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) + } +} diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 46fe77d..889aab6 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -61,9 +61,11 @@ final class PlayerBridge: ObservableObject { // MARK: - 内部状态 let player = AVPlayer() + var cancellables = Set() private var timeObserver: Any? private var itemStatusObserver: NSKeyValueObservation? - private var cancellables = Set() + 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) } } diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..d7ef7b0 --- /dev/null +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Sources/Resources/iOS/AppIcon.appiconset/Contents.json b/Sources/Resources/iOS/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..7b3bbd2 --- /dev/null +++ b/Sources/Resources/iOS/AppIcon.appiconset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_1024.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..0285f35 Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_1024.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_120.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_120.png new file mode 100644 index 0000000..9d5f9a8 Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_120.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_152.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_152.png new file mode 100644 index 0000000..c2ab899 Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_152.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_167.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_167.png new file mode 100644 index 0000000..8d4d35e Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_167.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_180.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_180.png new file mode 100644 index 0000000..0584068 Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_180.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_40.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_40.png new file mode 100644 index 0000000..d22550e Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_40.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_58.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_58.png new file mode 100644 index 0000000..f44e57e Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_58.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_60.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_60.png new file mode 100644 index 0000000..ca05232 Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_60.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_80.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_80.png new file mode 100644 index 0000000..61d351f Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_80.png differ diff --git a/Sources/Resources/iOS/AppIcon.appiconset/icon_87.png b/Sources/Resources/iOS/AppIcon.appiconset/icon_87.png new file mode 100644 index 0000000..07af970 Binary files /dev/null and b/Sources/Resources/iOS/AppIcon.appiconset/icon_87.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/Contents.json b/Sources/Resources/macOS/MiniPlayer.appiconset/Contents.json new file mode 100644 index 0000000..d040faf --- /dev/null +++ b/Sources/Resources/macOS/MiniPlayer.appiconset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_128.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_128.png new file mode 100644 index 0000000..bd90b01 Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_128.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_128@2x.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_128@2x.png new file mode 100644 index 0000000..42b83dc Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_128@2x.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_16.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_16.png new file mode 100644 index 0000000..4d52787 Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_16.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_16@2x.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_16@2x.png new file mode 100644 index 0000000..76fdb3a Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_16@2x.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_256.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_256.png new file mode 100644 index 0000000..42b83dc Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_256.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_256@2x.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_256@2x.png new file mode 100644 index 0000000..e78311b Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_256@2x.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_32.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_32.png new file mode 100644 index 0000000..76fdb3a Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_32.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_32@2x.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_32@2x.png new file mode 100644 index 0000000..2ff0f7e Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_32@2x.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_512.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_512.png new file mode 100644 index 0000000..e78311b Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_512.png differ diff --git a/Sources/Resources/macOS/MiniPlayer.appiconset/icon_512@2x.png b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_512@2x.png new file mode 100644 index 0000000..0285f35 Binary files /dev/null and b/Sources/Resources/macOS/MiniPlayer.appiconset/icon_512@2x.png differ diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..626acff --- /dev/null +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -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" = "列表"; diff --git a/generate_icons.py b/generate_icons.py new file mode 100644 index 0000000..b2e560c --- /dev/null +++ b/generate_icons.py @@ -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!")