feat: load optional XDG TOML configuration with session overrides
This commit is contained in:
@@ -9,7 +9,6 @@ final class CompactWindowController: NSObject {
|
||||
var snappingEnabled: Bool {
|
||||
didSet {
|
||||
guard snappingEnabled != oldValue else { return }
|
||||
defaults.set(snappingEnabled, forKey: "compactSnappingEnabled")
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
@@ -45,7 +44,6 @@ final class CompactWindowController: NSObject {
|
||||
|
||||
private enum FullScreenTransition { case entering, exiting }
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private weak var window: NSWindow?
|
||||
private var snapshot: Snapshot?
|
||||
private var fullScreenTransition: FullScreenTransition?
|
||||
@@ -57,10 +55,8 @@ final class CompactWindowController: NSObject {
|
||||
private var videoAspect = 16.0 / 9.0
|
||||
private var fittedAspect: Double?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
defaults.register(defaults: ["compactSnappingEnabled": true])
|
||||
snappingEnabled = defaults.bool(forKey: "compactSnappingEnabled")
|
||||
init(snappingEnabled: Bool = true) {
|
||||
self.snappingEnabled = snappingEnabled
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
||||
+28
-11
@@ -1,9 +1,11 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TerebiConfiguration
|
||||
|
||||
@MainActor
|
||||
final class PlayerModel: ObservableObject {
|
||||
static let shared = PlayerModel()
|
||||
static var startupConfiguration = PlayerConfiguration()
|
||||
static let shared = PlayerModel(configuration: startupConfiguration)
|
||||
|
||||
@Published private(set) var state = PlayerState()
|
||||
@Published private(set) var fileName: String?
|
||||
@@ -24,7 +26,7 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var controlsVisible = true
|
||||
@Published private(set) var isFullScreen = false
|
||||
@Published private(set) var isOnTop = false
|
||||
let compactWindow = CompactWindowController()
|
||||
let compactWindow: CompactWindowController
|
||||
var isCompact: Bool { compactWindow.isCompact }
|
||||
var canToggleCompact: Bool {
|
||||
isCompact || compactWindow.isTransitioning ||
|
||||
@@ -51,7 +53,6 @@ final class PlayerModel: ObservableObject {
|
||||
private var youtubeTask: Task<Void, Never>?
|
||||
private var youtubeRequest: UUID?
|
||||
private var resolvingAutomaticQuality = false
|
||||
private let defaults: UserDefaults
|
||||
private let qualityClock: () -> Double
|
||||
private var automaticQuality = AutomaticQuality()
|
||||
private var qualityNow: Double { qualityClock() }
|
||||
@@ -62,11 +63,12 @@ final class PlayerModel: ObservableObject {
|
||||
private var lastSavedPosition: Double?
|
||||
private var systemPlayback: SystemPlaybackControls?
|
||||
|
||||
init(defaults: UserDefaults = .standard, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.defaults = defaults
|
||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
|
||||
showsBufferInfo = defaults.bool(forKey: "showsBufferInfo")
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
showsBufferInfo = configuration.showBufferInfo
|
||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
|
||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||
let player = player_create()
|
||||
handle = player
|
||||
@@ -162,13 +164,11 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func setShowsBufferInfo(_ enabled: Bool) {
|
||||
showsBufferInfo = enabled
|
||||
defaults.set(enabled, forKey: "showsBufferInfo")
|
||||
revealControls()
|
||||
}
|
||||
|
||||
func setAutomaticYouTubeQuality(_ enabled: Bool) {
|
||||
automaticYouTubeQuality = enabled
|
||||
defaults.set(enabled, forKey: "automaticYouTubeQuality")
|
||||
automaticQuality = AutomaticQuality()
|
||||
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
revealControls()
|
||||
@@ -1370,7 +1370,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
|
||||
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
|
||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||
if arguments.count == 2, arguments[0] == "--open-media", let url = URL(string: arguments[1]) {
|
||||
if let index = arguments.firstIndex(of: "--open-media"), index + 1 < arguments.count,
|
||||
let url = URL(string: arguments[index + 1]) {
|
||||
PlayerModel.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -1448,8 +1449,24 @@ struct TerebiApp: App {
|
||||
|
||||
#if !PLAYER_CHECK
|
||||
@_cdecl("terebi_app_main")
|
||||
#endif
|
||||
@MainActor
|
||||
public func terebiAppMain() {
|
||||
do {
|
||||
PlayerModel.startupConfiguration = try PlayerConfiguration.loadForApplication(
|
||||
arguments: Array(CommandLine.arguments.dropFirst()),
|
||||
environment: ProcessInfo.processInfo.environment,
|
||||
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
|
||||
NSApplication.shared.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Could not load terebi configuration"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
exit(78)
|
||||
}
|
||||
TerebiApp.main()
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,12 +2,12 @@ import Foundation
|
||||
|
||||
enum PlayerCommand {
|
||||
case help
|
||||
case launch(URL?, newInstance: Bool)
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [FILE | YOUTUBE_URL]
|
||||
terebi [--new-instance] --url YOUTUBE_URL
|
||||
terebi [--new-instance] -- FILE
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
||||
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
|
||||
Open one local media file or YouTube video and start playback, resuming saved
|
||||
@@ -15,6 +15,7 @@ enum PlayerCommand {
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video URL.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
-h, --help Show this help without launching the player.
|
||||
@@ -26,13 +27,23 @@ enum PlayerCommand {
|
||||
|
||||
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
||||
var inputs = arguments
|
||||
let newInstance = inputs.first == "--new-instance"
|
||||
if newInstance { inputs.removeFirst() }
|
||||
if inputs.isEmpty { return .launch(nil, newInstance: newInstance) }
|
||||
var newInstance = false
|
||||
var noConfig = false
|
||||
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
||||
if option == "--new-instance" {
|
||||
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
|
||||
newInstance = true
|
||||
} else {
|
||||
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
|
||||
noConfig = true
|
||||
}
|
||||
inputs.removeFirst()
|
||||
}
|
||||
if inputs.isEmpty { return .launch(nil, newInstance: newInstance, noConfig: noConfig) }
|
||||
if inputs == ["--help"] || inputs == ["-h"] { return .help }
|
||||
if inputs.first == "--url" {
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance)
|
||||
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance, noConfig: noConfig)
|
||||
}
|
||||
let filenameOnly = inputs.first == "--"
|
||||
if filenameOnly {
|
||||
@@ -52,7 +63,7 @@ enum PlayerCommand {
|
||||
}
|
||||
file = url
|
||||
} else {
|
||||
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance)
|
||||
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance, noConfig: noConfig)
|
||||
}
|
||||
} else {
|
||||
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
@@ -62,7 +73,7 @@ enum PlayerCommand {
|
||||
FileManager.default.isReadableFile(atPath: file.path) else {
|
||||
throw ArgumentError("File does not exist or is not a readable regular file.")
|
||||
}
|
||||
return .launch(file.standardizedFileURL, newInstance: newInstance)
|
||||
return .launch(file.standardizedFileURL, newInstance: newInstance, noConfig: noConfig)
|
||||
}
|
||||
|
||||
private struct ArgumentError: LocalizedError {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import TerebiConfiguration
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
@@ -14,7 +15,16 @@ enum TerebiCLI {
|
||||
switch command {
|
||||
case .help:
|
||||
print(PlayerCommand.usage)
|
||||
case .launch(let url, let newInstance):
|
||||
case .launch(let url, let newInstance, let noConfig):
|
||||
let configURL = PlayerConfiguration.fileURL(environment: ProcessInfo.processInfo.environment,
|
||||
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
||||
if !noConfig {
|
||||
do {
|
||||
_ = try PlayerConfiguration.load(from: configURL)
|
||||
} catch {
|
||||
fail(error.localizedDescription, code: 78)
|
||||
}
|
||||
}
|
||||
guard let executable = Bundle.main.executableURL else {
|
||||
fail("Could not locate the CLI executable.", code: 69)
|
||||
}
|
||||
@@ -25,8 +35,10 @@ enum TerebiCLI {
|
||||
}
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.createsNewApplicationInstance = newInstance
|
||||
// LaunchServices does not inherit the invoking shell's XDG environment.
|
||||
configuration.arguments = noConfig ? ["--no-config"] : ["--config-file", configURL.absoluteString]
|
||||
if newInstance, let url {
|
||||
configuration.arguments = ["--open-media", url.absoluteString]
|
||||
configuration.arguments += ["--open-media", url.absoluteString]
|
||||
}
|
||||
do {
|
||||
// Launch an application, not a document, so SwiftUI creates its singleton window.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import TOMLDecoder
|
||||
|
||||
public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var snapToEdges: Bool
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.snapToEdges = snapToEdges
|
||||
}
|
||||
|
||||
public static func fileURL(environment: [String: String], homeDirectory: URL) -> URL {
|
||||
let directory: URL
|
||||
if let path = environment["XDG_CONFIG_HOME"], path.hasPrefix("/") {
|
||||
directory = URL(fileURLWithPath: path, isDirectory: true)
|
||||
} else {
|
||||
directory = homeDirectory.appendingPathComponent(".config", isDirectory: true)
|
||||
}
|
||||
return directory.appendingPathComponent("terebi/config.toml")
|
||||
}
|
||||
|
||||
public static func loadForApplication(arguments: [String], environment: [String: String],
|
||||
homeDirectory: URL) throws -> Self {
|
||||
if arguments.contains("--no-config") { return Self() }
|
||||
if let index = arguments.firstIndex(of: "--config-file") {
|
||||
guard index + 1 < arguments.count, let url = URL(string: arguments[index + 1]), url.isFileURL,
|
||||
url.host == nil || url.host == "" || url.host == "localhost" else {
|
||||
throw ConfigurationError(message: "--config-file requires a local file URL.")
|
||||
}
|
||||
return try load(from: url)
|
||||
}
|
||||
return try load(from: fileURL(environment: environment, homeDirectory: homeDirectory))
|
||||
}
|
||||
|
||||
public static func load(from url: URL) throws -> Self {
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
let failure = error as NSError
|
||||
let underlying = failure.userInfo[NSUnderlyingErrorKey] as? NSError
|
||||
if failure.domain == NSCocoaErrorDomain, failure.code == CocoaError.fileReadNoSuchFile.rawValue,
|
||||
underlying?.domain == NSPOSIXErrorDomain, underlying?.code == Int(ENOENT) {
|
||||
return Self()
|
||||
}
|
||||
throw ConfigurationError(message: "\(url.path): \(error.localizedDescription)")
|
||||
}
|
||||
do {
|
||||
let values = try TOMLDecoder(isLenient: false).decode([String: Bool].self, from: data)
|
||||
for key in values.keys {
|
||||
switch key {
|
||||
case "show_buffer_info", "automatic_quality", "snap_to_edges": break
|
||||
default: throw ConfigurationError(message: "Unknown setting '\(key)'.")
|
||||
}
|
||||
}
|
||||
var configuration = Self()
|
||||
configuration.showBufferInfo = values["show_buffer_info"] ?? configuration.showBufferInfo
|
||||
configuration.automaticQuality = values["automatic_quality"] ?? configuration.automaticQuality
|
||||
configuration.snapToEdges = values["snap_to_edges"] ?? configuration.snapToEdges
|
||||
return configuration
|
||||
} catch {
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error))")
|
||||
}
|
||||
}
|
||||
|
||||
private static func diagnostic(for error: Error) -> String {
|
||||
let context: DecodingError.Context
|
||||
switch error {
|
||||
case DecodingError.dataCorrupted(let value), DecodingError.typeMismatch(_, let value),
|
||||
DecodingError.valueNotFound(_, let value), DecodingError.keyNotFound(_, let value): context = value
|
||||
case let error as ConfigurationError: return error.message
|
||||
default: return String(describing: error)
|
||||
}
|
||||
if let underlying = context.underlyingError { return String(describing: underlying) }
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return path.isEmpty ? context.debugDescription : "\(path): \(context.debugDescription)"
|
||||
}
|
||||
|
||||
private struct ConfigurationError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user