feat: show optional SponsorBlock ranges on playback timeline

This commit is contained in:
2026-10-25 03:01:40 +01:00
parent edb17f76e3
commit b04ed7fdc8
9 changed files with 666 additions and 24 deletions
+23
View File
@@ -206,6 +206,28 @@ struct PlayerMiruroMenu: View {
}
}
@MainActor
struct PlayerSponsorBlockMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
Menu("SponsorBlock") {
Toggle("Show Sponsored Ranges", isOn: Binding(
get: { model.sponsorBlockEnabled },
set: { model.setSponsorBlockEnabled($0) }))
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
Text("Reduces disclosure; not anonymous. Display only, never skips.")
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
Menu("Acknowledgements") {
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
Link("CC BY-NC-SA 4.0 License",
destination: URL(string: "https://creativecommons.org/licenses/by-nc-sa/4.0/")!)
Text("Display filters and merges ranges; does not modify source timings.")
}
}
}
}
@MainActor
struct PlayerCommands: Commands {
@FocusedObject private var focusedModel: PlayerModel?
@@ -311,6 +333,7 @@ struct PlayerCommands: Commands {
}
}
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
PlayerSponsorBlockMenu(model: model)
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo)
}
+144
View File
@@ -0,0 +1,144 @@
import Foundation
import CryptoKit
struct SponsorSegment: Sendable {
let start: Double
let end: Double
let videoDuration: Double
static func visibleRanges(_ segments: [SponsorSegment], duration: Double) -> [ClosedRange<Double>] {
guard duration.isFinite, duration > 0 else { return [] }
let ranges = segments.compactMap { segment -> ClosedRange<Double>? in
guard segment.start.isFinite, segment.end.isFinite,
segment.start >= 0, segment.end > segment.start, segment.end <= duration,
segment.videoDuration.isFinite, segment.videoDuration >= 0,
segment.videoDuration == 0 || abs(segment.videoDuration - duration) < 1 else { return nil }
return segment.start...segment.end
}.sorted { $0.lowerBound < $1.lowerBound }
var merged: [ClosedRange<Double>] = []
for range in ranges {
if let previous = merged.last, range.lowerBound <= previous.upperBound {
merged[merged.count - 1] = previous.lowerBound...max(previous.upperBound, range.upperBound)
} else {
merged.append(range)
}
}
return merged
}
}
private enum SponsorBlockError: LocalizedError {
case invalidVideoID, invalidResponse, status(Int), tooLarge, malformedJSON
var errorDescription: String? {
switch self {
case .invalidVideoID: return "SponsorBlock requires a canonical YouTube video ID."
case .invalidResponse: return "SponsorBlock returned an invalid response."
case .status(let status): return "SponsorBlock returned HTTP \(status)."
case .tooLarge: return "SponsorBlock returned too much data."
case .malformedJSON: return "SponsorBlock returned malformed segment data."
}
}
}
private final class SponsorBlockRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
enum SponsorBlockResolver {
static let responseLimit = 2 * 1024 * 1024
private static func validID(_ id: String) -> Bool {
id.utf8.count == 11 && id.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
}
}
static func videoID(for url: URL) -> String? {
let prefix = "https://www.youtube.com/watch?v="
let text = url.absoluteString
guard text.hasPrefix(prefix) else { return nil }
let id = String(text.dropFirst(prefix.count))
return validID(id) ? id : nil
}
static func request(videoID: String) throws -> URLRequest {
guard validID(videoID) else { throw SponsorBlockError.invalidVideoID }
let prefix = SHA256.hash(data: Data(videoID.utf8)).prefix(2).map { String(format: "%02x", $0) }.joined()
let url = URL(string: "https://sponsor.ajay.app/api/skipSegments/\(prefix)?category=sponsor&actionType=skip&service=YouTube")!
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10)
request.httpMethod = "GET"
request.httpShouldHandleCookies = false
return request
}
static func resolve(videoID: String, configuration: URLSessionConfiguration? = nil) async throws -> [SponsorSegment] {
let request = try request(videoID: videoID)
let configuration = (configuration?.copy() as? URLSessionConfiguration) ?? .ephemeral
configuration.httpCookieStorage = nil
configuration.httpShouldSetCookies = false
configuration.urlCredentialStorage = nil
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
configuration.httpAdditionalHeaders = nil
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 15
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
try Task.checkCancellation()
let (bytes, response) = try await session.bytes(for: request, delegate: SponsorBlockRedirectPolicy())
try Task.checkCancellation()
guard let response = response as? HTTPURLResponse else { throw SponsorBlockError.invalidResponse }
if response.statusCode == 404 { return [] }
guard response.statusCode == 200 else { throw SponsorBlockError.status(response.statusCode) }
guard response.expectedContentLength <= Int64(responseLimit) else { throw SponsorBlockError.tooLarge }
var data = Data()
data.reserveCapacity(min(responseLimit, max(0, Int(response.expectedContentLength))))
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < responseLimit else { throw SponsorBlockError.tooLarge }
data.append(byte)
}
try Task.checkCancellation()
return try decode(data, videoID: videoID)
}
static func decode(_ data: Data, videoID: String) throws -> [SponsorSegment] {
guard data.count <= responseLimit else { throw SponsorBlockError.tooLarge }
let buckets: [Lossy<Bucket>]
do { buckets = try JSONDecoder().decode([Lossy<Bucket>].self, from: data) }
catch { throw SponsorBlockError.malformedJSON }
return buckets.compactMap(\.value).filter { $0.videoID == videoID }.flatMap { bucket in
bucket.segments.compactMap { row in
guard let row = row.value, row.category == "sponsor", row.actionType == "skip",
row.segment.count == 2,
row.segment[0].isFinite, row.segment[1].isFinite,
row.segment[0] >= 0, row.segment[1] > row.segment[0],
row.videoDuration.isFinite, row.videoDuration >= 0 else { return nil }
return SponsorSegment(start: row.segment[0], end: row.segment[1], videoDuration: row.videoDuration)
}
}
}
private struct Lossy<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
}
private struct Bucket: Decodable {
let videoID: String
let segments: [Lossy<Row>]
}
private struct Row: Decodable {
let category: String
let actionType: String
let segment: [Double]
let videoDuration: Double
}
}
+132 -15
View File
@@ -29,7 +29,11 @@ final class PlayerModel: ObservableObject {
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
streamQueue: startupStreamQueue)
@Published private(set) var state = PlayerState()
@Published private(set) var state = PlayerState() {
didSet {
if state.duration != oldValue.duration { updateSponsorRanges() }
}
}
@Published private(set) var fileName: String?
@Published var showsYouTubeInput = false
@Published private(set) var fileIcon: NSImage?
@@ -71,6 +75,18 @@ final class PlayerModel: ObservableObject {
private var shouldRecordRecentMedia = false
private var metadataLifecycle = MetadataLifecycle()
private var metadataTask: Task<Void, Never>?
@Published private(set) var sponsorBlockEnabled: Bool
private var sponsorVideoID: String?
private var sponsorGeneration: UInt64 = 0
private var sponsorTask: Task<Void, Never>?
private var sponsorSegments: [SponsorSegment] = []
private var sponsorRanges: [ClosedRange<Double>] = []
var sponsoredRanges: [ClosedRange<Double>] {
guard sponsorBlockEnabled, !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
case .youtube(let video, _) = currentMedia, !video.isLive,
SponsorBlockResolver.videoID(for: video.webpageURL) == sponsorVideoID else { return [] }
return sponsorRanges
}
var visibleIntervals: [MiruroInterval] {
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
@@ -222,6 +238,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 sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
private var reportingErrorMessage: String {
@@ -232,6 +249,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) },
sponsorResolver: @escaping @MainActor (String) async throws -> [SponsorSegment] = { try await SponsorBlockResolver.resolve(videoID: $0) },
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 },
@@ -240,12 +258,14 @@ final class PlayerModel: ObservableObject {
self.skipClock = skipClock
self.youtubeResolver = youtubeResolver
self.youtubeTitleResolver = youtubeTitleResolver
self.sponsorResolver = sponsorResolver
self.miruroResolver = miruroResolver
self.miruroMetadataResolver = miruroMetadataResolver
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
sponsorBlockEnabled = configuration.sponsorBlock
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
@@ -716,6 +736,7 @@ final class PlayerModel: ObservableObject {
return
}
objectWillChange.send()
replaceSponsorMetadata(videoID: SponsorBlockResolver.videoID(for: url), reusing: change.isQuality)
let request = transition.beginResolution(change)
systemPlayback?.update()
commandError = nil
@@ -818,7 +839,46 @@ final class PlayerModel: ObservableObject {
queueMetadataTask = (item.id, task)
}
func setSponsorBlockEnabled(_ enabled: Bool) {
guard sponsorBlockEnabled != enabled else { return }
sponsorBlockEnabled = enabled
replaceSponsorMetadata(videoID: sponsorVideoID)
}
private func updateSponsorRanges() {
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
}
private func replaceSponsorMetadata(videoID: String?, reusing: Bool = false) {
if reusing, sponsorVideoID == videoID { return }
sponsorTask?.cancel()
sponsorTask = nil
sponsorGeneration &+= 1
sponsorVideoID = videoID
sponsorSegments = []
updateSponsorRanges()
guard sponsorBlockEnabled, let videoID else { return }
let generation = sponsorGeneration
let resolver = sponsorResolver
sponsorTask = Task { [weak self] in
// Optional enrichment never changes the playback resolution/error state.
let segments = (try? await resolver(videoID)) ?? []
guard !Task.isCancelled, let self,
self.sponsorVideoID == videoID, self.sponsorGeneration == generation else { return }
self.objectWillChange.send()
self.sponsorSegments = segments
self.updateSponsorRanges()
self.sponsorTask = nil
}
}
private func replaceMetadata(for source: MediaSource?) {
if case .youtube(let video, _) = source {
replaceSponsorMetadata(videoID: video.isLive ? nil : SponsorBlockResolver.videoID(for: video.webpageURL),
reusing: true)
} else {
replaceSponsorMetadata(videoID: nil)
}
metadataTask?.cancel()
metadataTask = nil
if source?.url.absoluteString != metadataLifecycle.identity {
@@ -2271,6 +2331,7 @@ private enum SunsetPalette {
static let actionFill = accent.withAlphaComponent(0.08)
static let track = secondaryText.withAlphaComponent(0.18)
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
static let sponsor = NSColor(srgbRed: 220.0 / 255, green: 20.0 / 255, blue: 60.0 / 255, alpha: 1)
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
}
@@ -2354,9 +2415,11 @@ private struct PlaybackSlider: NSViewRepresentable {
var enabled = true
var bufferedRange: ClosedRange<Double>?
var boundaries: [Double] = []
var sponsoredRanges: [ClosedRange<Double>] = []
struct Hover: Equatable {
let time: Double
let x: CGFloat
var sponsored = false
}
var onHover: (Hover?) -> Void = { _ in }
var onEditingChanged: (Bool) -> Void = { _ in }
@@ -2394,6 +2457,11 @@ private struct PlaybackSlider: NSViewRepresentable {
cell.bufferedRange = bufferedRange
slider.needsDisplay = true
}
if cell.sponsoredRanges != sponsoredRanges {
cell.sponsoredRanges = sponsoredRanges
slider.needsDisplay = true
DispatchQueue.main.async { [weak slider] in slider?.refreshHover() }
}
}
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
}
@@ -2464,7 +2532,13 @@ private struct PlaybackSlider: NSViewRepresentable {
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
let middle = (lower + upper) / 2
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width))
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width,
sponsored: cell.sponsoredRanges.contains { $0.lowerBound <= time && time < $0.upperBound }))
}
func refreshHover() {
guard hover != nil, let window else { return }
updateHover(at: convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
override func mouseEntered(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
@@ -2557,6 +2631,7 @@ private struct PlaybackSlider: NSViewRepresentable {
var volume = false
var bufferedRange: ClosedRange<Double>?
var boundaries: [Double] = []
var sponsoredRanges: [ClosedRange<Double>] = []
var emphasis: [Double] = []
func segment(at time: Double) -> Int {
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
@@ -2609,6 +2684,15 @@ private struct PlaybackSlider: NSViewRepresentable {
SunsetPalette.bufferedTrack.setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
if !volume, maxValue > minValue {
// Reuse the chapter-shaped clip: sponsorship paints the track, never adds gaps.
SunsetPalette.sponsor.setFill()
let scale = bar.width / (maxValue - minValue)
for range in sponsoredRanges {
NSRect(x: bar.minX + (range.lowerBound - minValue) * scale, y: bar.minY - 1.5,
width: (range.upperBound - range.lowerBound) * scale, height: 6).fill()
}
}
}
override func drawKnob(_ rect: NSRect) {
let knob = visibleKnobRect(rect)
@@ -2656,6 +2740,28 @@ func playbackSliderHitCheck() -> Bool {
NSGraphicsContext.restoreGraphicsState()
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
bitmap.colorAt(x: 140, y: 4)!.alphaComponent == 0 else { return false }
cell.sponsoredRanges = [10...40]
cell.bufferedRange = 0...100
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
NSGraphicsContext.current!.cgContext.clear(NSRect(x: 0, y: 0, width: 280, height: 14))
cell.drawBar(inside: NSRect(x: 0, y: 0, width: 280, height: 14), flipped: false)
NSGraphicsContext.restoreGraphicsState()
func pixel(_ time: Double, y: Int = 7) -> NSColor {
let x = Int((cell.knobThickness / 2 + time / 100 * (280 - cell.knobThickness)).rounded())
return bitmap.colorAt(x: x, y: y)!.usingColorSpace(.sRGB)!
}
for time in [20.0, 30.0] {
let color = pixel(time)
guard abs(color.redComponent * 255 - 220) < 2,
abs(color.greenComponent * 255 - 20) < 2,
abs(color.blueComponent * 255 - 60) < 2 else { return false }
}
guard pixel(25).alphaComponent == 0, // A sponsor crossing a chapter still preserves its gap.
pixel(20, y: 4).alphaComponent > 0, pixel(30, y: 4).alphaComponent == 0,
pixel(5).redComponent != pixel(20).redComponent,
pixel(50).redComponent != pixel(30).redComponent,
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
cell.emphasis = []
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] {
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
@@ -2663,6 +2769,7 @@ func playbackSliderHitCheck() -> Bool {
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
slider.mouseMoved(with: event)
guard hovered?.time == middle,
hovered?.sponsored == (time == 25),
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
}
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
@@ -3192,6 +3299,7 @@ private struct PlayerContent: View {
Divider()
PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model)
PlayerSponsorBlockMenu(model: model)
Divider()
if model.isCompact {
Menu("Subtitles") {
@@ -3261,7 +3369,11 @@ private struct PlayerContent: View {
let intervalStatus = intervals.map {
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
}.joined(separator: ", ")
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus].compactMap { $0 }
let sponsors = model.sponsoredRanges
let sponsorStatus = sponsors.map { "Sponsor \(time($0.lowerBound))–\(time($0.upperBound))" }
.joined(separator: ", ")
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus,
sponsorStatus.isEmpty ? nil : sponsorStatus].compactMap { $0 }
.joined(separator: ", ")
return VStack(spacing: 8) {
HStack(spacing: 0) {
@@ -3311,23 +3423,28 @@ private struct PlayerContent: View {
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
sponsoredRanges: sponsors,
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.overlay(alignment: .topLeading) {
if let hover = hoveredSliderSegment, let title = model.sliderSegmentTitle(at: hover.time) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.lineLimit(1)
.padding(.horizontal, 8).padding(.vertical, 5)
.background { PlayerGlass(cornerRadius: 6) }
.fixedSize()
.position(x: hover.x, y: -16)
.transition(.opacity)
.id(hover.time)
.allowsHitTesting(false)
if let hover = hoveredSliderSegment {
let chapter = model.sliderSegmentTitle(at: hover.time)
let sponsored = hover.sponsored && !sponsors.isEmpty
if let title = sponsored ? chapter.map({ "Sponsor · " + $0 }) ?? "Sponsor" : chapter {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.lineLimit(1)
.padding(.horizontal, 8).padding(.vertical, 5)
.background { PlayerGlass(cornerRadius: 6) }
.fixedSize()
.position(x: hover.x, y: -16)
.transition(.opacity)
.id(hover.time)
.allowsHitTesting(false)
}
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: hoveredSliderSegment)
@@ -8,17 +8,20 @@ public enum CompactTitleProgress: String, Decodable, Sendable {
public struct PlayerConfiguration: Equatable, Sendable {
public var showBufferInfo: Bool
public var automaticQuality: Bool
public var sponsorBlock: 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,
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
snapToEdges: Bool = true,
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality
self.sponsorBlock = sponsorBlock
self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress
self.miruroVideoProvider = miruroVideoProvider
@@ -33,6 +36,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
var lines = [
"[playback]",
"automatic_quality = \(defaults.automaticQuality)",
"sponsorblock = \(defaults.sponsorBlock)",
"", "[miruro]",
"video_provider = \"\(defaults.miruroVideoProvider)\"",
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
@@ -91,6 +95,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
automaticQuality: values.playback?.automaticQuality ?? false,
sponsorBlock: values.playback?.sponsorBlock ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
miruroVideoProvider: values.miruro?.videoProvider ?? "",
@@ -175,10 +180,12 @@ public struct PlayerConfiguration: Equatable, Sendable {
private struct PlaybackConfiguration {
let automaticQuality: Bool?
let sponsorBlock: Bool?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["automatic_quality"])
try validate(container, allowed: ["automatic_quality", "sponsorblock"])
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
}
}