MiniPlayer/Sources/PlayerRecorder.swift
yumoqing d22a5037b8 feat: iOS/macOS 双平台打包支持
- Package.swift: 重构为 MiniPlayerCore(共享库) + MiniPlayer(macOS) + MiniPlayeriOS(iOS) 三 target
- 所有源文件添加 #if os(macOS) / #if os(iOS) 条件编译
- HLSRecorder.swift: iOS 纯 Swift HLS 录制器(下载TS+合并+AVAssetExportSession转MP4)
- scripts/build-macos.sh: macOS 打包脚本 (.app + .dmg)
- scripts/build-ios.sh: iOS 打包脚本 (xcodegen + xcodebuild archive → .ipa)
- .gitignore: 排除构建产物
2026-07-01 08:29:16 +08:00

169 lines
6.1 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 Foundation
#if os(macOS)
import StreamRecorderKit
import AppKit
// MARK: - RecorderState退
enum RecorderState {
static nonisolated(unsafe) var isFinalizing: Bool = false
static nonisolated(unsafe) var pendingTerminate: Bool = false
}
// MARK: - PlayerRecorder StreamRecorderKit FFmpegRecorder
/// 使 FFmpeg URL 使 AVAssetWriter + MTAudioProcessingTap
///
/// - /
/// - 883 ~150
/// - AVPlayerItemVideoOutput / copyPixelBuffer
/// -
@MainActor
final class PlayerRecorder: NSObject {
private var recorder: FFmpegRecorder?
private var recordingTask: Task<Void, Never>?
private var durationTimer: Timer?
private var startDate: Date?
@Published var isRecording = false
@Published var durationText = "00:00"
var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)?
private var saveDirectory: URL {
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: -
func startRecording(url: URL) {
guard !isRecording else { return }
let recorder = FFmpegRecorder()
self.recorder = recorder
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
let tempURL = saveDirectory.appendingPathComponent(tempFilename)
try? FileManager.default.removeItem(at: tempURL)
isRecording = true
startDate = Date()
startDurationTimer()
NSLog("[Recorder] Starting FFmpeg recording: %@ → %@", url.lastPathComponent, tempFilename)
recordingTask = Task { [weak self] in
do {
try await recorder.start(url: url, outputPath: tempURL.path, duration: 0)
// duration=0 stop()
// start stop()
NSLog("[Recorder] ✓ Recording completed: %@", tempURL.path)
await MainActor.run {
RecorderState.isFinalizing = false
self?.stopDurationTimer()
self?.showSaveDialog(tempURL: tempURL)
}
} catch {
NSLog("[Recorder] ✗ Recording failed: %@", error.localizedDescription)
await MainActor.run {
RecorderState.isFinalizing = false
self?.stopDurationTimer()
//
let attrs = try? FileManager.default.attributesOfItem(atPath: tempURL.path)
let fileSize = (attrs?[.size] as? Int64) ?? 0
NSLog("[Recorder] File exists: %d, size: %lld", FileManager.default.fileExists(atPath: tempURL.path), fileSize)
if fileSize > 0 {
NSLog("[Recorder] File exists with size %lld, offering save dialog", fileSize)
self?.showSaveDialog(tempURL: tempURL)
} else {
self?.onError?(error.localizedDescription)
try? FileManager.default.removeItem(at: tempURL)
self?.checkPendingTerminate()
}
}
}
}
}
// MARK: -
func stopRecording() {
guard isRecording else { return }
isRecording = false
RecorderState.isFinalizing = true
stopDurationTimer()
NSLog("[Recorder] Stopping recording...")
recorder?.stop()
}
// MARK: -
private func startDurationTimer() {
startDate = Date()
durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.updateDuration()
}
}
}
private func stopDurationTimer() {
durationTimer?.invalidate()
durationTimer = nil
}
private func updateDuration() {
guard let start = startDate else { return }
let elapsed = Int(Date().timeIntervalSince(start))
let min = elapsed / 60
let sec = elapsed % 60
durationText = String(format: "%02d:%02d", min, sec)
}
// MARK: -
private func showSaveDialog(tempURL: URL) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.mpeg4Movie]
// temp_
let defaultName = tempURL.lastPathComponent.replacingOccurrences(of: "temp_", with: "")
panel.nameFieldStringValue = defaultName
panel.directoryURL = saveDirectory
panel.begin { [weak self] response in
Task { @MainActor in
if response == .OK, let url = panel.url {
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
try FileManager.default.moveItem(at: tempURL, to: url)
NSLog("[Recorder] ✓ Saved: %@", url.path)
self?.onRecordingSaved?(url)
} catch {
NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription)
self?.onError?("Save failed: \(error.localizedDescription)")
}
} else {
try? FileManager.default.removeItem(at: tempURL)
}
self?.checkPendingTerminate()
}
}
}
private func checkPendingTerminate() {
if RecorderState.pendingTerminate {
RecorderState.pendingTerminate = false
NSApp.reply(toApplicationShouldTerminate: true)
}
}
}
#endif