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
+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) {