feat: show optional SponsorBlock ranges on playback timeline
This commit is contained in:
@@ -130,6 +130,12 @@ struct ConfigurationChecks {
|
|||||||
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
||||||
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
|
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
|
||||||
"Miruro defaults select automatic video and captions independently")
|
"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")
|
let miruro = root.appendingPathComponent("miruro.toml")
|
||||||
for (settings, video, captions) in [
|
for (settings, video, captions) in [
|
||||||
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
("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.
|
# Sections and nested keys can be quoted, with comments and whitespace.
|
||||||
['playback']
|
['playback']
|
||||||
'automatic_quality' = true
|
'automatic_quality' = true
|
||||||
|
'sponsorblock' = true
|
||||||
[ui]
|
[ui]
|
||||||
"buffer_info" = true # session display default
|
"buffer_info" = true # session display default
|
||||||
compact_snap_to_edges = false
|
compact_snap_to_edges = false
|
||||||
""".utf8)
|
""".utf8)
|
||||||
try original.write(to: config)
|
try original.write(to: config)
|
||||||
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
|
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]
|
let environment = ["XDG_CONFIG_HOME": xdg.path]
|
||||||
try require(try PlayerConfiguration.load(from: config) == configured,
|
try require(try PlayerConfiguration.load(from: config) == configured,
|
||||||
"TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
|
"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")
|
try require(session == configured, "application startup automatically loads the XDG config")
|
||||||
session.showBufferInfo = false
|
session.showBufferInfo = false
|
||||||
session.automaticQuality = false
|
session.automaticQuality = false
|
||||||
|
session.sponsorBlock = false
|
||||||
session.snapToEdges = true
|
session.snapToEdges = true
|
||||||
try require(session == defaults, "controls can override all settings within the session")
|
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")
|
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"]),
|
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
|
||||||
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
||||||
("integer", "[playback]\nautomatic_quality = 1\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", []),
|
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
|
||||||
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
|
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
|
||||||
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
|
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
return
|
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 suite = "terebi.resolved-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
@@ -114,12 +121,108 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await dubOnlySelection(source: source)
|
try await dubOnlySelection(source: source)
|
||||||
try await queueMetadataLifecycle(source: source)
|
try await queueMetadataLifecycle(source: source)
|
||||||
try await chapterLifecycle(source: source, file: file)
|
try await chapterLifecycle(source: source, file: file)
|
||||||
|
try await sponsorLifecycle(source: source)
|
||||||
try await streamQueueLifecycle(source: source)
|
try await streamQueueLifecycle(source: source)
|
||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
try await sidebarQueueLifecycle(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] {
|
static var fixtureChapters: [YouTubeChapter] {
|
||||||
[.init(start: 0, end: 1.25, title: "Opening"),
|
[.init(start: 0, end: 1.25, title: "Opening"),
|
||||||
.init(start: 1.25, end: 2.5, title: "Ending")]
|
.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")!
|
let chapterless = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
||||||
var requests: [URL] = []
|
var requests: [URL] = []
|
||||||
// The single-video Tubular launch uses this progress-file initialization plus openStream.
|
// 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)
|
requests.append(url)
|
||||||
return YouTubeVideo(title: url == chaptered ? "Chaptered launch" : "Chapterless launch",
|
return YouTubeVideo(title: url == chaptered ? "Chaptered launch" : "Chapterless launch",
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: height ?? 1080, availableHeights: [1080, 720],
|
duration: 0, isLive: false, webpageURL: url, height: height ?? 1080, availableHeights: [1080, 720],
|
||||||
chapters: url == chaptered ? fixtureChapters + [.init(start: 3, end: 500, title: nil)] : [])
|
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: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
require(model.progressInitializationError == nil, "single Tubular launch initializes progress reporting")
|
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
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
requireYouTubeChapters(model, expected, "direct openStream")
|
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) &&
|
require(model.sliderSegmentTitle(at: 0.5) == (url == chaptered ? "Opening" : nil) &&
|
||||||
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
|
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
|
||||||
model.sliderSegmentTitle(at: 2.5) == 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
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position >= 0.7
|
||||||
}
|
}
|
||||||
requireYouTubeChapters(model, expected, "saved/recent open(url)")
|
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")
|
require(model.setPlayback(playing: false), "pause before chapter quality replacement")
|
||||||
try await until("chapter quality pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("chapter quality pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let position = model.state.position
|
let position = model.state.position
|
||||||
@@ -195,6 +306,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
abs(model.state.position - position) < 0.2
|
abs(model.state.position - position) < 0.2
|
||||||
}
|
}
|
||||||
requireYouTubeChapters(model, expected, "chaptered/chapterless quality replacement")
|
requireYouTubeChapters(model, expected, "chaptered/chapterless quality replacement")
|
||||||
|
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "quality replacement retains paint")
|
||||||
}
|
}
|
||||||
model.open(chaptered)
|
model.open(chaptered)
|
||||||
try await until("chaptered media replaces chapterless media", model: model) {
|
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
|
!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.sliderBoundaries.isEmpty, "resolved media does not inherit YouTube chapters")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "resolved replacement clears sponsorship")
|
||||||
model.closeFile()
|
model.closeFile()
|
||||||
try await until("resolved replacement closes", model: model) { model.state.status == PLAYER_EMPTY }
|
try await until("resolved replacement closes", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
require(model.sliderBoundaries.isEmpty, "empty player has no chapter boundaries")
|
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,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [],
|
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [],
|
||||||
chapters: fixtureChapters)
|
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
|
youtubeRequests += 1
|
||||||
if youtubeRequests == 1 {
|
if youtubeRequests == 1 {
|
||||||
return await withCheckedContinuation { stale = $0 }
|
return await withCheckedContinuation { stale = $0 }
|
||||||
}
|
}
|
||||||
return video
|
return video
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors }, miruroResolver: { _, _ in
|
||||||
episodeRequests += 1
|
episodeRequests += 1
|
||||||
return try providerSources(source)
|
return try providerSources(source)
|
||||||
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
}, 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")
|
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
||||||
requireYouTubeChapters(model, [1.25, 2.5], "batch queue previous")
|
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")
|
require(model.setPlayback(playing: false), "pause first queued stream")
|
||||||
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
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")
|
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
|
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
|
||||||
}
|
}
|
||||||
require(model.sliderBoundaries.isEmpty, "natural queue advance clears YouTube chapters for Miruro")
|
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")
|
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
||||||
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
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")
|
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 suite = "terebi.enqueue-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: 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
|
youtubeResolver: { url, _ in
|
||||||
requests += 1
|
requests += 1
|
||||||
return YouTubeVideo(title: url.absoluteString,
|
return YouTubeVideo(title: url.absoluteString,
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
||||||
chapters: url == first ? fixtureChapters : [])
|
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: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
func deliver(_ items: [(URL, URL)]) throws {
|
func deliver(_ items: [(URL, URL)]) throws {
|
||||||
@@ -664,6 +781,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
requireYouTubeChapters(model, [1.25, 2.5], "single stream before enqueue")
|
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")
|
require(model.setPlayback(playing: false), "pause original single-video playback")
|
||||||
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let selected = model.queue.currentID
|
let selected = model.queue.currentID
|
||||||
@@ -676,6 +794,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
||||||
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
"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")
|
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")
|
require(model.setPlayback(playing: true), "resume original playback")
|
||||||
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
||||||
let playingRevision = model.state.revision
|
let playingRevision = model.state.revision
|
||||||
@@ -685,6 +804,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.state.status == PLAYER_PLAYING,
|
model.state.status == PLAYER_PLAYING,
|
||||||
"repeated native delivery appends while playing without reopening current media")
|
"repeated native delivery appends while playing without reopening current media")
|
||||||
requireYouTubeChapters(model, [1.25, 2.5], "playing enqueue preserves current chapters")
|
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"))])
|
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
||||||
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||||
"conflicting identity rejects complete enqueue without changing playback")
|
"conflicting identity rejects complete enqueue without changing playback")
|
||||||
@@ -710,11 +830,13 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.queue.currentURL == second
|
model.queue.currentURL == second
|
||||||
}
|
}
|
||||||
require(model.sliderBoundaries.isEmpty, "enqueued Miruro advance clears current chapters")
|
require(model.sliderBoundaries.isEmpty, "enqueued Miruro advance clears current chapters")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "enqueue advance to Miruro clears sponsor paint")
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
try await until("enqueued YouTube item decodes", model: model) {
|
try await until("enqueued YouTube item decodes", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
||||||
}
|
}
|
||||||
requireYouTubeChapters(model, [], "chapterless enqueued YouTube advance")
|
requireYouTubeChapters(model, [], "chapterless enqueued YouTube advance")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "enqueued no-result video has no sponsor paint")
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("enqueued Miruro report revisits", model: model) {
|
try await until("enqueued Miruro report revisits", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
|
|||||||
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
|
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
|
||||||
CLI_SOURCES += Sources/App/StreamResolver.swift
|
CLI_SOURCES += Sources/App/StreamResolver.swift
|
||||||
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
||||||
|
SWIFT_SOURCES += Sources/App/SponsorBlock.swift
|
||||||
CLI_SOURCES += Sources/App/StreamQueue.swift
|
CLI_SOURCES += Sources/App/StreamQueue.swift
|
||||||
CONFIG_BUILD := .build/configuration
|
CONFIG_BUILD := .build/configuration
|
||||||
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
|
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)
|
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
|
||||||
|
|
||||||
check: check-stream
|
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
|
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
|
$(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)
|
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
|
||||||
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
|
$(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)
|
$(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 $@
|
$(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 $@
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
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
|
### 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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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
|
@MainActor
|
||||||
struct PlayerCommands: Commands {
|
struct PlayerCommands: Commands {
|
||||||
@FocusedObject private var focusedModel: PlayerModel?
|
@FocusedObject private var focusedModel: PlayerModel?
|
||||||
@@ -311,6 +333,7 @@ struct PlayerCommands: Commands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||||
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||||
model.performMenuAction(bufferInfo)
|
model.performMenuAction(bufferInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -29,7 +29,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
|
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
|
||||||
streamQueue: startupStreamQueue)
|
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 private(set) var fileName: String?
|
||||||
@Published var showsYouTubeInput = false
|
@Published var showsYouTubeInput = false
|
||||||
@Published private(set) var fileIcon: NSImage?
|
@Published private(set) var fileIcon: NSImage?
|
||||||
@@ -71,6 +75,18 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var shouldRecordRecentMedia = false
|
private var shouldRecordRecentMedia = false
|
||||||
private var metadataLifecycle = MetadataLifecycle()
|
private var metadataLifecycle = MetadataLifecycle()
|
||||||
private var metadataTask: Task<Void, Never>?
|
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] {
|
var visibleIntervals: [MiruroInterval] {
|
||||||
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||||||
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
||||||
@@ -222,6 +238,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var reportingErrorBuffer = [CChar]()
|
private var reportingErrorBuffer = [CChar]()
|
||||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||||
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
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 miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
|
||||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||||
private var reportingErrorMessage: String {
|
private var reportingErrorMessage: String {
|
||||||
@@ -232,6 +249,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
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) },
|
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) },
|
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) },
|
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||||
@@ -240,12 +258,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
self.skipClock = skipClock
|
self.skipClock = skipClock
|
||||||
self.youtubeResolver = youtubeResolver
|
self.youtubeResolver = youtubeResolver
|
||||||
self.youtubeTitleResolver = youtubeTitleResolver
|
self.youtubeTitleResolver = youtubeTitleResolver
|
||||||
|
self.sponsorResolver = sponsorResolver
|
||||||
self.miruroResolver = miruroResolver
|
self.miruroResolver = miruroResolver
|
||||||
self.miruroMetadataResolver = miruroMetadataResolver
|
self.miruroMetadataResolver = miruroMetadataResolver
|
||||||
keyBindings = configuration.keys
|
keyBindings = configuration.keys
|
||||||
compactTitleProgress = configuration.compactTitleProgress
|
compactTitleProgress = configuration.compactTitleProgress
|
||||||
automaticYouTubeQuality = configuration.automaticQuality
|
automaticYouTubeQuality = configuration.automaticQuality
|
||||||
showsBufferInfo = configuration.showBufferInfo
|
showsBufferInfo = configuration.showBufferInfo
|
||||||
|
sponsorBlockEnabled = configuration.sponsorBlock
|
||||||
miruroVideoProvider = configuration.miruroVideoProvider
|
miruroVideoProvider = configuration.miruroVideoProvider
|
||||||
miruroCaptionProvider = configuration.miruroCaptionProvider
|
miruroCaptionProvider = configuration.miruroCaptionProvider
|
||||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||||||
@@ -716,6 +736,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
|
replaceSponsorMetadata(videoID: SponsorBlockResolver.videoID(for: url), reusing: change.isQuality)
|
||||||
let request = transition.beginResolution(change)
|
let request = transition.beginResolution(change)
|
||||||
systemPlayback?.update()
|
systemPlayback?.update()
|
||||||
commandError = nil
|
commandError = nil
|
||||||
@@ -818,7 +839,46 @@ final class PlayerModel: ObservableObject {
|
|||||||
queueMetadataTask = (item.id, task)
|
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?) {
|
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?.cancel()
|
||||||
metadataTask = nil
|
metadataTask = nil
|
||||||
if source?.url.absoluteString != metadataLifecycle.identity {
|
if source?.url.absoluteString != metadataLifecycle.identity {
|
||||||
@@ -2271,6 +2331,7 @@ private enum SunsetPalette {
|
|||||||
static let actionFill = accent.withAlphaComponent(0.08)
|
static let actionFill = accent.withAlphaComponent(0.08)
|
||||||
static let track = secondaryText.withAlphaComponent(0.18)
|
static let track = secondaryText.withAlphaComponent(0.18)
|
||||||
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
|
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)
|
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2354,9 +2415,11 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
var enabled = true
|
var enabled = true
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var boundaries: [Double] = []
|
var boundaries: [Double] = []
|
||||||
|
var sponsoredRanges: [ClosedRange<Double>] = []
|
||||||
struct Hover: Equatable {
|
struct Hover: Equatable {
|
||||||
let time: Double
|
let time: Double
|
||||||
let x: CGFloat
|
let x: CGFloat
|
||||||
|
var sponsored = false
|
||||||
}
|
}
|
||||||
var onHover: (Hover?) -> Void = { _ in }
|
var onHover: (Hover?) -> Void = { _ in }
|
||||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||||
@@ -2394,6 +2457,11 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
cell.bufferedRange = bufferedRange
|
cell.bufferedRange = bufferedRange
|
||||||
slider.needsDisplay = true
|
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")
|
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||||
}
|
}
|
||||||
@@ -2464,7 +2532,13 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
||||||
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
||||||
let middle = (lower + upper) / 2
|
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)) }
|
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 volume = false
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var boundaries: [Double] = []
|
var boundaries: [Double] = []
|
||||||
|
var sponsoredRanges: [ClosedRange<Double>] = []
|
||||||
var emphasis: [Double] = []
|
var emphasis: [Double] = []
|
||||||
func segment(at time: Double) -> Int {
|
func segment(at time: Double) -> Int {
|
||||||
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
|
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
|
||||||
@@ -2609,6 +2684,15 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
SunsetPalette.bufferedTrack.setFill()
|
SunsetPalette.bufferedTrack.setFill()
|
||||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
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) {
|
override func drawKnob(_ rect: NSRect) {
|
||||||
let knob = visibleKnobRect(rect)
|
let knob = visibleKnobRect(rect)
|
||||||
@@ -2656,6 +2740,28 @@ func playbackSliderHitCheck() -> Bool {
|
|||||||
NSGraphicsContext.restoreGraphicsState()
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
|
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
|
||||||
bitmap.colorAt(x: 140, y: 4)!.alphaComponent == 0 else { return false }
|
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 = []
|
cell.emphasis = []
|
||||||
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] {
|
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)
|
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)!
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
||||||
slider.mouseMoved(with: event)
|
slider.mouseMoved(with: event)
|
||||||
guard hovered?.time == middle,
|
guard hovered?.time == middle,
|
||||||
|
hovered?.sponsored == (time == 25),
|
||||||
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
|
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
|
||||||
}
|
}
|
||||||
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
||||||
@@ -3192,6 +3299,7 @@ private struct PlayerContent: View {
|
|||||||
Divider()
|
Divider()
|
||||||
PlayerQualityMenu(model: model)
|
PlayerQualityMenu(model: model)
|
||||||
PlayerMiruroMenu(model: model)
|
PlayerMiruroMenu(model: model)
|
||||||
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Divider()
|
Divider()
|
||||||
if model.isCompact {
|
if model.isCompact {
|
||||||
Menu("Subtitles") {
|
Menu("Subtitles") {
|
||||||
@@ -3261,7 +3369,11 @@ private struct PlayerContent: View {
|
|||||||
let intervalStatus = intervals.map {
|
let intervalStatus = intervals.map {
|
||||||
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
|
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
|
||||||
}.joined(separator: ", ")
|
}.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: ", ")
|
.joined(separator: ", ")
|
||||||
return VStack(spacing: 8) {
|
return VStack(spacing: 8) {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -3311,23 +3423,28 @@ private struct PlayerContent: View {
|
|||||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||||
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
|
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
|
||||||
|
sponsoredRanges: sponsors,
|
||||||
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
|
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
|
||||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||||
})
|
})
|
||||||
.frame(height: 14)
|
.frame(height: 14)
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if let hover = hoveredSliderSegment, let title = model.sliderSegmentTitle(at: hover.time) {
|
if let hover = hoveredSliderSegment {
|
||||||
Text(title)
|
let chapter = model.sliderSegmentTitle(at: hover.time)
|
||||||
.font(.system(size: 11, weight: .medium))
|
let sponsored = hover.sponsored && !sponsors.isEmpty
|
||||||
.foregroundStyle(Color(SunsetPalette.text))
|
if let title = sponsored ? chapter.map({ "Sponsor · " + $0 }) ?? "Sponsor" : chapter {
|
||||||
.lineLimit(1)
|
Text(title)
|
||||||
.padding(.horizontal, 8).padding(.vertical, 5)
|
.font(.system(size: 11, weight: .medium))
|
||||||
.background { PlayerGlass(cornerRadius: 6) }
|
.foregroundStyle(Color(SunsetPalette.text))
|
||||||
.fixedSize()
|
.lineLimit(1)
|
||||||
.position(x: hover.x, y: -16)
|
.padding(.horizontal, 8).padding(.vertical, 5)
|
||||||
.transition(.opacity)
|
.background { PlayerGlass(cornerRadius: 6) }
|
||||||
.id(hover.time)
|
.fixedSize()
|
||||||
.allowsHitTesting(false)
|
.position(x: hover.x, y: -16)
|
||||||
|
.transition(.opacity)
|
||||||
|
.id(hover.time)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: hoveredSliderSegment)
|
.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 struct PlayerConfiguration: Equatable, Sendable {
|
||||||
public var showBufferInfo: Bool
|
public var showBufferInfo: Bool
|
||||||
public var automaticQuality: Bool
|
public var automaticQuality: Bool
|
||||||
|
public var sponsorBlock: Bool
|
||||||
public var snapToEdges: Bool
|
public var snapToEdges: Bool
|
||||||
public var compactTitleProgress: CompactTitleProgress
|
public var compactTitleProgress: CompactTitleProgress
|
||||||
public var miruroVideoProvider: String
|
public var miruroVideoProvider: String
|
||||||
public var miruroCaptionProvider: String
|
public var miruroCaptionProvider: String
|
||||||
public var keys: KeyBindings
|
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 = "",
|
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||||
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||||
self.showBufferInfo = showBufferInfo
|
self.showBufferInfo = showBufferInfo
|
||||||
self.automaticQuality = automaticQuality
|
self.automaticQuality = automaticQuality
|
||||||
|
self.sponsorBlock = sponsorBlock
|
||||||
self.snapToEdges = snapToEdges
|
self.snapToEdges = snapToEdges
|
||||||
self.compactTitleProgress = compactTitleProgress
|
self.compactTitleProgress = compactTitleProgress
|
||||||
self.miruroVideoProvider = miruroVideoProvider
|
self.miruroVideoProvider = miruroVideoProvider
|
||||||
@@ -33,6 +36,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
var lines = [
|
var lines = [
|
||||||
"[playback]",
|
"[playback]",
|
||||||
"automatic_quality = \(defaults.automaticQuality)",
|
"automatic_quality = \(defaults.automaticQuality)",
|
||||||
|
"sponsorblock = \(defaults.sponsorBlock)",
|
||||||
"", "[miruro]",
|
"", "[miruro]",
|
||||||
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
||||||
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
||||||
@@ -91,6 +95,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
||||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||||
|
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
||||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||||
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||||
@@ -175,10 +180,12 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
|
|
||||||
private struct PlaybackConfiguration {
|
private struct PlaybackConfiguration {
|
||||||
let automaticQuality: Bool?
|
let automaticQuality: Bool?
|
||||||
|
let sponsorBlock: Bool?
|
||||||
|
|
||||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
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"))
|
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||||
|
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user