feat: select Miruro playback and caption providers
This commit is contained in:
+90
-19
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user