feat: select Miruro playback and caption providers

This commit is contained in:
2026-10-22 18:45:14 +02:00
parent 4576080955
commit 80d9e705e5
10 changed files with 561 additions and 76 deletions
+90 -19
View File
@@ -121,6 +121,20 @@ final class PlayerModel: ObservableObject {
@Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false
@Published private(set) var showsBufferInfo = false
@Published private(set) var miruroVideoProvider: String
@Published private(set) var miruroCaptionProvider: String
@Published private(set) var selectedMiruroVideoProvider: String?
@Published private(set) var selectedMiruroCaptionProvider: String?
private var miruroSources: MiruroSources? {
if case .resolved(_, let providers) = currentMedia { return providers }
return nil
}
var miruroVideoProviders: [String] { miruroSources?.videoProviders ?? [] }
var miruroCaptionProviders: [String] { miruroSources?.captionProviders ?? [] }
var canChangeMiruroProvider: Bool {
castDevice == nil && miruroSources != nil && canTogglePlayback &&
!isSeeking && !transition.seekPending && !state.seek_pending
}
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
@Published private(set) var selectedSubtitle: Int?
@Published private(set) var subtitleText = ""
@@ -188,7 +202,7 @@ final class PlayerModel: ObservableObject {
private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
@@ -198,7 +212,7 @@ final class PlayerModel: ObservableObject {
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
@@ -212,6 +226,8 @@ final class PlayerModel: ObservableObject {
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
playbackProgress = PlaybackProgress(defaults: defaults)
recentMedia = playbackProgress.recentMedia
@@ -404,11 +420,13 @@ final class PlayerModel: ObservableObject {
title = video.title
source = .youtube(video, height: nil)
case .miruro(let mediaID, let episode):
let resolved = try await miruroResolver(mediaID, episode)
let providers = try await miruroResolver(mediaID, episode)
let resolved = try providers.select(videoProvider: miruroVideoProvider,
captionProvider: miruroCaptionProvider).source
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
title = resolved.title
source = .resolved(resolved)
source = .resolved(resolved, providers: providers)
}
try Task.checkCancellation()
guard canOpen else { throw CancellationError() }
@@ -578,6 +596,38 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change)
}
func setMiruroVideoProvider(_ provider: String) {
guard canChangeMiruroProvider, let handle,
provider.isEmpty || miruroVideoProviders.contains(provider),
provider != miruroVideoProvider,
case .resolved(let source, _) = currentMedia else { return }
let identity = source.identity.split(separator: ":")
guard identity.count == 3, let episode = Int(identity[2]) else { return }
miruroVideoProvider = provider
let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
resolveMiruro(mediaID: String(identity[1]), episode: episode, change: change)
}
func setMiruroCaptionProvider(_ provider: String) {
guard canChangeMiruroProvider, let providers = miruroSources,
provider.isEmpty || miruroCaptionProviders.contains(provider) else { return }
do {
let selection = try providers.select(videoProvider: selectedMiruroVideoProvider,
captionProvider: provider)
let language = selectedSubtitle.flatMap { subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil }
miruroCaptionProvider = provider
selectedMiruroCaptionProvider = selection.captionProvider
currentMedia = .resolved(selection.source, providers: providers)
subtitleTracks = selection.source.subtitles
let index = language.flatMap { language in subtitleTracks.firstIndex { $0.language == language } ??
subtitleTracks.firstIndex(where: \.isDefault) }
selectSubtitle(index)
revealControls()
} catch {
commandError = error.localizedDescription
}
}
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
@@ -594,22 +644,25 @@ final class PlayerModel: ObservableObject {
commandError = nil
revealControls()
let resolver = miruroResolver
let videoProvider = miruroVideoProvider
let captionProvider = miruroCaptionProvider
let task = Task { [weak self] in
do {
let source = try await resolver(mediaID, episode)
let providers = try await resolver(mediaID, episode)
let selection = try providers.select(videoProvider: videoProvider, captionProvider: captionProvider)
let source = selection.source
guard source.identity == "miruro:\(mediaID):\(episode)" else {
throw CocoaError(.fileReadCorruptFile)
}
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)
self.openMedia(.resolved(source, providers: providers), 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()
self.failStreamChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
@@ -652,7 +705,7 @@ final class PlayerModel: ObservableObject {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.failYouTubeChange(change, error: error)
self.failStreamChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
@@ -664,7 +717,7 @@ final class PlayerModel: ObservableObject {
transition.cancelResolution()
}
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
private func failStreamChange(_ change: MediaChange, error: Error? = nil) {
if change.automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
@@ -791,7 +844,7 @@ final class PlayerModel: ObservableObject {
}
}
}
case .resolved(let source):
case .resolved(let source, _):
accepted = source.identity.withCString { identity in
source.url.withCString { url in
source.headers.withCString { headers in
@@ -808,9 +861,9 @@ final class PlayerModel: ObservableObject {
case .file:
commandError = "Could not queue the file to open. Please try again."
case .resolved:
commandError = "Could not queue the resolved stream to open."
failStreamChange(change, error: CocoaError(.fileReadUnknown))
case .youtube:
failYouTubeChange(change)
failStreamChange(change)
}
return
}
@@ -819,6 +872,15 @@ final class PlayerModel: ObservableObject {
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false
currentMedia = source
if case .resolved(_, let providers) = source,
let selection = try? providers?.select(videoProvider: miruroVideoProvider,
captionProvider: miruroCaptionProvider) {
selectedMiruroVideoProvider = selection.videoProvider
selectedMiruroCaptionProvider = selection.captionProvider
} else {
selectedMiruroVideoProvider = nil
selectedMiruroCaptionProvider = nil
}
shouldRecordRecentMedia = !change.isQuality
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
@@ -834,7 +896,7 @@ final class PlayerModel: ObservableObject {
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
case .resolved(let source):
case .resolved(let source, _):
fileName = source.title
fileIcon = nil
youtubeHeights = []
@@ -844,10 +906,16 @@ final class PlayerModel: ObservableObject {
if let fileName, !source.url.isFileURL, let id = queue.currentID {
queue.updateLabel(fileName, for: id)
}
let previousSubtitleLanguage = selectedSubtitle.flatMap {
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
}
selectSubtitle(nil)
if case .resolved(let resolved) = source {
if case .resolved(let resolved, _) = source {
subtitleTracks = resolved.subtitles
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex
} : defaultIndex)
} else {
subtitleTracks = []
}
@@ -1010,6 +1078,8 @@ final class PlayerModel: ObservableObject {
queueCompletionHandled = true
queueCastDevice = nil
currentMedia = nil
selectedMiruroVideoProvider = nil
selectedMiruroCaptionProvider = nil
replaceMetadata(for: nil)
shouldRecordRecentMedia = false
selectSubtitle(nil)
@@ -1818,7 +1888,7 @@ final class PlayerModel: ObservableObject {
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
local: false, restricted: false, live: video.isLive,
duration: duration, subtitles: [])
case .resolved(let resolved):
case .resolved(let resolved, _):
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
live: false, duration: duration, subtitles: resolved.subtitles)
@@ -1860,13 +1930,13 @@ final class PlayerModel: ObservableObject {
private enum MediaSource {
case file(URL)
case youtube(YouTubeVideo, height: Int?)
case resolved(ResolvedSource)
case resolved(ResolvedSource, providers: MiruroSources? = nil)
var url: URL {
switch self {
case .file(let url): return url
case .youtube(let video, _): return video.webpageURL
case .resolved(let source): return URL(string: source.identity)!
case .resolved(let source, _): return URL(string: source.identity)!
}
}
var live: Bool {
@@ -2940,6 +3010,7 @@ private struct PlayerContent: View {
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider()
PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model)
Divider()
if model.isCompact {
Menu("Subtitles") {