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
+14 -1
View File
@@ -130,6 +130,12 @@ struct ConfigurationChecks {
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
"Miruro defaults select automatic video and captions independently")
let sponsor = root.appendingPathComponent("sponsorblock.toml")
for enabled in [true, false] {
try Data("[playback]\nsponsorblock = \(enabled)\n".utf8).write(to: sponsor)
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled),
"SponsorBlock opt-in and explicit opt-out load without changing other defaults")
}
let miruro = root.appendingPathComponent("miruro.toml")
for (settings, video, captions) in [
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
@@ -178,13 +184,15 @@ struct ConfigurationChecks {
# Sections and nested keys can be quoted, with comments and whitespace.
['playback']
'automatic_quality' = true
'sponsorblock' = true
[ui]
"buffer_info" = true # session display default
compact_snap_to_edges = false
""".utf8)
try original.write(to: config)
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false)
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, sponsorBlock: true,
snapToEdges: false)
let environment = ["XDG_CONFIG_HOME": xdg.path]
try require(try PlayerConfiguration.load(from: config) == configured,
"TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
@@ -192,6 +200,7 @@ struct ConfigurationChecks {
try require(session == configured, "application startup automatically loads the XDG config")
session.showBufferInfo = false
session.automaticQuality = false
session.sponsorBlock = false
session.snapToEdges = true
try require(session == defaults, "controls can override all settings within the session")
try require(try Data(contentsOf: config) == original, "loading and session overrides do not change config bytes")
@@ -267,6 +276,10 @@ struct ConfigurationChecks {
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
("string", "[ui]\nbuffer_info = \"true\"\n", []),
("integer", "[playback]\nautomatic_quality = 1\n", []),
("sponsor-string", "[playback]\nsponsorblock = \"true\"\n", ["sponsorblock"]),
("sponsor-integer", "[playback]\nsponsorblock = 1\n", ["sponsorblock"]),
("sponsor-array", "[playback]\nsponsorblock = [true]\n", ["sponsorblock"]),
("sponsor-table", "[playback.sponsorblock]\nenabled = true\n", ["sponsorblock"]),
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
+128 -6
View File
@@ -46,6 +46,13 @@ struct ResolvedPlaybackCheck {
try await enqueueLifecycle(source: source)
return
}
if CommandLine.arguments.contains("--sponsors-only") {
try await chapterLifecycle(source: source, file: file)
try await sponsorLifecycle(source: source)
try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source)
return
}
let suite = "terebi.resolved-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
@@ -114,12 +121,108 @@ struct ResolvedPlaybackCheck {
try await dubOnlySelection(source: source)
try await queueMetadataLifecycle(source: source)
try await chapterLifecycle(source: source, file: file)
try await sponsorLifecycle(source: source)
try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source)
try await sidebarQueueLifecycle(source: source)
}
}
static var fixtureSponsors: [SponsorSegment] {
[.init(start: 0.5, end: 1.5, videoDuration: 0)]
}
static func sponsorLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.sponsor-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let url = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
var extraction: CheckedContinuation<YouTubeVideo, Never>?
var held: [CheckedContinuation<[SponsorSegment], Never>] = []
var extractionCount = 0
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
extractionCount += 1
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
audio: nil, duration: 0, isLive: false, webpageURL: url, height: height ?? 1080,
availableHeights: [1080, 720], chapters: fixtureChapters)
if extractionCount == 1 { return await withCheckedContinuation { extraction = $0 } }
return video
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
return await withCheckedContinuation { held.append($0) }
})
defer { model.shutdown() }
model.open(url)
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
require(held.isEmpty, "opted-out playback makes no SponsorBlock request")
model.setSponsorBlockEnabled(true)
try await until("SponsorBlock starts independently of held playback extraction", model: model) { held.count == 1 }
held[0].resume(returning: fixtureSponsors)
extraction!.resume(returning: YouTubeVideo(title: "Sponsor fixture",
video: .init(url: source.url, headers: source.headers), audio: nil, duration: 0, isLive: false,
webpageURL: url, height: 1080, availableHeights: [1080, 720], chapters: fixtureChapters))
extraction = nil
try await until("sponsor metadata appears against native measured duration", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.sponsoredRanges == [0.5...1.5]
}
requireYouTubeChapters(model, [1.25, 2.5], "sponsor paint crossing chapter boundary")
require(model.setPlayback(playing: false), "pause sponsored playback")
try await until("sponsor playback paused", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.75 - model.state.position), "seek within sponsor range")
try await until("sponsor seek acknowledged without automatic skipping", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
}
require(model.segmentToSkip == nil, "sponsors never offer Miruro skip actions")
model.setSponsorBlockEnabled(false)
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
model.setSponsorBlockEnabled(true)
try await until("re-enabled lookup held", model: model) { held.count == 2 }
model.setYouTubeQuality(720)
try await until("quality changes without awaiting optional metadata", model: model) {
!model.isBusy && model.state.status == PLAYER_PAUSED && model.youtubeHeight == 720
}
require(held.count == 2, "quality replacement coalesces the in-flight current-video lookup")
held[1].resume(returning: fixtureSponsors)
try await until("pending metadata survives quality replacement", model: model) { model.sponsoredRanges == [0.5...1.5] }
model.setYouTubeQuality(1080)
try await until("completed metadata survives quality replacement", model: model) {
!model.isBusy && model.youtubeHeight == 1080 && model.sponsoredRanges == [0.5...1.5]
}
require(held.count == 2, "quality replacement reuses completed metadata")
model.open(url)
try await until("same-video reload has its own lookup", model: model) {
held.count == 3 && !model.isBusy && model.state.status == PLAYER_PLAYING
}
model.open(url)
try await until("second same-video reload has a new generation", model: model) {
held.count == 4 && !model.isBusy && model.state.status == PLAYER_PLAYING
}
held[2].resume(returning: [.init(start: 2, end: 3, videoDuration: 0)])
for _ in 0..<10 { await Task.yield() }
require(model.sponsoredRanges.isEmpty, "late same-video response cannot paint a newer load")
held[3].resume(returning: fixtureSponsors)
try await until("current reload accepts its own metadata", model: model) { model.sponsoredRanges == [0.5...1.5] }
model.open(url)
try await until("lookup pending before close", model: model) {
held.count == 5 && !model.isBusy && model.state.status == PLAYER_PLAYING
}
model.closeFile()
held[4].resume(returning: fixtureSponsors)
try await until("close clears sponsorship", model: model) { model.state.status == PLAYER_EMPTY }
require(model.sponsoredRanges.isEmpty, "closed player rejects late metadata")
model.open(URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!)
try await until("SponsorBlock timeout never blocks native playback", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
}
require(model.commandError == nil && model.sponsoredRanges.isEmpty, "optional failure leaves unmarked playback")
model.open(URL(string: "https://example.org/generic-video")!)
try await until("generic extractor site remains playable without SponsorBlock", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
}
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
print("PASS: SponsorBlock opt-in, independent lookup, crimson-only ranges, quality reuse, same-video cancellation, close, generic sites and timeout isolation")
}
static var fixtureChapters: [YouTubeChapter] {
[.init(start: 0, end: 1.25, title: "Opening"),
.init(start: 1.25, end: 2.5, title: "Ending")]
@@ -146,13 +249,14 @@ struct ResolvedPlaybackCheck {
let chapterless = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
var requests: [URL] = []
// The single-video Tubular launch uses this progress-file initialization plus openStream.
let model = PlayerModel(defaults: defaults, progressFile: report.path, youtubeResolver: { url, height in
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true),
progressFile: report.path, youtubeResolver: { url, height in
requests.append(url)
return YouTubeVideo(title: url == chaptered ? "Chaptered launch" : "Chapterless launch",
video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: url, height: height ?? 1080, availableHeights: [1080, 720],
chapters: url == chaptered ? fixtureChapters + [.init(start: 3, end: 500, title: nil)] : [])
}, youtubeTitleResolver: { _ in nil },
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in id == "AAAAAAAAAAA" ? fixtureSponsors : [] },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
require(model.progressInitializationError == nil, "single Tubular launch initializes progress reporting")
@@ -163,6 +267,10 @@ struct ResolvedPlaybackCheck {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
}
requireYouTubeChapters(model, expected, "direct openStream")
try await until("direct launch sponsorship metadata", model: model) {
url != chaptered || model.sponsoredRanges == [0.5...1.5]
}
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "single launch sponsorship")
require(model.sliderSegmentTitle(at: 0.5) == (url == chaptered ? "Opening" : nil) &&
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
model.sliderSegmentTitle(at: 2.5) == nil,
@@ -185,6 +293,9 @@ struct ResolvedPlaybackCheck {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position >= 0.7
}
requireYouTubeChapters(model, expected, "saved/recent open(url)")
try await until("saved-item sponsorship metadata", model: model) {
url != chaptered || model.sponsoredRanges == [0.5...1.5]
}
require(model.setPlayback(playing: false), "pause before chapter quality replacement")
try await until("chapter quality pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
let position = model.state.position
@@ -195,6 +306,7 @@ struct ResolvedPlaybackCheck {
abs(model.state.position - position) < 0.2
}
requireYouTubeChapters(model, expected, "chaptered/chapterless quality replacement")
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "quality replacement retains paint")
}
model.open(chaptered)
try await until("chaptered media replaces chapterless media", model: model) {
@@ -207,6 +319,7 @@ struct ResolvedPlaybackCheck {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio
}
require(model.sliderBoundaries.isEmpty, "resolved media does not inherit YouTube chapters")
require(model.sponsoredRanges.isEmpty, "resolved replacement clears sponsorship")
model.closeFile()
try await until("resolved replacement closes", model: model) { model.state.status == PLAYER_EMPTY }
require(model.sliderBoundaries.isEmpty, "empty player has no chapter boundaries")
@@ -537,13 +650,14 @@ struct ResolvedPlaybackCheck {
video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [],
chapters: fixtureChapters)
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true),
streamQueue: queue, youtubeResolver: { _, _ in
youtubeRequests += 1
if youtubeRequests == 1 {
return await withCheckedContinuation { stale = $0 }
}
return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors }, miruroResolver: { _, _ in
episodeRequests += 1
return try providerSources(source)
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
@@ -573,6 +687,7 @@ struct ResolvedPlaybackCheck {
}
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
requireYouTubeChapters(model, [1.25, 2.5], "batch queue previous")
try await until("batch launch sponsor paint", model: model) { model.sponsoredRanges == [0.5...1.5] }
require(model.setPlayback(playing: false), "pause first queued stream")
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
@@ -585,6 +700,7 @@ struct ResolvedPlaybackCheck {
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
}
require(model.sliderBoundaries.isEmpty, "natural queue advance clears YouTube chapters for Miruro")
require(model.sponsoredRanges.isEmpty, "natural advance clears sponsor paint for Miruro")
require(model.setPlayback(playing: false), "pause second stream for restoration")
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
@@ -642,14 +758,15 @@ struct ResolvedPlaybackCheck {
let suite = "terebi.enqueue-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults, progressFile: originalReport.path,
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true), progressFile: originalReport.path,
youtubeResolver: { url, _ in
requests += 1
return YouTubeVideo(title: url.absoluteString,
video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
chapters: url == first ? fixtureChapters : [])
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in id == "AAAAAAAAAAA" ? fixtureSponsors : [] },
miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
func deliver(_ items: [(URL, URL)]) throws {
@@ -664,6 +781,7 @@ struct ResolvedPlaybackCheck {
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
}
requireYouTubeChapters(model, [1.25, 2.5], "single stream before enqueue")
try await until("initial single launch sponsor paint", model: model) { model.sponsoredRanges == [0.5...1.5] }
require(model.setPlayback(playing: false), "pause original single-video playback")
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
let selected = model.queue.currentID
@@ -676,6 +794,7 @@ struct ResolvedPlaybackCheck {
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
requireYouTubeChapters(model, [1.25, 2.5], "paused enqueue preserves current chapters")
require(model.sponsoredRanges == [0.5...1.5], "paused enqueue preserves current sponsor paint")
require(model.setPlayback(playing: true), "resume original playback")
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
let playingRevision = model.state.revision
@@ -685,6 +804,7 @@ struct ResolvedPlaybackCheck {
model.state.status == PLAYER_PLAYING,
"repeated native delivery appends while playing without reopening current media")
requireYouTubeChapters(model, [1.25, 2.5], "playing enqueue preserves current chapters")
require(model.sponsoredRanges == [0.5...1.5], "playing enqueue preserves current sponsor paint")
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
"conflicting identity rejects complete enqueue without changing playback")
@@ -710,11 +830,13 @@ struct ResolvedPlaybackCheck {
model.queue.currentURL == second
}
require(model.sliderBoundaries.isEmpty, "enqueued Miruro advance clears current chapters")
require(model.sponsoredRanges.isEmpty, "enqueue advance to Miruro clears sponsor paint")
model.advanceQueue(1)
try await until("enqueued YouTube item decodes", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
}
requireYouTubeChapters(model, [], "chapterless enqueued YouTube advance")
require(model.sponsoredRanges.isEmpty, "enqueued no-result video has no sponsor paint")
model.advanceQueue(-1)
try await until("enqueued Miruro report revisits", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
+185
View File
@@ -0,0 +1,185 @@
import Foundation
private final class SponsorBlockProtocol: URLProtocol, @unchecked Sendable {
struct Reply {
var status = 200
var body = Data("[]".utf8)
var headers: [String: String] = [:]
var chunkSize = 8192
}
final class State: @unchecked Sendable {
let lock = NSLock()
var reply = Reply()
var requests: [URLRequest] = []
func reset(_ reply: Reply) {
lock.lock(); defer { lock.unlock() }
self.reply = reply
requests = []
}
func receive(_ request: URLRequest) -> Reply {
lock.lock(); defer { lock.unlock() }
requests.append(request)
return reply
}
var captured: [URLRequest] {
lock.lock(); defer { lock.unlock() }
return requests
}
}
static let state = State()
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let reply = Self.state.receive(request)
let response = HTTPURLResponse(url: request.url!, statusCode: reply.status,
httpVersion: "HTTP/1.1", headerFields: reply.headers)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
for offset in stride(from: 0, to: reply.body.count, by: reply.chunkSize) {
client?.urlProtocol(self, didLoad: reply.body.subdata(in: offset..<min(offset + reply.chunkSize, reply.body.count)))
}
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@main
struct SponsorBlockChecks {
struct Failure: Error { let message: String }
static let id = "YOelRv7fMxY"
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw Failure(message: message) }
}
static func reject(_ message: String, _ operation: () async throws -> Void) async throws {
do { try await operation() }
catch { return }
throw Failure(message: "Accepted \(message)")
}
static func row(_ start: Double = 10.25, _ end: Double = 20.75, duration: Double = 100) -> [String: Any] {
["category": "sponsor", "actionType": "skip", "segment": [start, end], "videoDuration": duration]
}
static func wire(_ value: Any) throws -> Data {
try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed])
}
static func bucket(_ rows: [Any], videoID: String = id) -> [String: Any] {
["videoID": videoID, "segments": rows]
}
static func configuration() -> URLSessionConfiguration {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [SponsorBlockProtocol.self]
// Even an injected configuration must not send ambient credentials.
configuration.httpAdditionalHeaders = ["Authorization": "secret", "Cookie": "secret"]
return configuration
}
private static func resolve(_ reply: SponsorBlockProtocol.Reply) async throws -> [SponsorSegment] {
SponsorBlockProtocol.state.reset(reply)
return try await SponsorBlockResolver.resolve(videoID: id, configuration: configuration())
}
static func checks() async throws {
let canonical = "https://www.youtube.com/watch?v=\(id)"
try require(SponsorBlockResolver.videoID(for: URL(string: canonical)!) == id, "canonical identity")
for invalid in ["http://www.youtube.com/watch?v=\(id)", "https://youtube.com/watch?v=\(id)",
"https://youtu.be/\(id)", "https://www.youtube.com:443/watch?v=\(id)",
"https://user:secret@www.youtube.com/watch?v=\(id)", canonical + "&t=10",
canonical + "#chapter", canonical + "&v=\(id)", canonical + "x",
"https://www.youtube.com/watch?v=YOelRv7fMxé",
"https://www.youtube.com/watch?v=%59OelRv7fMxY",
"https://www.youtube.com/watch?v=YOelRv7fMx.",
"https://www.youtube.com/embed/\(id)", "https://example.org/watch?v=\(id)"] {
try require(SponsorBlockResolver.videoID(for: URL(string: invalid)!) == nil, "reject noncanonical identity: \(invalid)")
}
let request = try SponsorBlockResolver.request(videoID: id)
try require(request.url?.absoluteString == "https://sponsor.ajay.app/api/skipSegments/5db6?category=sponsor&actionType=skip&service=YouTube", "known SHA256 prefix and exact endpoint")
try require(request.httpMethod == "GET" && !request.httpShouldHandleCookies && request.timeoutInterval == 10, "bounded credential-free GET")
try await reject("invalid direct identity") { _ = try SponsorBlockResolver.request(videoID: "../secret") }
var invalidRows: [Any] = [NSNull(), "bad row", 4, ["segment": [1, 2]], row(-1, 2), row(3, 2), row(2, 2)]
for (key, value) in [("category", "intro" as Any), ("actionType", "mute" as Any),
("segment", [1] as Any), ("segment", [1, 2, 3] as Any),
("segment", [true, false] as Any), ("segment", ["1", "2"] as Any),
("videoDuration", -1 as Any), ("videoDuration", "100" as Any),
("videoDuration", true as Any), ("videoDuration", NSNull() as Any)] {
var malformed = row(); malformed[key] = value; invalidRows.append(malformed)
}
var missing = row(); missing.removeValue(forKey: "videoDuration"); invalidRows.append(missing)
let body = try wire([NSNull(), 42, ["videoID": id],
bucket([row(1, 2)], videoID: "aqz-KE-bpKQ"),
bucket(invalidRows + [row(), row(80.125, 90.5, duration: 0)])])
let segments = try SponsorBlockResolver.decode(body, videoID: id)
try require(segments.count == 2 && segments[0].start == 10.25 && segments[0].end == 20.75 && segments[0].videoDuration == 100 && segments[1].videoDuration == 0, "lossy individual buckets/rows, exact match, fractional times and unknown duration")
let absent = try SponsorBlockResolver.decode(body, videoID: "abcdefghijk")
try require(absent.isEmpty, "no exact bucket match")
for malformed in [Data("[".utf8), try wire(["segments": []]), try wire(NSNull()), Data("[NaN]".utf8)] {
try await reject("malformed top-level JSON") { _ = try SponsorBlockResolver.decode(malformed, videoID: id) }
}
let overflow = Data("[{\"videoID\":\"\(id)\",\"segments\":[{\"category\":\"sponsor\",\"actionType\":\"skip\",\"segment\":[0,1e999],\"videoDuration\":100}]}]".utf8)
let overflowRows = try SponsorBlockResolver.decode(overflow, videoID: id)
try require(overflowRows.isEmpty, "nonfinite row numbers are rejected without poisoning the bucket")
func segment(_ start: Double, _ end: Double, _ reference: Double = 100) -> SponsorSegment {
SponsorSegment(start: start, end: end, videoDuration: reference)
}
let ranges = SponsorSegment.visibleRanges([segment(30, 40), segment(10.25, 20.75), segment(20.75, 31), segment(12, 13)], duration: 100)
try require(ranges == [10.25...40], "sort, union overlapping and touching ranges")
for duration in [0, -1, Double.nan, .infinity] {
try require(SponsorSegment.visibleRanges(segments, duration: duration).isEmpty, "unknown or invalid actual duration hides paint")
}
for reference in [99.0, 101.0, -1, Double.nan, .infinity] {
try require(SponsorSegment.visibleRanges([segment(10, 20, reference)], duration: 100).isEmpty, "strict stale boundary and invalid reference duration")
}
for reference in [99.001, 100.999, 0] {
try require(SponsorSegment.visibleRanges([segment(10, 20, reference)], duration: 100) == [10...20], "under-one-second mismatch or unknown reference accepted")
}
let invalid = [segment(-1, 2), segment(2, 2), segment(3, 2), segment(.nan, 3), segment(0, .infinity), segment(90, 101)]
try require(SponsorSegment.visibleRanges(invalid, duration: 100).isEmpty, "never clip, rescale, or retain invalid timestamps")
let changing = [segment(90, 100, 0)]
try require(SponsorSegment.visibleRanges(changing, duration: 100) == [90...100] && SponsorSegment.visibleRanges(changing, duration: 99).isEmpty && SponsorSegment.visibleRanges(changing, duration: 110) == [90...100], "changing duration recomputes visibility from untouched ranges")
let fetched = try await resolve(.init(body: body))
try require(fetched.count == 2, "native async streamed transport decodes fixture")
let captured = SponsorBlockProtocol.state.captured
try require(captured.count == 1 && captured[0].url == request.url && captured[0].httpMethod == "GET" && captured[0].value(forHTTPHeaderField: "Authorization") == nil && captured[0].value(forHTTPHeaderField: "Cookie") == nil, "one fixed-host request without injected credentials")
let notFound = try await resolve(.init(status: 404, body: Data("not JSON".utf8)))
try require(notFound.isEmpty, "404 is normal absence")
for status in [401, 429, 500, 302] {
try await reject("HTTP \(status)") {
_ = try await resolve(.init(status: status, headers: ["Location": "https://example.org/secret"]))
}
try require(SponsorBlockProtocol.state.captured.count == 1, "no retries or follow-up requests")
}
try await reject("malformed network payload") { _ = try await resolve(.init(body: Data("oops".utf8))) }
try await reject("oversize advertised payload") { _ = try await resolve(.init(headers: ["Content-Length": "2097153"])) }
try await reject("oversize streamed payload without length") {
_ = try await resolve(.init(body: Data(repeating: 32, count: SponsorBlockResolver.responseLimit + 1)))
}
let cancelled = Task {
withUnsafeCurrentTask { $0?.cancel() }
return try await SponsorBlockResolver.resolve(videoID: id, configuration: configuration())
}
do {
_ = try await cancelled.value
throw Failure(message: "Cancelled lookup succeeded")
} catch is CancellationError {} catch let failure as Failure { throw failure }
print("SponsorBlock checks passed")
}
static func main() async {
do {
if Array(CommandLine.arguments.dropFirst()) == ["--live"] {
for videoID in [id, "aqz-KE-bpKQ"] {
let segments = try await SponsorBlockResolver.resolve(videoID: videoID)
let intervals = segments.map { ["start": $0.start, "end": $0.end, "videoDuration": $0.videoDuration] }
let data = try wire(["videoID": videoID, "intervals": intervals])
print(String(decoding: data, as: UTF8.self))
}
} else if CommandLine.arguments.count == 1 {
try await checks()
} else {
throw Failure(message: "Usage: sponsorblock-check [--live]")
}
} catch {
let message = (error as? Failure)?.message ?? error.localizedDescription
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
exit(1)
}
}
}
+9
View File
@@ -22,6 +22,7 @@ SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
CLI_SOURCES += Sources/App/StreamResolver.swift
SWIFT_SOURCES += Sources/App/StreamQueue.swift
SWIFT_SOURCES += Sources/App/SponsorBlock.swift
CLI_SOURCES += Sources/App/StreamQueue.swift
CONFIG_BUILD := .build/configuration
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
@@ -161,6 +162,7 @@ check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
check: check-stream
check: check-sponsorblock
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
@@ -215,6 +217,13 @@ $(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/Y
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
$(BUILD_DIR)/sponsorblock-check: Checks/sponsor-block.swift Sources/App/SponsorBlock.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $< Sources/App/SponsorBlock.swift -framework Foundation -o $@
.PHONY: check-sponsorblock
check-sponsorblock: $(BUILD_DIR)/sponsorblock-check
$(BUILD_DIR)/sponsorblock-check
$(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@
+22
View File
@@ -26,6 +26,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
- **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title; accessibility also identifies sponsored ranges. This is visual information only: it never seeks or skips.
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
@@ -82,6 +83,21 @@ Copy the settings you want to change into the config file; omitted settings keep
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
### SponsorBlock
SponsorBlock is **off by default**. Enable **Playback → SponsorBlock → Show Sponsored Ranges** for the session, or opt in at startup:
```toml
[playback]
sponsorblock = true
```
Use `false` (the generated default) or omit the setting to stay opted out; non-boolean values are rejected. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports.
Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display.
### Miruro providers
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
@@ -114,3 +130,9 @@ Native playback supports the returned proxy configuration and HLS query/segment-
Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`. The suite requires a graphical macOS session and a working audio device; it launches players and plays short test audio. See the [Makefile](Makefile) for individual check targets.
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
## Acknowledgements
Uses [SponsorBlock](https://sponsor.ajay.app/) data under the [CC BY-NC-SA 4.0 license](https://creativecommons.org/licenses/by-nc-sa/4.0/). Distribution involving this data must account for its **NonCommercial** and **ShareAlike** conditions; obtain separate permission from the relevant rights holders if your intended use needs it. This data credit does not relicense Terebi's application source.
+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
}
}
+121 -4
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,12 +3423,16 @@ 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) {
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))
@@ -3330,6 +3446,7 @@ private struct PlayerContent: View {
.allowsHitTesting(false)
}
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: hoveredSliderSegment)
.allowsHitTesting(model.canSeek)
.accessibilityLabel("Playback Position")
@@ -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"))
}
}