feat: open streaming URLs without changing resolved handoffs

This commit is contained in:
2026-10-19 19:32:26 +02:00
parent d068c52e04
commit 9f1dca3081
17 changed files with 553 additions and 103 deletions
+5 -5
View File
@@ -94,8 +94,8 @@ extension PlayerModel {
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openYoutube).contains(chord) {
return perform(.openYoutube)
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
return perform(.openStream)
}
let responder = window.firstResponder
if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false }
@@ -200,9 +200,9 @@ struct PlayerCommands: Commands {
.disabled(!model.canPerform(.openFile))
Button("Add to Queue…", action: model.addQueuePanel)
.disabled(!model.canOpen)
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube))
Button("Open Stream…") { model.performMenuAction(.openStream) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openStream).first?.keyboardShortcut)
.disabled(!model.canPerform(.openStream))
Button("Close File") { model.performMenuAction(.closeFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
+38 -21
View File
@@ -12,6 +12,15 @@ struct ResolvedSubtitle: Decodable, Sendable {
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
}
fileprivate var validatedHeaders: [String: String] { headerFields }
init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String]) {
self.url = url
self.format = format
self.language = language
self.label = label
self.isDefault = isDefault
self.headerFields = headers
}
private enum CodingKeys: String, CodingKey {
case url, format, language, label
case isDefault = "default"
@@ -26,7 +35,7 @@ struct ResolvedSource: Sendable {
let title: String
let subtitles: [ResolvedSubtitle]
private struct Document: Decodable {
struct Document: Decodable {
struct Identity: Decodable {
let provider: String
let media_id: String
@@ -62,29 +71,37 @@ struct ResolvedSource: Sendable {
let data = try handle.read(upToCount: 1_048_577) ?? Data()
guard data.count <= 1_048_576 else { throw InvalidSource() }
let document = try JSONDecoder().decode(Document.self, from: data)
let id = document.identity
guard document.version == 1, id.provider == "miruro",
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
id.episode > 0, id.episode <= Int(Int32.max),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32,
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
}
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers,
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
subtitles: document.subtitles)
return try validated(document)
} catch { throw InvalidSource() }
}
static func validIdentity(mediaID: String, episode: Int) -> Bool {
!mediaID.isEmpty && mediaID.utf8.count <= 512 &&
mediaID.utf8.allSatisfy({ isIDByte($0) }) &&
episode > 0 && episode <= Int(Int32.max)
}
static func validated(_ document: Document) throws -> ResolvedSource {
let id = document.identity
guard document.version == 1, id.provider == "miruro",
validIdentity(mediaID: id.media_id, episode: id.episode),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32,
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
}
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers,
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
subtitles: document.subtitles)
}
private static func isIDByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95
+199
View File
@@ -0,0 +1,199 @@
import Foundation
private struct StreamResolutionError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
enum StreamURL: Sendable {
case youtube(URL)
case miruro(mediaID: String, episode: Int)
static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.")
guard !text.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
}), let components = URLComponents(string: text),
components.scheme?.lowercased() == "https",
components.host?.lowercased() == "barelystarted.miruro.tv",
components.user == nil, components.password == nil,
components.port == nil || components.port == 443,
components.fragment == nil,
components.percentEncodedPath == components.path,
components.url != nil else { throw invalid }
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
guard path.count == 4, path[0].isEmpty, path[1] == "watch",
!path[3].isEmpty, path[3].utf8.count <= 512,
path[3].utf8.allSatisfy({ isSegmentByte($0) }),
let query = components.queryItems, query.count == 1, query[0].name == "ep",
let number = query[0].value, let episode = Int(number),
components.percentEncodedQuery == "ep=\(episode)",
ResolvedSource.validIdentity(mediaID: String(path[2]), episode: episode) else { throw invalid }
return .miruro(mediaID: String(path[2]), episode: episode)
}
private static func isSegmentByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95
}
}
enum MiruroResolver {
static let bodyLimit = 2 * 1024 * 1024
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
try Task.checkCancellation()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
}
var components = URLComponents(string: "https://barelystarted.miruro.tv/api/sources")!
components.queryItems = [URLQueryItem(name: "id", value: mediaID), URLQueryItem(name: "n", value: String(episode))]
var request = URLRequest(url: components.url!)
request.timeoutInterval = 25
request.setValue("application/json", forHTTPHeaderField: "Accept")
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.timeoutIntervalForResource = 25
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: MiruroRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
do {
let (bytes, response) = try await session.bytes(for: request)
try validateResponse(response)
var data = Data()
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < bodyLimit else {
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
}
data.append(byte)
}
try Task.checkCancellation()
return try decode(data, mediaID: mediaID, episode: episode)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro request failed or timed out. No retry was attempted.")
}
}
static func validateResponse(_ response: URLResponse) throws {
guard let response = response as? HTTPURLResponse else {
throw StreamResolutionError(message: "Miruro returned an invalid HTTP response.")
}
guard response.statusCode == 200 else {
throw StreamResolutionError(message: "Miruro HTTP \(response.statusCode). No retry was attempted.")
}
guard response.mimeType?.lowercased() == "application/json" else {
throw StreamResolutionError(message: "Miruro returned an unexpected content type; protection pages are not supported.")
}
guard response.expectedContentLength <= Int64(bodyLimit) else {
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
}
}
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
do {
guard data.count <= bodyLimit,
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
let response = try JSONDecoder().decode(Sources.self, from: data)
guard response.episodeNumber == episode else { throw InvalidProjection() }
let config = response.config.sun
guard config.player == "native", !config.proxy,
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
}
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
.providers.first(where: { $0.provider == "sun" }),
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
let stream = server.streams.first else {
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
}
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
!stream.hasEmbed else { throw InvalidProjection() }
let headers = server.headers ?? [:]
var subtitles: [ResolvedSubtitle] = []
for subtitle in provider.subtitles where subtitle.format == "vtt" {
guard subtitles.count < 32 else { throw InvalidProjection() }
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
label: subtitle.label, isDefault: subtitle.default, headers: headers))
}
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: subtitles))
} catch {
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
}
}
private struct InvalidProjection: Error {}
private struct Sources: Decodable {
let episodeNumber: Int
let tracks: [Track]
let config: Config
}
private struct Config: Decodable { let sun: ProviderConfig }
private struct ProviderConfig: Decodable {
let player: String
let proxy: Bool
let hls: HLS?
}
private struct HLS: Decodable {
struct Rule: Decodable { let enabled: Bool? }
let segments: Rule?
let query: Rule?
}
private struct Track: Decodable {
let track: String
let providers: [Provider]
}
private struct Provider: Decodable {
let provider: String
let servers: [Server]
let subtitles: [Subtitle]
}
private struct Server: Decodable {
let headers: [String: String]?
let streams: [Stream]
}
private struct Stream: Decodable {
let url: String
let format: String
let hasEmbed: Bool
enum CodingKeys: String, CodingKey { case url, format, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
url = try values.decode(String.self, forKey: .url)
format = try values.decode(String.self, forKey: .format)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
}
private struct Subtitle: Decodable {
let file: String
let format: String
let language: String
let label: String
let `default`: Bool
}
}
private final class MiruroRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
+70 -34
View File
@@ -58,7 +58,7 @@ final class PlayerModel: ObservableObject {
private var currentMedia: MediaSource?
private var shouldRecordRecentMedia = false
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving }
var resolvingStream: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
var isNetworkMedia: Bool { transition.isNetwork }
@Published private(set) var youtubeHeights: [Int] = []
@@ -216,7 +216,7 @@ final class PlayerModel: ObservableObject {
func cast(to device: CastDevice, playing: Bool? = nil) {
guard canCast, let source = currentMedia, let handle else { return }
cancelYouTubeResolution()
cancelStreamResolution()
commandError = nil
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
@@ -294,11 +294,11 @@ final class PlayerModel: ObservableObject {
}
func openYouTube(_ text: String) {
func openStream(_ text: String) {
guard canOpen else { return }
let url: URL
let stream: StreamURL
do {
url = try YouTubeResolver.validateURL(text)
stream = try StreamURL.parse(text)
} catch {
commandError = error.localizedDescription
revealControls()
@@ -306,14 +306,17 @@ final class PlayerModel: ObservableObject {
}
showsYouTubeInput = false
clearQueue()
resolveYouTube(url, height: nil)
switch stream {
case .youtube(let url): resolveYouTube(url, height: nil)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode)
}
}
func openResolvedSource(_ file: URL) {
do {
let source = try ResolvedSource.load(from: file)
clearQueue()
cancelYouTubeResolution()
cancelStreamResolution()
openMedia(.resolved(source))
} catch {
commandError = error.localizedDescription
@@ -362,7 +365,7 @@ final class PlayerModel: ObservableObject {
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
automaticQuality = AutomaticQuality()
if !enabled && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if !enabled && transition.resolvingAutomaticQuality { cancelStreamResolution() }
revealControls()
}
@@ -381,10 +384,43 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change)
}
private func resolveMiruro(mediaID: String, episode: Int) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
cancelStreamResolution()
automaticQuality = AutomaticQuality()
guard player_pause(handle) else {
commandError = "Could not pause the current media to open the stream. Please try again."
revealControls()
return
}
objectWillChange.send()
let request = transition.beginResolution(.open)
systemPlayback?.update()
commandError = nil
revealControls()
let task = Task { [weak self] in
do {
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.openMedia(.resolved(source), change: change)
} catch {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard self.transition.finishResolution(request) != nil else { return }
self.commandError = error.localizedDescription
self.revealControls()
}
}
transition.attachResolution(task, request: request)
}
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
cancelYouTubeResolution()
cancelStreamResolution()
if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
@@ -422,7 +458,7 @@ final class PlayerModel: ObservableObject {
transition.attachResolution(task, request: request)
}
private func cancelYouTubeResolution() {
private func cancelStreamResolution() {
guard transition.isResolving else { return }
objectWillChange.send()
transition.cancelResolution()
@@ -545,7 +581,7 @@ final class PlayerModel: ObservableObject {
}
func open(_ url: URL) {
if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) }
if url.isFileURL { openFiles([url]) } else { openStream(url.absoluteString) }
}
func openFiles(_ urls: [URL]) {
@@ -595,7 +631,7 @@ final class PlayerModel: ObservableObject {
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
queuePlaybackRevision = nil
queueCompletionHandled = true
cancelYouTubeResolution()
cancelStreamResolution()
openMedia(.file(url), change: .queue(playing: receiver == nil))
queueCastDevice = receiver
}
@@ -634,7 +670,7 @@ final class PlayerModel: ObservableObject {
return accepted
}
guard let handle, canTogglePlayback else { return false }
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing && transition.resolvingAutomaticQuality { cancelStreamResolution() }
if !playing { automaticQuality.resetHealth() }
switch transition.setPlayback(playing, status: state.status, handle: handle) {
case .unchanged:
@@ -651,7 +687,7 @@ final class PlayerModel: ObservableObject {
}
func finishSeeking() {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
@@ -669,7 +705,7 @@ final class PlayerModel: ObservableObject {
guard let handle, canClose else { return }
saveProgress()
if castDevice != nil { stopCasting(resume: false) }
cancelYouTubeResolution()
cancelStreamResolution()
automaticQuality = AutomaticQuality()
guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again."
@@ -705,7 +741,7 @@ final class PlayerModel: ObservableObject {
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth()
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
@@ -890,7 +926,7 @@ final class PlayerModel: ObservableObject {
return window != nil && !isFullScreen && fullScreenIntent == nil &&
!isCompact && compactIntent != true && !compactWindow.isTransitioning &&
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
case .openFile, .openYoutube: return canOpen
case .openFile, .openStream: return canOpen
case .closeFile: return canClose
case .quit: return true
case .showBufferInfo: return !showsBufferInfo
@@ -948,7 +984,7 @@ final class PlayerModel: ObservableObject {
window?.level = isOnTop ? .floating : .normal
revealControls()
case .openFile: openPanel()
case .openYoutube: showsYouTubeInput.toggle()
case .openStream: showsYouTubeInput.toggle()
case .closeFile: closeFile()
case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true)
@@ -1013,7 +1049,7 @@ final class PlayerModel: ObservableObject {
}
func beginSeeking() {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth()
isSeeking = true
revealControls()
@@ -1185,7 +1221,7 @@ final class PlayerModel: ObservableObject {
saveProgress()
if castDevice != nil { stopCasting(resume: false) }
castDiscovery.stop()
cancelYouTubeResolution()
cancelStreamResolution()
selectSubtitle(nil)
systemPlayback?.shutdown()
systemPlayback = nil
@@ -2118,13 +2154,13 @@ private struct PlayerContent: View {
}
private func toggleYouTubeInput() {
model.perform(.openYoutube)
model.perform(.openStream)
}
private func startScreen(in size: CGSize) -> some View {
let sidebarWidth = min(260, max(112, size.width * 0.28))
let openKeys = model.keyBindings.shortcuts(for: .openFile)
let urlKeys = model.keyBindings.shortcuts(for: .openYoutube)
let urlKeys = model.keyBindings.shortcuts(for: .openStream)
let latest = model.recentMedia.first
let resumePosition = latest.flatMap { model.savedPosition(for: $0.url) }
return HStack(spacing: 0) {
@@ -2164,14 +2200,14 @@ private struct PlayerContent: View {
.accessibilityLabel("Open File…")
Button(action: toggleYouTubeInput) {
HStack {
Text("Open URL…")
Text("Open Stream…")
Spacer(minLength: 8)
Text((urlKeys.first { $0.modifiers.contains(.command) } ?? urlKeys.first)?.displayText ?? "")
.foregroundStyle(.secondary)
}
}
.buttonStyle(StartScreenButtonStyle())
.accessibilityLabel("Open YouTube URL…")
.accessibilityLabel("Open Stream…")
if showsYouTubeInput {
startURLInput.padding(.horizontal, 14)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
@@ -2226,20 +2262,20 @@ private struct PlayerContent: View {
private var startURLInput: some View {
HStack(spacing: 8) {
TextField("YouTube video URL", text: $youTubeURL,
prompt: Text("YouTube video URL")
TextField("Stream URL", text: $youTubeURL,
prompt: Text("Stream URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain)
.focused($youTubeInputFocused)
.accessibilityLabel("YouTube video URL")
.accessibilityLabel("Stream URL")
.onAppear { youTubeInputFocused = true }
.onSubmit { model.openYouTube(youTubeURL) }
Button { model.openYouTube(youTubeURL) } label: {
.onSubmit { model.openStream(youTubeURL) }
Button { model.openStream(youTubeURL) } label: {
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Open YouTube Video")
.help("Open YouTube Video")
.accessibilityLabel("Open Stream")
.help("Open Stream")
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.font(.system(size: 12))
@@ -2369,7 +2405,7 @@ private struct PlayerContent: View {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider()
PlayerQualityMenu(model: model)
@@ -2870,7 +2906,7 @@ private struct PlayerContent: View {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…"))
(model.resolvingStream ? "Resolving stream…" : "Opening…"))
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -3125,7 +3161,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
PlayerModel.shared.openYouTube(text)
PlayerModel.shared.openStream(text)
}
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
+19 -9
View File
@@ -7,21 +7,21 @@ enum PlayerCommand {
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
static let usage = """
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
terebi [--new-instance] [--no-config] --url STREAM_URL
terebi --new-instance --url STREAM_URL --progress-file PATH
terebi --new-instance --source-file PATH [--progress-file PATH]
terebi [--new-instance] [--no-config] -- FILE
terebi --help
terebi --default-config
Open one local file, YouTube video or resolved source and start playback,
Open one local file, YouTube video, Miruro episode or resolved source and start playback,
resuming saved video progress. With no arguments, open or activate the player.
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.
--url URL Open a YouTube video or Miruro episode URL.
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
--progress-file PATH Write JSONL playback facts to a new private file; requires
--new-instance. The GUI owns reporting after this CLI exits.
@@ -71,6 +71,16 @@ enum PlayerCommand {
}
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
}
func streamURL(_ text: String) throws -> URL {
switch try StreamURL.parse(text) {
case .youtube(let url): return url
case .miruro:
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw ArgumentError("Invalid stream URL.")
}
return url
}
}
if inputs.isEmpty { return try launch(nil) }
if inputs == ["--help"] || inputs == ["-h"] {
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") }
@@ -94,8 +104,8 @@ enum PlayerCommand {
noConfig: noConfig, progressFile: progressFile)
}
if inputs.first == "--url" {
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return try launch(YouTubeResolver.validateURL(inputs[1]))
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one stream URL.") }
return try launch(streamURL(inputs[1]))
}
let filenameOnly = inputs.first == "--"
if filenameOnly {
@@ -104,7 +114,7 @@ enum PlayerCommand {
throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.")
}
guard inputs.count == 1, let input = inputs.first, !input.isEmpty else {
throw ArgumentError("Expected exactly one file or YouTube URL.")
throw ArgumentError("Expected exactly one file or stream URL.")
}
let file: URL
if !filenameOnly && input.contains("://") {
@@ -115,7 +125,7 @@ enum PlayerCommand {
}
file = url
} else {
return try launch(YouTubeResolver.validateURL(input))
return try launch(streamURL(input))
}
} else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
+1 -1
View File
@@ -83,7 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"],
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
]
return texts.mapValues { $0.map { try! KeyChord($0) } }
+2 -2
View File
@@ -4,7 +4,7 @@ public enum PlayerAction: Hashable, Sendable {
case stepFrameForward, stepFrameBackward
case enterFullscreen, exitFullscreen, enterCompact, exitCompact
case enableAlwaysOnTop, disableAlwaysOnTop
case openFile, openYoutube, closeFile, quit
case openFile, openStream, closeFile, quit
case showBufferInfo, hideBufferInfo
case enableCompactSnapping, disableCompactSnapping
case enableAutomaticQuality, disableAutomaticQuality
@@ -19,7 +19,7 @@ public enum PlayerAction: Hashable, Sendable {
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
("open_file", .openFile), ("open_youtube", .openYoutube), ("close_file", .closeFile), ("quit", .quit),
("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),