Changing the playback display did nothing while a video was playing: routeVlc set the window bounds and re-asserted fullscreen, but a fullscreen VLC window ignores `set bounds` and `set fullscreen mode to true` is a no-op when already true — so the video stayed on the original screen. Drop out of fullscreen first, move the now-normal window onto the target screen, then re-enter fullscreen there. Verified VLC AppleScript automation is reachable (get fullscreen mode → true) so this is the missing step, not a perms issue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
No EOL
4.5 KiB
Swift
125 lines
No EOL
4.5 KiB
Swift
import Foundation
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// Enumerate displays and route local players (VLC / QuickTime) to a screen.
|
|
public enum DisplayService {
|
|
|
|
public static func list() -> [DisplayInfo] {
|
|
#if canImport(AppKit)
|
|
return NSScreen.screens.enumerated().map { i, screen in
|
|
let frame = screen.frame
|
|
let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
|
|
let displayId = num?.uint32Value ?? UInt32(i)
|
|
let name = screen.localizedName
|
|
let isPrimary = screen == NSScreen.main
|
|
let isBuiltIn = name.localizedCaseInsensitiveContains("built-in")
|
|
return DisplayInfo(
|
|
displayId: displayId,
|
|
name: name,
|
|
width: Int(frame.width.rounded()),
|
|
height: Int(frame.height.rounded()),
|
|
isPrimary: isPrimary,
|
|
isBuiltIn: isBuiltIn
|
|
)
|
|
}
|
|
#else
|
|
return []
|
|
#endif
|
|
}
|
|
|
|
/// Persist VLC's fullscreen-output device (`macosx-vdev` pref).
|
|
public static func setVlcOutputDevice(_ displayId: UInt32) {
|
|
_ = ProcessRunner.run(
|
|
"/usr/bin/defaults",
|
|
["write", "org.videolan.vlc", "macosx-vdev", "-int", String(displayId)]
|
|
)
|
|
}
|
|
|
|
/// Route VLC to `display` — video on the target screen plus matching HDMI audio
|
|
/// (not the laptop's default output when the TV is selected).
|
|
@discardableResult
|
|
public static func routeVlc(to display: DisplayInfo) async -> Bool {
|
|
setVlcOutputDevice(display.displayId)
|
|
_ = await AudioOutputService.routeVlc(for: display)
|
|
#if canImport(AppKit)
|
|
guard let screen = screen(for: display) else { return true }
|
|
let (l, t, r, b) = appleScriptBounds(for: screen)
|
|
// A fullscreen VLC window can't be relocated: `set bounds` is ignored while
|
|
// fullscreen and `set fullscreen mode to true` is a no-op when already true,
|
|
// so a live display switch would otherwise leave the video on the old screen.
|
|
// Drop out of fullscreen first, move the (now normal) window onto the target
|
|
// screen, then re-enter fullscreen there.
|
|
let script = """
|
|
tell application "VLC"
|
|
activate
|
|
try
|
|
set fullscreen mode to false
|
|
end try
|
|
delay 0.3
|
|
try
|
|
set bounds of window 1 to {\(l), \(t), \(r), \(b)}
|
|
end try
|
|
delay 0.3
|
|
set fullscreen mode to true
|
|
end tell
|
|
"""
|
|
return await runAppleScript(script)
|
|
#else
|
|
return true
|
|
#endif
|
|
}
|
|
|
|
/// Move QuickTime's front window onto `display` and present fullscreen.
|
|
@discardableResult
|
|
public static func routeQuickTime(to display: DisplayInfo) async -> Bool {
|
|
#if canImport(AppKit)
|
|
guard let screen = screen(for: display) else { return false }
|
|
let (l, t, r, b) = appleScriptBounds(for: screen)
|
|
let script = """
|
|
tell application "QuickTime Player"
|
|
activate
|
|
if (count windows) > 0 then
|
|
set bounds of window 1 to {\(l), \(t), \(r), \(b)}
|
|
try
|
|
tell document 1 to present
|
|
end try
|
|
end if
|
|
end tell
|
|
"""
|
|
return await runAppleScript(script)
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
#if canImport(AppKit)
|
|
public static func screen(for display: DisplayInfo) -> NSScreen? {
|
|
NSScreen.screens.first { s in
|
|
let num = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
|
|
return num?.uint32Value == display.displayId
|
|
}
|
|
}
|
|
|
|
/// AppleScript window bounds `{left, top, right, bottom}` from an NSScreen frame.
|
|
public static func appleScriptBounds(for screen: NSScreen) -> (Int, Int, Int, Int) {
|
|
let f = screen.frame
|
|
let mainH = NSScreen.screens.map(\.frame.maxY).max() ?? f.maxY
|
|
return (
|
|
Int(f.minX.rounded()),
|
|
Int((mainH - f.maxY).rounded()),
|
|
Int(f.maxX.rounded()),
|
|
Int((mainH - f.minY).rounded())
|
|
)
|
|
}
|
|
#endif
|
|
|
|
private static func runAppleScript(_ script: String) async -> Bool {
|
|
let quoted = "'" + script.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
let r: ProcessResult = await Task.detached(priority: .utility) {
|
|
ProcessRunner.runShell("osascript -e \(quoted)", timeout: 12)
|
|
}.value
|
|
return r.ok
|
|
}
|
|
} |