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: "'\\''") + "'" } }