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>
93 lines
4.1 KiB
Swift
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: "'\\''") + "'"
|
|
}
|
|
}
|