diff --git a/.gitignore b/.gitignore index ccfe5e9..f127af2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ build/ *.xcodeproj .DS_Store MiniPlayer.app/ + +# Build artifacts +*.dmg +MiniPlayer.app/ +.build-ios/ +*.xcodeproj/ +project.yml diff --git a/Package.swift b/Package.swift index cfa97bc..ff6f257 100644 --- a/Package.swift +++ b/Package.swift @@ -13,13 +13,24 @@ let package = Package( .package(path: "../StreamRecorder") ], targets: [ - .executableTarget( - name: "MiniPlayer", + .target( + name: "MiniPlayerCore", dependencies: [ .product(name: "SwiftBricks", package: "swiftbricks"), - .product(name: "StreamRecorderKit", package: "StreamRecorder") + .product(name: "StreamRecorderKit", package: "StreamRecorder", condition: .when(platforms: [.macOS])) ], + path: "Sources", resources: [.process("Resources")] + ), + .executableTarget( + name: "MiniPlayer", + dependencies: ["MiniPlayerCore"], + path: "SourcesMac" + ), + .executableTarget( + name: "MiniPlayeriOS", + dependencies: ["MiniPlayerCore"], + path: "SourcesiOS" ) ] ) diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift new file mode 100644 index 0000000..3f3945c --- /dev/null +++ b/Sources/HLSRecorder.swift @@ -0,0 +1,168 @@ +#if os(iOS) +import Foundation +import AVFoundation +import UIKit + +/// iOS HLS 录制器 — 下载 HLS 流片段并合并为 MP4 +@MainActor +final class HLSRecorder: ObservableObject { + @Published var isRecording = false + @Published var durationText = "00:00" + + var onRecordingSaved: ((URL) -> Void)? + var onError: ((String) -> Void)? + + private var recordTask: Task? + private var startTime: Date? + private var timer: Timer? + + func startRecording(url: URL) { + guard !isRecording else { return } + isRecording = true + startTime = Date() + + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, let start = self.startTime else { return } + let elapsed = Int(Date().timeIntervalSince(start)) + self.durationText = String(format: "%02d:%02d:%02d", elapsed/3600, (elapsed%3600)/60, elapsed%60) + } + } + + recordTask = Task { + do { + let outputURL = try await downloadAndMergeHLS(url: url) + await MainActor.run { + self.isRecording = false + self.timer?.invalidate() + self.onRecordingSaved?(outputURL) + } + } catch { + await MainActor.run { + self.isRecording = false + self.timer?.invalidate() + self.onError?(error.localizedDescription) + } + } + } + } + + func stopRecording() { + recordTask?.cancel() + recordTask = nil + isRecording = false + timer?.invalidate() + } + + private func downloadAndMergeHLS(url: URL) async throws -> URL { + // 1. Download m3u8 + let (data, _) = try await URLSession.shared.data(from: url) + guard let playlist = String(data: data, encoding: .utf8) else { + throw RecorderError.invalidPlaylist + } + + // 2. Check if master playlist - if so, pick first variant + let lines = playlist.components(separatedBy: .newlines) + var isMaster = false + var variantURL: URL? + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#EXT-X-STREAM-INF") { isMaster = true } + if isMaster && !trimmed.hasPrefix("#") && !trimmed.isEmpty { + variantURL = resolveURL(trimmed, baseURL: url) + break + } + } + + // If master, re-download variant playlist + let mediaPlaylist: String + let baseURL: URL + if let vURL = variantURL { + let (vData, _) = try await URLSession.shared.data(from: vURL) + guard let vStr = String(data: vData, encoding: .utf8) else { throw RecorderError.invalidPlaylist } + mediaPlaylist = vStr + baseURL = vURL.deletingLastPathComponent() + } else { + mediaPlaylist = playlist + baseURL = url.deletingLastPathComponent() + } + + // 3. Parse segment URLs + let mLines = mediaPlaylist.components(separatedBy: .newlines) + var segmentURLs: [URL] = [] + for line in mLines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.hasPrefix("#") && !trimmed.isEmpty { + segmentURLs.append(resolveURL(trimmed, baseURL: baseURL)) + } + } + + guard !segmentURLs.isEmpty else { throw RecorderError.noSegments } + + // 4. Download all segments to temp dir + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + var segmentFiles: [URL] = [] + for (i, segURL) in segmentURLs.enumerated() { + let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%04d", i)).ts") + let (segData, _) = try await URLSession.shared.data(from: segURL) + try segData.write(to: localFile) + segmentFiles.append(localFile) + } + + // 5. Merge segments into single MPEG-TS file + let mergedTS = tempDir.appendingPathComponent("merged.ts") + let output = try FileHandle(forWritingTo: mergedTS) + for segFile in segmentFiles { + let segData = try Data(contentsOf: segFile) + output.write(segData) + } + output.closeFile() + + // 6. Remux TS → MP4 using AVAssetExportSession + let outputMP4 = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4") + + let asset = AVURLAsset(url: mergedTS) + if let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) { + exportSession.outputURL = outputMP4 + exportSession.outputFileType = .mp4 + await exportSession.export() + if exportSession.status != .completed { + throw RecorderError.exportFailed(exportSession.error?.localizedDescription ?? "Unknown") + } + } else { + try FileManager.default.moveItem(at: mergedTS, to: outputMP4.deletingPathExtension().appendingPathExtension("ts")) + try? FileManager.default.removeItem(at: tempDir) + return outputMP4.deletingPathExtension().appendingPathExtension("ts") + } + + // 7. Clean up temp + try? FileManager.default.removeItem(at: tempDir) + + return outputMP4 + } + + private func resolveURL(_ urlString: String, baseURL: URL) -> URL { + if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { + return URL(string: urlString)! + } + return baseURL.appendingPathComponent(urlString) + } + + enum RecorderError: LocalizedError { + case invalidPlaylist + case noSegments + case exportFailed(String) + var errorDescription: String? { + switch self { + case .invalidPlaylist: return "Cannot parse HLS playlist" + case .noSegments: return "No segments found in playlist" + case .exportFailed(let msg): return "Export failed: \(msg)" + } + } + } +} +#endif diff --git a/Sources/MediaLibraryView.swift b/Sources/MediaLibraryView.swift index 7d2fa42..7459838 100644 --- a/Sources/MediaLibraryView.swift +++ b/Sources/MediaLibraryView.swift @@ -123,12 +123,14 @@ struct MediaLibraryView: View { // MARK: - 侧边栏 private var sidebarView: some View { - List(selection: $selectedSidebar) { + List { Section { Label(L.allMedia, systemImage: "square.grid.2x2") .tag(SidebarItem.all) + .onTapGesture { selectedSidebar = .all } Label(L.liked, systemImage: "heart.fill") .tag(SidebarItem.liked) + .onTapGesture { selectedSidebar = .liked } } Section(L.playlist) { @@ -140,6 +142,7 @@ struct MediaLibraryView: View { .font(.caption2).foregroundColor(.secondary) } .tag(SidebarItem.playlist(pl.id)) + .onTapGesture { selectedSidebar = .playlist(pl.id) } .contextMenu { Button(L.delete, role: .destructive) { library.deletePlaylist(pl.id) @@ -162,6 +165,7 @@ struct MediaLibraryView: View { ForEach(library.allTags, id: \.self) { tag in Label(tag, systemImage: "tag") .tag(SidebarItem.tag(tag)) + .onTapGesture { selectedSidebar = .tag(tag) } } } } @@ -255,7 +259,9 @@ struct MediaLibraryView: View { .foregroundColor(showUnavailable ? .orange : .secondary) } .buttonStyle(.plain) + #if os(macOS) .help(showUnavailable ? L.hideUnavailable : L.showUnavailable) + #endif } .padding(.horizontal, 12).padding(.vertical, 8) @@ -384,11 +390,15 @@ struct LibraryItemRow: View { case .forbidden: Image(systemName: "lock.fill") .foregroundColor(.red).font(.caption) + #if os(macOS) .help(L.statusForbidden) + #endif case .unavailable: Image(systemName: "xmark.circle.fill") .foregroundColor(.gray).font(.caption) + #if os(macOS) .help(L.statusUnavailable) + #endif case .unknown: EmptyView() } diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index e43b15e..c4108c1 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -4,7 +4,11 @@ import Combine import SwiftBricks #if os(macOS) import AppKit +#elseif os(iOS) +import UIKit +#endif +#if os(macOS) // 主窗口关闭时退出应用 class AppDel: NSObject, NSApplicationDelegate { private var closeObserver: Any? @@ -56,18 +60,21 @@ class AppDel: NSObject, NSApplicationDelegate { } #endif -@main -struct MiniPlayerApp: App { +public 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 { + public init() {} + + public var body: some Scene { WindowGroup { BricksAppView(bridge: bridge, engine: engine) + #if os(macOS) .frame(minWidth: 900, minHeight: 600) + #endif .onAppear { bridge.setup() setupBridgeEvents() @@ -177,7 +184,17 @@ struct MiniPlayerApp: App { 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"), + #if os(macOS) + let bundle: Bundle = { + if let mainResources = Bundle.main.url(forResource: "i18n", withExtension: nil) { + return Bundle.main + } + return Bundle.module + }() + #else + let bundle = Bundle.module + #endif + if let url = bundle.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) @@ -187,6 +204,7 @@ struct MiniPlayerApp: App { } // MARK: - AVPlayerLayer 直接渲染(无内置控制栏) +#if os(macOS) struct PlayerLayerView: NSViewRepresentable { let player: AVPlayer @@ -222,8 +240,37 @@ class PlayerLayerHostView: NSView { playerLayer.frame = bounds } } +#endif + +#if os(iOS) +struct PlayerLayerView: UIViewRepresentable { + let player: AVPlayer + func makeUIView(context: Context) -> PlayerLayerHostViewiOS { + let view = PlayerLayerHostViewiOS() + view.playerLayer.player = player + view.playerLayer.videoGravity = .resizeAspect + return view + } + func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) { + uiView.playerLayer.player = player + } +} +class PlayerLayerHostViewiOS: UIView { + let playerLayer = AVPlayerLayer() + override init(frame: CGRect) { + super.init(frame: frame) + layer.addSublayer(playerLayer) + } + required init?(coder: NSCoder) { fatalError() } + override func layoutSubviews() { + super.layoutSubviews() + playerLayer.frame = bounds + } +} +#endif // MARK: - 窗口级鼠标监控(替代 .onHover 和 background tracking area) +#if os(macOS) class WindowMouseMonitor { static let shared = WindowMouseMonitor() private var monitor: Any? @@ -254,6 +301,7 @@ class WindowMouseMonitor { win.standardWindowButton(.zoomButton)?.isHidden = !isInside } } +#endif // MARK: - 可复用的播放器内容视图(主窗口和全屏共用) struct PlayerContentView: View { @@ -285,6 +333,7 @@ struct PlayerContentView: View { .onTapGesture { bridge.togglePlayPause() } + #if os(macOS) .onDrop(of: [.fileURL, .text], isTargeted: nil) { providers in for provider in providers { // 尝试作为文件 URL @@ -306,6 +355,7 @@ struct PlayerContentView: View { } return true } + #endif // 播放状态指示(loading/buffering/error) PlayerStatusOverlay(bridge: bridge) @@ -351,6 +401,7 @@ struct PlayerContentView: View { } } .onAppear { + #if os(macOS) // 初始状态:标题栏完全隐藏(含红绿灯按钮) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { guard let win = NSApplication.shared.windows.first(where: { $0.isVisible }) else { return } @@ -367,6 +418,7 @@ struct PlayerContentView: View { // 用全局鼠标事件监听器替代 .onHover WindowMouseMonitor.shared.startMonitoring() } + #endif } .onChange(of: showToolbar) { _, newValue in if newValue { @@ -427,6 +479,7 @@ struct ControlToolbar: View { // 按钮行 HStack(spacing: 10) { + #if os(macOS) // 录制(放最前面,更显眼) if bridge.screenRecorder.isRecording { HStack(spacing: 4) { @@ -446,10 +499,13 @@ struct ControlToolbar: View { } Divider().frame(height: 20).padding(.horizontal, 4) + #endif TB(icon: "folder") { bridge.openFileDialog(); onTouch() } TB(icon: "link") { bridge.showURLDialog = true; onTouch() } + #if os(macOS) TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() } + #endif 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() } diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 9a2a914..65bb5fa 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -73,7 +73,9 @@ final class PlayerBridge: ObservableObject { @Published var showURLDialog = false @Published var showTrackDialog = false @Published var showQRScanner = false + #if os(macOS) var qrScannerController: QRScannerWindowController? + #endif @Published var toastMessage: String? @Published var availableTracks: [String] = [] @Published var currentTrackIndex: Int = 0 diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index be8cec6..aaf477e 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -1,6 +1,6 @@ import Foundation -import StreamRecorderKit #if os(macOS) +import StreamRecorderKit import AppKit // MARK: - RecorderState(应用退出保护) diff --git a/SourcesMac/main.swift b/SourcesMac/main.swift new file mode 100644 index 0000000..4635e26 --- /dev/null +++ b/SourcesMac/main.swift @@ -0,0 +1,2 @@ +import MiniPlayerCore +MiniPlayerApp.main() diff --git a/SourcesiOS/main.swift b/SourcesiOS/main.swift new file mode 100644 index 0000000..4635e26 --- /dev/null +++ b/SourcesiOS/main.swift @@ -0,0 +1,2 @@ +import MiniPlayerCore +MiniPlayerApp.main() diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..5283845 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# ======================================== +# MiniPlayer iOS 打包脚本 +# 生成 .ipa (需要签名配置) +# ======================================== +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_NAME="MiniPlayer" +VERSION=$(date +%Y.%m.%d) +BUILD_DIR="$PROJECT_DIR/.build-ios" + +# 签名配置 (修改为你的实际值) +TEAM_ID="${IOS_TEAM_ID:-YOUR_TEAM_ID}" +BUNDLE_ID="com.miniplayer.ios" +PROVISIONING_PROFILE="${IOS_PROVISIONING_PROFILE:-}" # 留空用 automatic signing +EXPORT_METHOD="development" # development / ad-hoc / app-store + +echo "=== MiniPlayer iOS 打包 ===" +echo "项目目录: $PROJECT_DIR" +echo "版本: $VERSION" +echo "Team ID: $TEAM_ID" + +# 1. 生成 Xcode 项目 +echo "" +echo "--- Step 1: 生成 Xcode 项目 ---" +cd "$PROJECT_DIR" + +cat > project.yml << EOF +name: MiniPlayer +options: + bundleIdPrefix: com.miniplayer + deploymentTarget: + iOS: "17.0" + xcodeVersion: "15.0" + generateEmptyDirectories: true + +packages: + SwiftBricks: + path: ../swiftbricks + StreamRecorder: + path: ../StreamRecorder + +targets: + MiniPlayer: + type: application + platform: iOS + sources: + - path: Sources + excludes: + - "**/PlayerRecorder.swift" + dependencies: + - package: SwiftBricks + - package: StreamRecorder + product: StreamRecorderKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: $BUNDLE_ID + MARKETING_VERSION: $VERSION + CURRENT_PROJECT_VERSION: 1 + INFOPLIST_KEY_CFBundleDisplayName: "MiniPlayer" + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: true + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: true + INFOPLIST_KEY_UILaunchScreen_Generation: true + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + SWIFT_VERSION: "5.9" + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: $TEAM_ID + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + resources: + - path: Sources/Resources +EOF + +if command -v xcodegen &> /dev/null; then + xcodegen generate + echo " ✓ Xcode 项目生成成功" +else + echo " ⚠ xcodegen 未安装,使用 xcodebuild + SPM 模式" + rm -f project.yml +fi + +# 2. Archive +echo "" +echo "--- Step 2: Archive ---" + +if [ -f "$PROJECT_DIR/MiniPlayer.xcodeproj/project.pbxproj" ]; then + # 使用 xcodegen 生成的项目 + SCHEME="MiniPlayer" + PROJECT_FLAG="-project $PROJECT_DIR/MiniPlayer.xcodeproj" +else + # 使用 SPM 自动生成的项目 + SCHEME="MiniPlayeriOS" + PROJECT_FLAG="" +fi + +ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive" +mkdir -p "$BUILD_DIR" + +xcodebuild archive \ + $PROJECT_FLAG \ + -scheme "$SCHEME" \ + -destination "generic/platform=iOS" \ + -archivePath "$ARCHIVE_PATH" \ + -allowProvisioningUpdates \ + 2>&1 | tail -20 + +if [ $? -ne 0 ]; then + echo " ✗ Archive 失败" + echo " 提示: 确保 Team ID 和签名配置正确" + exit 1 +fi + +echo " ✓ Archive 完成" + +# 3. Export .ipa +echo "" +echo "--- Step 3: 导出 .ipa ---" + +# 生成 ExportOptions.plist +cat > "$BUILD_DIR/ExportOptions.plist" << EOF + + + + + method + $EXPORT_METHOD + teamID + $TEAM_ID + compileBitcode + + stripSwiftSymbols + + thinning + <none> + + +EOF + +IPA_OUTPUT="$BUILD_DIR/ipa" +mkdir -p "$IPA_OUTPUT" + +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$IPA_OUTPUT" \ + -exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" \ + -allowProvisioningUpdates \ + 2>&1 | tail -10 + +IPA_FILE=$(find "$IPA_OUTPUT" -name "*.ipa" -type f | head -1) + +if [ -n "$IPA_FILE" ]; then + echo "" + echo "==============================" + echo " iOS 打包完成!" + echo "==============================" + echo " .ipa: $IPA_FILE" + echo " 大小: $(du -h "$IPA_FILE" | cut -f1)" + echo "" + echo " 安装方式:" + echo " - Xcode: Window > Devices > Add App" + echo " - AltStore / SideStore: 直接导入 .ipa" + echo " - TestFlight: 使用 app-store 签名方式" +else + echo " ✗ .ipa 导出失败" + echo " 提示: 检查签名配置和 ExportOptions.plist" +fi + +# 清理临时文件 +rm -f "$PROJECT_DIR/project.yml" diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 0000000..956b1fc --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# ======================================== +# MiniPlayer macOS 打包脚本 +# 生成 .app bundle + .dmg 安装包 +# ======================================== +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="$PROJECT_DIR/.build/release" +APP_NAME="MiniPlayer" +BUNDLE_ID="com.miniplayer.app" +VERSION=$(date +%Y.%m.%d) + +APP_BUNDLE="$PROJECT_DIR/$APP_NAME.app" +DMG_OUTPUT="$PROJECT_DIR/$APP_NAME-$VERSION.dmg" + +echo "=== MiniPlayer macOS 打包 ===" +echo "项目目录: $PROJECT_DIR" +echo "版本: $VERSION" +echo "" + +# 1. 清理旧构建 +echo "[1/6] 清理旧构建..." +rm -rf "$APP_BUNDLE" +rm -f "$DMG_OUTPUT" + +# 2. 编译 Release +echo "[2/6] 编译 Release 版本..." +cd "$PROJECT_DIR" +swift build -c release +echo " ✓ 编译完成" + +# 3. 创建 .app bundle 结构 +echo "[3/6] 创建 .app bundle..." +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources/i18n" +mkdir -p "$APP_BUNDLE/Contents/Resources/zh-Hans.lproj" +mkdir -p "$APP_BUNDLE/Contents/Resources/en.lproj" + +# 4. 拷贝文件 +echo "[4/6] 拷贝文件..." +# 可执行文件 +cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/" + +# 资源文件 +cp "$PROJECT_DIR/Sources/Resources/miniplayer.ui" "$APP_BUNDLE/Contents/Resources/" +cp "$PROJECT_DIR/Sources/Resources/i18n/en.json" "$APP_BUNDLE/Contents/Resources/i18n/" +cp "$PROJECT_DIR/Sources/Resources/i18n/zh.json" "$APP_BUNDLE/Contents/Resources/i18n/" +cp "$PROJECT_DIR/Sources/Resources/zh-Hans.lproj/Localizable.strings" "$APP_BUNDLE/Contents/Resources/zh-Hans.lproj/" +cp "$PROJECT_DIR/Sources/Resources/en.lproj/Localizable.strings" "$APP_BUNDLE/Contents/Resources/en.lproj/" + +# App 图标(如果有) +if [ -f "$PROJECT_DIR/Resources/AppIcon.icns" ]; then + cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/" + ICON_FILE="AppIcon.icns" +else + ICON_FILE="" +fi + +# 5. 生成 Info.plist +echo "[5/6] 生成 Info.plist..." +cat > "$APP_BUNDLE/Contents/Info.plist" < + + + + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + $APP_NAME + CFBundleDisplayName + MiniPlayer + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.entertainment + NSHumanReadableCopyright + Copyright © $(date +%Y) MiniPlayer. All rights reserved. + CFBundleIconFile + ${ICON_FILE:-} + CFBundleResourceSpecification + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + NSCameraUsageDescription + MiniPlayer 需要摄像头权限来扫描二维码添加播放地址。 + NSMicrophoneUsageDescription + MiniPlayer 需要麦克风权限来录制音频。 + + +PLIST + +echo " ✓ .app bundle 创建完成: $APP_BUNDLE" + +# 6. 创建 DMG +echo "[6/6] 创建 DMG 安装包..." +DMG_TEMP="$PROJECT_DIR/.dmg_temp" +DMG_RW="$PROJECT_DIR/.dmg_rw.dmg" +DMG_SIZE="200m" + +mkdir -p "$DMG_TEMP" +cp -R "$APP_BUNDLE" "$DMG_TEMP/" + +# 创建 Applications 快捷方式 +ln -s /Applications "$DMG_TEMP/Applications" + +# 创建临时 DMG +hdiutil create -srcfolder "$DMG_TEMP" -volname "$APP_NAME" \ + -fs HFS+ -fsargs "-c c=64,a=16,e=16" \ + -format UDRW -size "$DMG_SIZE" "$DMG_RW" 2>/dev/null + +# 转换为压缩 DMG +hdiutil convert "$DMG_RW" -format UDZO -imagekey zlib-level=9 -o "$DMG_OUTPUT" 2>/dev/null + +# 清理临时文件 +rm -rf "$DMG_TEMP" +rm -f "$DMG_RW" + +echo " ✓ DMG 创建完成: $DMG_OUTPUT" + +# 显示结果 +echo "" +echo "==============================" +echo " 打包完成!" +echo "==============================" +echo " .app: $APP_BUNDLE" +echo " .dmg: $DMG_OUTPUT" +APP_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1) +DMG_SIZE=$(du -sh "$DMG_OUTPUT" | cut -f1) +echo " .app 大小: $APP_SIZE" +echo " .dmg 大小: $DMG_SIZE" +echo "" +echo " 双击 .app 直接运行" +echo " 双击 .dmg 安装(拖入 Applications)"