feat: add independent CLI launches and preserve shared video bookmarks

This commit is contained in:
2026-10-11 12:47:08 +02:00
parent 537e8e2624
commit 0dce073287
10 changed files with 254 additions and 40 deletions
+14 -8
View File
@@ -2,28 +2,34 @@ import Foundation
struct PlaybackProgress {
private let defaults: UserDefaults
private var positions: [String: Double]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] ?? [:]
// Migrate the old shared dictionary once; separate keys prevent cross-player lost updates.
if let positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] {
for (identity, position) in positions where position.isFinite && position > 0 {
let key = "playbackPosition:" + identity
if defaults.object(forKey: key) == nil { defaults.set(position, forKey: key) }
}
defaults.removeObject(forKey: "playbackPositions")
}
}
func position(for url: URL) -> Double? {
guard let position = positions[key(for: url)], position.isFinite, position > 0 else { return nil }
guard let position = defaults.object(forKey: key(for: url)) as? Double,
position.isFinite, position > 0 else { return nil }
return position
}
mutating func save(_ position: Double?, for url: URL) {
func save(_ position: Double?, for url: URL) {
let key = key(for: url)
let position = position.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
guard positions[key] != position else { return }
positions[key] = position
defaults.set(positions, forKey: "playbackPositions")
guard (defaults.object(forKey: key) as? Double) != position else { return }
defaults.set(position, forKey: key)
}
private func key(for url: URL) -> String {
// YouTube callers supply the canonical webpage URL, never an expiring media URL.
return url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString
return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString)
}
}
+5 -1
View File
@@ -55,7 +55,7 @@ final class PlayerModel: ObservableObject {
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private var playbackProgress: PlaybackProgress
private let playbackProgress: PlaybackProgress
private var progressURL: URL?
private var resumePosition: Double?
private var resumePlaying = true
@@ -1369,6 +1369,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
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]) {
PlayerModel.shared.open(url)
}
}
// Let SwiftUI create the initial window for a Finder/document launch.
+21 -17
View File
@@ -2,34 +2,38 @@ import Foundation
enum PlayerCommand {
case help
case launch(URL?)
case launch(URL?, newInstance: Bool)
static let usage = """
Usage: terebi [FILE | YOUTUBE_URL]
terebi --url YOUTUBE_URL
terebi -- FILE
Usage: terebi [--new-instance] [FILE | YOUTUBE_URL]
terebi [--new-instance] --url YOUTUBE_URL
terebi [--new-instance] -- FILE
terebi --help
Open one local media file or YouTube video and start playback, resuming saved
video progress when available. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters.
--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.
--new-instance Launch a separate player, leaving existing playback untouched.
--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.
Reuses the running player when available. The command exits once macOS accepts
the launch; playback and extraction errors appear in the player window.
Reuses the running player unless --new-instance is supplied before the media
arguments. The command exits once macOS accepts the launch; playback and
extraction errors appear in the player window.
"""
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
if arguments.isEmpty { return .launch(nil) }
if arguments == ["--help"] || arguments == ["-h"] { return .help }
if arguments.first == "--url" {
guard arguments.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return .launch(try YouTubeResolver.validateURL(arguments[1]))
}
var inputs = arguments
let newInstance = inputs.first == "--new-instance"
if newInstance { inputs.removeFirst() }
if inputs.isEmpty { return .launch(nil, newInstance: newInstance) }
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)
}
let filenameOnly = inputs.first == "--"
if filenameOnly {
inputs.removeFirst()
@@ -48,7 +52,7 @@ enum PlayerCommand {
}
file = url
} else {
return .launch(try YouTubeResolver.validateURL(input))
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance)
}
} else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
@@ -58,7 +62,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)
return .launch(file.standardizedFileURL, newInstance: newInstance)
}
private struct ArgumentError: LocalizedError {
+7 -3
View File
@@ -14,7 +14,7 @@ enum TerebiCLI {
switch command {
case .help:
print(PlayerCommand.usage)
case .launch(let url):
case .launch(let url, let newInstance):
guard let executable = Bundle.main.executableURL else {
fail("Could not locate the CLI executable.", code: 69)
}
@@ -24,10 +24,14 @@ enum TerebiCLI {
fail(error, code: 69)
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.createsNewApplicationInstance = newInstance
if newInstance, let url {
configuration.arguments = ["--open-media", url.absoluteString]
}
do {
// Launch without media first so SwiftUI creates its singleton window.
// Launch an application, not a document, so SwiftUI creates its singleton window.
_ = try await NSWorkspace.shared.openApplication(at: app, configuration: configuration)
if let url {
if !newInstance, let url {
_ = try await NSWorkspace.shared.open([url], withApplicationAt: app, configuration: configuration)
}
} catch {