tv-anarchy/Sources/TVAnarchyCore/Display/AudioOutputService.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

175 lines
No EOL
6.9 KiB
Swift

import Foundation
#if canImport(CoreAudio)
import CoreAudio
#endif
/// Enumerate macOS audio outputs and route VLC's selected device to match the
/// playback display (HDMI TV when video is on the TV, laptop speakers otherwise).
public enum AudioOutputService {
public static func list() -> [AudioOutputInfo] {
#if canImport(CoreAudio)
return enumerateCoreAudio()
#else
return []
#endif
}
/// Resolve the sink that pairs with `display`.
public static func pick(for display: DisplayInfo) -> AudioOutputInfo? {
AudioOutputInfo.pick(for: display, from: list())
}
/// Point VLC's audio output at `device` updates vlcrc for the next launch
/// and clicks the live Audio > Audio Device menu when VLC is running.
@discardableResult
public static func routeVlc(to device: AudioOutputInfo) async -> Bool {
persistVlcAudioDevice(device.deviceId)
return await selectVlcAudioMenuItem(device.name)
}
/// Route VLC audio to whatever sink pairs with `display`.
@discardableResult
public static func routeVlc(for display: DisplayInfo) async -> Bool {
guard let device = pick(for: display) else { return false }
return await routeVlc(to: device)
}
// MARK: vlcrc
static var vlcrcURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Preferences/org.videolan.vlc/vlcrc")
}
/// Rewrite `auhal-audio-device` under the `[auhal]` section.
public static func persistVlcAudioDevice(_ deviceId: UInt32) {
let path = vlcrcURL.path
guard let text = try? String(contentsOfFile: path, encoding: .utf8),
let next = rewriteVlcrcAudioDevice(text, deviceId: deviceId) else { return }
try? next.write(toFile: path, atomically: true, encoding: .utf8)
}
/// Pure vlcrc rewrite unit-tested without touching the real VLC config.
public static func rewriteVlcrcAudioDevice(_ text: String, deviceId: UInt32) -> String? {
let line = "auhal-audio-device=\(deviceId)"
var lines = text.components(separatedBy: "\n")
var inAuhal = false
var replaced = false
for i in lines.indices {
if lines[i].hasPrefix("[auhal]") { inAuhal = true; continue }
if inAuhal, lines[i].hasPrefix("["), !lines[i].hasPrefix("[#") { inAuhal = false }
guard inAuhal, lines[i].hasPrefix("auhal-audio-device=") else { continue }
lines[i] = line
replaced = true
break
}
if !replaced, let idx = lines.firstIndex(where: { $0.hasPrefix("[auhal]") }) {
lines.insert(line, at: idx + 1)
replaced = true
}
guard replaced else { return nil }
return lines.joined(separator: "\n")
}
// MARK: live VLC menu
private static func selectVlcAudioMenuItem(_ name: String) async -> Bool {
#if canImport(AppKit)
guard !name.isEmpty else { return false }
let escaped = name
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = """
tell application "System Events"
if not (exists process "VLC") then return false
tell process "VLC"
set frontmost to true
set audioMenu to menu "Audio" of menu bar item "Audio" of menu bar 1
set deviceItem to menu item "Audio Device" of audioMenu
set deviceMenu to menu 1 of deviceItem
if not (exists menu item "\(escaped)" of deviceMenu) then return false
click menu item "\(escaped)" of deviceMenu
end tell
end tell
return true
"""
return await runAppleScriptBool(script)
#else
return false
#endif
}
#if canImport(AppKit)
private static func runAppleScriptBool(_ 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
guard r.ok else { return false }
return r.stdout.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"
}
#endif
// MARK: CoreAudio
#if canImport(CoreAudio)
private static func enumerateCoreAudio() -> [AudioOutputInfo] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var sz: UInt32 = 0
guard AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &sz) == noErr,
sz > 0 else { return [] }
let count = Int(sz) / MemoryLayout<AudioDeviceID>.size
var ids = [AudioDeviceID](repeating: 0, count: count)
guard AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &sz, &ids) == noErr else { return [] }
return ids.compactMap { deviceInfo($0) }
}
private static func deviceInfo(_ id: AudioDeviceID) -> AudioOutputInfo? {
guard hasOutputChannels(id) else { return nil }
guard let name = deviceName(id) else { return nil }
return AudioOutputInfo(
deviceId: id,
name: name,
isBuiltIn: AudioOutputInfo.inferBuiltIn(name: name))
}
private static func deviceName(_ id: AudioDeviceID) -> String? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceNameCFString,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var cfName: CFString?
var sz = UInt32(MemoryLayout<CFString?>.size)
let err = withUnsafeMutablePointer(to: &cfName) { ptr in
AudioObjectGetPropertyData(id, &addr, 0, nil, &sz, ptr)
}
guard err == noErr, let cfName else { return nil }
return cfName as String
}
private static func hasOutputChannels(_ id: AudioDeviceID) -> Bool {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeOutput,
mElement: 0)
var sz: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &sz) == noErr, sz > 0 else { return false }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(sz),
alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
var dataSz = sz
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &dataSz, raw) == noErr else { return false }
let list = raw.assumingMemoryBound(to: AudioBufferList.self).pointee
return list.mNumberBuffers > 0
}
#endif
}