tv-anarchy/Sources/TVAnarchyCore/Display/DisplayService.swift
Natalie 82ed75cd08 fix(display): relocate a fullscreen VLC window when the output display changes
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>
2026-06-30 02:56:32 -04:00

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
}
}