feat: open streaming URLs without changing resolved handoffs
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user