tv-anarchy/Sources/TVAnarchyCore/QuickTimeTarget.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename
the auto-commit service couldn't stage — it git-add'd the old, now-gone
paths and aborted every cycle), and commits the accumulated work:

- Library: black-built index fast path (LibraryIndex + scanFromIndex) with
  NFS-walk fallback; incremental --add on download-complete; mtime staleness
  gate; loose-file series-collapse fix; determinate scan/index progress.
- Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year).
- Player: sleep timer (timed + end-of-episode); visibility-gated polling.
- Home: Continue Watching cover art + live refresh; Recently Added; adult gate.
- Logs: multi-line selection + copy; truncated giant tx-list errors.
- Hover previews (opt-in) via black ffmpeg + scp.

Also gitignores foreign project trees (governor/mcp/fleet/recommender) that
sit in this directory but belong to their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:04:22 -07:00

93 lines
4.1 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
let p = path.hasPrefix("file://") ? String(path.dropFirst(7)) : path
guard FileManager.default.fileExists(atPath: p) else { return false } // unmounted/missing
_ = 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: "'\\''") + "'"
}
}