96 lines
4.3 KiB
Swift
96 lines
4.3 KiB
Swift
import Foundation
|
|
|
|
/// Local playback through QuickTime Player, driven by AppleScript (`osascript`).
|
|
/// Zero-install (ships with macOS) and genuinely controllable — play/pause, seek,
|
|
/// volume and status all work on `document 1`. There's no playlist, so next/
|
|
/// previous are no-ops, and it plays local file paths only (like VLC here).
|
|
/// VLC/mpv remain the richer options; this is the friendly default.
|
|
public final class QuickTimeTarget: PlayerTarget, MediaLaunchable {
|
|
public let id: String
|
|
public let name: String
|
|
public let kind: HostKind = .quicktime
|
|
public let volumeScale = 100
|
|
public var detail: String { "QuickTime Player (local)" }
|
|
|
|
public init(id: String, name: String) { self.id = id; self.name = name }
|
|
|
|
private static let appName = "QuickTime Player"
|
|
|
|
public func poll() async -> PollResult {
|
|
// One script: "playing|curtime|duration|volume|name", or "IDLE".
|
|
let script = """
|
|
tell application "QuickTime Player"
|
|
if (count documents) is 0 then return "IDLE"
|
|
set d to document 1
|
|
return (playing of d as text) & "|" & (current time of d as text) & "|" & (duration of d as text) & "|" & (audio volume of d as text) & "|" & (name of d)
|
|
end tell
|
|
"""
|
|
guard let out = await osa(script) else { return .unreachable }
|
|
if out == "IDLE" { return PollResult(reachable: true, status: .idle) }
|
|
let f = out.components(separatedBy: "|")
|
|
guard f.count >= 5 else { return PollResult(reachable: true, status: .idle) }
|
|
var s = PlaybackStatus(playing: f[0] == "true")
|
|
s.paused = f[0] != "true"
|
|
s.position = Double(f[1])
|
|
s.duration = Double(f[2])
|
|
if let v = Double(f[3]) { s.volume = v * 100 } // QT volume is 0..1
|
|
s.title = f[4]
|
|
return PollResult(reachable: true, status: s)
|
|
}
|
|
|
|
@discardableResult
|
|
public func launch(_ request: LaunchRequest) async -> Bool {
|
|
guard case let .file(path) = request else { return false } // local file paths only
|
|
// Resolve to a local downloaded copy (no NFS); the player router only sends
|
|
// QuickTime files it has locally, anything else goes to black.
|
|
let uri = MediaPaths.toStreamURL(path)
|
|
let p = uri.hasPrefix("file://") ? String(uri.dropFirst(7)) : uri
|
|
guard FileManager.default.fileExists(atPath: p) else { return false } // not downloaded
|
|
_ = await shell("open -a \(Self.shq(Self.appName)) \(Self.shq(p))")
|
|
return await osa("tell application \"QuickTime Player\" to play document 1") != nil
|
|
}
|
|
|
|
public func playPause() async {
|
|
_ = await osa("""
|
|
tell application "QuickTime Player"
|
|
if (count documents) > 0 then tell document 1 to if playing then pause else play
|
|
end tell
|
|
""")
|
|
}
|
|
public func resume() async { _ = await doc("play document 1") }
|
|
public func setVolume(_ percent: Int) async {
|
|
let v = min(1.0, max(0.0, Double(percent) / 100.0))
|
|
_ = await doc("set audio volume of document 1 to \(v)")
|
|
}
|
|
public func seek(relative seconds: Int) async {
|
|
_ = await doc("set current time of document 1 to ((current time of document 1) + \(seconds))")
|
|
}
|
|
public func seek(toSeconds seconds: Int) async {
|
|
_ = await doc("set current time of document 1 to \(max(0, seconds))")
|
|
}
|
|
public func next() async {} // no playlist in QuickTime
|
|
public func previous() async {}
|
|
public func stop() async { _ = await doc("stop document 1") }
|
|
|
|
// MARK: - osascript helpers
|
|
|
|
private func doc(_ stmt: String) async -> String? {
|
|
await osa("tell application \"QuickTime Player\"\nif (count documents) > 0 then \(stmt)\nend tell")
|
|
}
|
|
|
|
/// Run an AppleScript via a single `-e` argument (newlines allowed inside).
|
|
private func osa(_ script: String) async -> String? {
|
|
await shell("osascript -e \(Self.shq(script))")
|
|
}
|
|
|
|
private func shell(_ command: String) async -> String? {
|
|
let r: ProcessResult = await Task.detached(priority: .utility) {
|
|
ProcessRunner.runShell(command, timeout: 8)
|
|
}.value
|
|
return r.ok ? r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) : nil
|
|
}
|
|
|
|
private static func shq(_ s: String) -> String {
|
|
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
}
|