Add drag-and-drop file/URL playback and screen recording with audio

This commit is contained in:
yumoqing 2026-06-24 19:33:21 +08:00
parent b929b1eb6e
commit 8af1140b06
3 changed files with 190 additions and 1 deletions

View File

@ -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)

View File

@ -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) }
}

View 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