feat: add independent CLI launches and preserve shared video bookmarks
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user