tv-anarchy/Sources/TVAnarchyCore/QuickTimeTarget.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

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