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>
175 lines
No EOL
6.9 KiB
Swift
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
|
|
} |