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
+36
View File
@@ -171,6 +171,41 @@ struct PlayerQualityMenu: View {
}
}
@MainActor
struct PlayerMiruroMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
if !model.miruroVideoProviders.isEmpty {
Menu("Miruro") {
Menu("Video Host") {
Button(model.miruroVideoProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroVideoProvider("")
}
ForEach(model.miruroVideoProviders, id: \.self) { provider in
Button(model.miruroVideoProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroVideoProvider(provider)
}
}
Text("Current: \(model.selectedMiruroVideoProvider ?? "None")")
}
Menu("Caption Provider") {
Button(model.miruroCaptionProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroCaptionProvider("")
}
ForEach(model.miruroCaptionProviders, id: \.self) { provider in
Button(model.miruroCaptionProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroCaptionProvider(provider)
}
}
Text("Current: \(model.selectedMiruroCaptionProvider ?? "None")")
}
}
.disabled(!model.canChangeMiruroProvider)
}
}
}
@MainActor
struct PlayerCommands: Commands {
@FocusedObject private var focusedModel: PlayerModel?
@@ -275,6 +310,7 @@ struct PlayerCommands: Commands {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
}
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo)
}
+130 -31
View File
@@ -41,10 +41,48 @@ enum StreamURL: Sendable {
}
}
struct MiruroSelection: Sendable {
let source: ResolvedSource
let videoProvider: String
let captionProvider: String?
}
struct MiruroSources: Sendable {
fileprivate struct Candidate: Sendable {
let provider: String
let source: ResolvedSource
}
fileprivate let videos: [Candidate]
fileprivate let captions: [Candidate]
let videoProviders: [String]
let captionProviders: [String]
fileprivate init(videos: [Candidate], captions: [Candidate]) {
self.videos = videos
self.captions = captions
videoProviders = videos.map(\.provider)
captionProviders = captions.map(\.provider)
}
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
guard let video = videos.first(where: { $0.provider == videoProvider }) ??
videos.first(where: { $0.provider == "sun" }) ?? videos.first else {
throw StreamResolutionError(message: "Direct Miruro Japanese source is unavailable.")
}
let caption = captions.first(where: { $0.provider == captionProvider }) ??
captions.first(where: { $0.provider == video.provider }) ??
captions.first(where: { $0.provider == "sun" }) ?? captions.first
let source = ResolvedSource(url: video.source.url, headers: video.source.headers,
identity: video.source.identity, title: video.source.title, subtitles: caption?.source.subtitles ?? [])
return MiruroSelection(source: source, videoProvider: video.provider, captionProvider: caption?.provider)
}
}
enum MiruroResolver {
static let bodyLimit = 2 * 1024 * 1024
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
try Task.checkCancellation()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
@@ -100,51 +138,100 @@ enum MiruroResolver {
}
}
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
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.")
var videos: [MiruroSources.Candidate] = []
var captions: [MiruroSources.Candidate] = []
var videoNames = Set<String>()
var captionNames = Set<String>()
// Japanese variants only, preferring soft subtitles independently for each provider.
for variant in ["ssub", "sub"] {
for track in response.tracks.lazy.compactMap(\.value) where track.track == variant {
for provider in track.providers.lazy.compactMap(\.value) {
let name = provider.provider
guard validProviderName(name), let config = response.config[name]?.value,
config.player == "native", !config.proxy,
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue }
let needsVideo = !videoNames.contains(name)
let needsCaptions = !captionNames.contains(name)
guard needsVideo || needsCaptions else { continue }
for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed {
let headers = server.headers ?? [:]
var found = false
for stream in server.streams.lazy.compactMap(\.value) {
guard !stream.hasEmbed,
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: [])) else { continue }
if videoNames.insert(name).inserted {
videos.append(.init(provider: name, source: video))
}
// Caption requests retain this provider/server's headers, never the selected video's.
let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map {
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
label: $0.label, isDefault: $0.default, headers: headers)
}
if needsCaptions, !subtitles.isEmpty,
let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: Array(subtitles))) {
captions.append(.init(provider: name, source: caption))
captionNames.insert(name)
}
found = true
break
}
if found { break }
}
}
}
}
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.")
// API order is advisory; retain response order for names omitted from it.
let order = response.providerOrder ?? []
func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] {
var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) })
var result: [MiruroSources.Candidate] = []
for name in order {
if let candidate = remaining.removeValue(forKey: name) { result.append(candidate) }
}
result.append(contentsOf: candidates.filter { remaining[$0.provider] != nil })
return result
}
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))
let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
_ = try sources.select()
return sources
} catch {
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
}
}
private static func validProviderName(_ name: String) -> Bool {
!name.isEmpty && name.utf8.count <= 128 && name.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
}
}
private struct InvalidProjection: Error {}
// A malformed entry must not discard valid neighboring providers, servers or streams.
private struct Entry<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
}
private struct Sources: Decodable {
let episodeNumber: Int
let tracks: [Track]
let config: Config
let tracks: [Entry<Track>]
let config: [String: Entry<ProviderConfig>]
let providerOrder: [String]?
}
private struct Config: Decodable { let sun: ProviderConfig }
private struct ProviderConfig: Decodable {
let player: String
// Objects describe proxy work this native player cannot perform.
let proxy: Bool
let hls: HLS?
}
@@ -155,16 +242,28 @@ enum MiruroResolver {
}
private struct Track: Decodable {
let track: String
let providers: [Provider]
let providers: [Entry<Provider>]
}
private struct Provider: Decodable {
let provider: String
let servers: [Server]
let subtitles: [Subtitle]
let servers: [Entry<Server>]
let subtitles: [Entry<Subtitle>]
}
private struct Server: Decodable {
let headers: [String: String]?
let streams: [Stream]
let streams: [Entry<Stream>]
let hasEmbed: Bool
enum CodingKeys: String, CodingKey { case headers, streams, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
headers = try values.decodeIfPresent([String: String].self, forKey: .headers)
streams = try values.decode([Entry<Stream>].self, forKey: .streams)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
}
private struct Stream: Decodable {
let url: String
+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") {
@@ -10,14 +10,19 @@ public struct PlayerConfiguration: Equatable, Sendable {
public var automaticQuality: Bool
public var snapToEdges: Bool
public var compactTitleProgress: CompactTitleProgress
public var miruroVideoProvider: String
public var miruroCaptionProvider: String
public var keys: KeyBindings
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) {
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress
self.miruroVideoProvider = miruroVideoProvider
self.miruroCaptionProvider = miruroCaptionProvider
self.keys = keys
}
@@ -28,6 +33,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
var lines = [
"[playback]",
"automatic_quality = \(defaults.automaticQuality)",
"", "[miruro]",
"video_provider = \"\(defaults.miruroVideoProvider)\"",
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
"", "[ui]",
"buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)",
@@ -85,6 +93,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
miruroVideoProvider: values.miruro?.videoProvider ?? "",
miruroCaptionProvider: values.miruro?.captionProvider ?? "",
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
@@ -143,10 +153,11 @@ public struct PlayerConfiguration: Equatable, Sendable {
let playback: PlaybackConfiguration?
let ui: UIConfiguration?
let keys: KeysConfiguration?
let miruro: MiruroConfiguration?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: SettingKey.self)
try validate(container, allowed: ["playback", "ui", "keys"])
try validate(container, allowed: ["playback", "ui", "keys", "miruro"])
if container.contains(SettingKey("playback")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil }
@@ -156,6 +167,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
if container.contains(SettingKey("keys")) {
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
} else { keys = nil }
if container.contains(SettingKey("miruro")) {
miruro = try MiruroConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("miruro")))
} else { miruro = nil }
}
}
@@ -168,6 +182,29 @@ public struct PlayerConfiguration: Equatable, Sendable {
}
}
private struct MiruroConfiguration {
let videoProvider: String?
let captionProvider: String?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["video_provider", "caption_provider"])
videoProvider = try Self.provider("video_provider", in: container)
captionProvider = try Self.provider("caption_provider", in: container)
}
private static func provider(_ name: String, in container: KeyedDecodingContainer<SettingKey>) throws -> String? {
let value = try container.decodeIfPresent(String.self, forKey: SettingKey(name))
if let value {
guard value.utf8.count <= 128, value.utf8.allSatisfy({
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 || $0 == 45
}) else {
throw ConfigurationError(message: "miruro.\(name): Expected an empty string (Auto) or up to 128 ASCII letters, digits, underscores or hyphens.")
}
}
return value
}
}
private struct UIConfiguration {
let bufferInfo: Bool?
let compactSnapToEdges: Bool?