Add drag-and-drop file/URL playback and screen recording with audio
This commit is contained in:
parent
b929b1eb6e
commit
8af1140b06
@ -192,6 +192,27 @@ struct PlayerContentView: View {
|
||||
.onTapGesture {
|
||||
bridge.togglePlayPause()
|
||||
}
|
||||
.onDrop(of: [.fileURL, .text], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
// 尝试作为文件 URL
|
||||
provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { data, _ in
|
||||
guard let data = data as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
|
||||
DispatchQueue.main.async {
|
||||
bridge.addItem(url: url, name: url.lastPathComponent, type: "file")
|
||||
}
|
||||
}
|
||||
// 尝试作为文本 URL (http/https)
|
||||
provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in
|
||||
guard let data = data as? Data, let text = String(data: data, encoding: .utf8),
|
||||
let url = URL(string: text), url.scheme == "http" || url.scheme == "https" else { return }
|
||||
DispatchQueue.main.async {
|
||||
let name = url.lastPathComponent.isEmpty ? (url.host ?? text) : url.lastPathComponent
|
||||
bridge.addItem(url: url, name: name, type: "url")
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 左上角应用图标 — 点击切换控制栏
|
||||
MiniPlayerIcon()
|
||||
@ -335,6 +356,13 @@ struct ControlToolbar: View {
|
||||
|
||||
// 播放列表
|
||||
TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() }
|
||||
|
||||
Divider().frame(height: 20).padding(.horizontal, 4)
|
||||
|
||||
// 录制
|
||||
TB(icon: bridge.screenRecorder.isRecording ? "stop.circle.fill" : "record.circle") {
|
||||
bridge.toggleRecording(); onTouch()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@ -84,6 +84,7 @@ final class PlayerBridge: ObservableObject {
|
||||
private var fullscreenWindow: NSWindow?
|
||||
private var playlistWindow: NSWindow?
|
||||
private var fullscreenEventMonitor: Any?
|
||||
let screenRecorder = ScreenRecorder()
|
||||
#endif
|
||||
|
||||
// MARK: - 初始化
|
||||
@ -92,6 +93,26 @@ final class PlayerBridge: ObservableObject {
|
||||
|
||||
setupTimeObserver()
|
||||
setupEndObserver()
|
||||
|
||||
#if os(macOS)
|
||||
screenRecorder.onRecordingStopped = { [weak self] url in
|
||||
self?.showToast("Saved: \(url.lastPathComponent)")
|
||||
}
|
||||
screenRecorder.onError = { [weak self] msg in
|
||||
self?.showToast(msg)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - 录制
|
||||
func toggleRecording() {
|
||||
#if os(macOS)
|
||||
if screenRecorder.isRecording {
|
||||
screenRecorder.stopRecording()
|
||||
} else {
|
||||
screenRecorder.startRecording()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - 播放控制
|
||||
@ -358,7 +379,7 @@ final class PlayerBridge: ObservableObject {
|
||||
addItem(url: url, name: name, type: type)
|
||||
}
|
||||
|
||||
private func addItem(url: URL, name: String, type: String) {
|
||||
func addItem(url: URL, name: String, type: String) {
|
||||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
|
||||
if queue.count == 1 { playIndex(0) }
|
||||
}
|
||||
|
||||
140
Sources/ScreenRecorder.swift
Normal file
140
Sources/ScreenRecorder.swift
Normal file
@ -0,0 +1,140 @@
|
||||
import AVFoundation
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// 屏幕录制器:录制屏幕 + 麦克风音频,保存到 ~/Movies/MiniPlayer/
|
||||
@MainActor
|
||||
final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||
|
||||
private var session: AVCaptureSession?
|
||||
private var fileOutput: AVCaptureMovieFileOutput?
|
||||
private var isConfigured = false
|
||||
|
||||
@Published var isRecording = false
|
||||
var onRecordingStopped: ((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
|
||||
}
|
||||
|
||||
/// 开始录制
|
||||
func startRecording() {
|
||||
guard !isRecording else { return }
|
||||
|
||||
// 首次配置 session(避免每次重建)
|
||||
if !isConfigured {
|
||||
guard configureSession() else { return }
|
||||
isConfigured = true
|
||||
}
|
||||
|
||||
guard let fileOutput = fileOutput else {
|
||||
onError?("Recording not ready")
|
||||
return
|
||||
}
|
||||
|
||||
// 生成文件名:录制_2026-06-24_15-30-45.mp4
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||
let filename = "Recording_\(formatter.string(from: Date())).mp4"
|
||||
let outputURL = saveDirectory.appendingPathComponent(filename)
|
||||
|
||||
// 启动 session 并开始录制
|
||||
if let session = session, !session.isRunning {
|
||||
session.startRunning()
|
||||
}
|
||||
|
||||
fileOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||
}
|
||||
|
||||
/// 停止录制
|
||||
func stopRecording() {
|
||||
guard isRecording, let fileOutput = fileOutput else { return }
|
||||
fileOutput.stopRecording()
|
||||
}
|
||||
|
||||
private func configureSession() -> Bool {
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .hd1920x1080
|
||||
|
||||
// 屏幕输入(主显示器)
|
||||
guard let displayID = NSScreen.main?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
|
||||
onError?("No display found")
|
||||
return false
|
||||
}
|
||||
|
||||
let screenInput = AVCaptureScreenInput(displayID: displayID)
|
||||
guard let screenInput = screenInput else {
|
||||
onError?("Cannot create screen input")
|
||||
return false
|
||||
}
|
||||
screenInput.capturesMouseClicks = false
|
||||
screenInput.capturesCursor = true
|
||||
|
||||
if session.canAddInput(screenInput) {
|
||||
session.addInput(screenInput)
|
||||
} else {
|
||||
onError?("Cannot add screen input")
|
||||
return false
|
||||
}
|
||||
|
||||
// 麦克风输入
|
||||
if let audioDevice = AVCaptureDevice.default(for: .audio),
|
||||
let audioInput = try? AVCaptureDeviceInput(device: audioDevice) {
|
||||
if session.canAddInput(audioInput) {
|
||||
session.addInput(audioInput)
|
||||
}
|
||||
}
|
||||
// 没有麦克风也能录制(只有画面)
|
||||
|
||||
// 文件输出
|
||||
let output = AVCaptureMovieFileOutput()
|
||||
if session.canAddOutput(output) {
|
||||
session.addOutput(output)
|
||||
} else {
|
||||
onError?("Cannot add file output")
|
||||
return false
|
||||
}
|
||||
|
||||
self.session = session
|
||||
self.fileOutput = output
|
||||
|
||||
// 请求屏幕录制权限(macOS 10.15+)
|
||||
// CGPreflightScreenCaptureAccess 在首次调用时触发权限弹窗
|
||||
#if canImport(ScreenCaptureKit)
|
||||
CGPreflightScreenCaptureAccess()
|
||||
#endif
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - AVCaptureFileOutputRecordingDelegate
|
||||
nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) {
|
||||
Task { @MainActor in
|
||||
self.isRecording = true
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||||
Task { @MainActor in
|
||||
self.isRecording = false
|
||||
if let error = error {
|
||||
self.onError?("Recording failed: \(error.localizedDescription)")
|
||||
} else {
|
||||
self.onRecordingStopped?(outputFileURL)
|
||||
}
|
||||
// 停止 session 释放资源
|
||||
self.session?.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
session?.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Loading…
x
Reference in New Issue
Block a user