feat: show optional SponsorBlock ranges on playback timeline

This commit is contained in:
2026-10-25 03:01:40 +01:00
parent edb17f76e3
commit b04ed7fdc8
9 changed files with 666 additions and 24 deletions
+132 -15
View File
@@ -29,7 +29,11 @@ final class PlayerModel: ObservableObject {
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
streamQueue: startupStreamQueue)
@Published private(set) var state = PlayerState()
@Published private(set) var state = PlayerState() {
didSet {
if state.duration != oldValue.duration { updateSponsorRanges() }
}
}
@Published private(set) var fileName: String?
@Published var showsYouTubeInput = false
@Published private(set) var fileIcon: NSImage?
@@ -71,6 +75,18 @@ final class PlayerModel: ObservableObject {
private var shouldRecordRecentMedia = false
private var metadataLifecycle = MetadataLifecycle()
private var metadataTask: Task<Void, Never>?
@Published private(set) var sponsorBlockEnabled: Bool
private var sponsorVideoID: String?
private var sponsorGeneration: UInt64 = 0
private var sponsorTask: Task<Void, Never>?
private var sponsorSegments: [SponsorSegment] = []
private var sponsorRanges: [ClosedRange<Double>] = []
var sponsoredRanges: [ClosedRange<Double>] {
guard sponsorBlockEnabled, !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
case .youtube(let video, _) = currentMedia, !video.isLive,
SponsorBlockResolver.videoID(for: video.webpageURL) == sponsorVideoID else { return [] }
return sponsorRanges
}
var visibleIntervals: [MiruroInterval] {
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
@@ -222,6 +238,7 @@ final class PlayerModel: ObservableObject {
private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
private let sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
private var reportingErrorMessage: String {
@@ -232,6 +249,7 @@ final class PlayerModel: ObservableObject {
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
sponsorResolver: @escaping @MainActor (String) async throws -> [SponsorSegment] = { try await SponsorBlockResolver.resolve(videoID: $0) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
@@ -240,12 +258,14 @@ final class PlayerModel: ObservableObject {
self.skipClock = skipClock
self.youtubeResolver = youtubeResolver
self.youtubeTitleResolver = youtubeTitleResolver
self.sponsorResolver = sponsorResolver
self.miruroResolver = miruroResolver
self.miruroMetadataResolver = miruroMetadataResolver
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
sponsorBlockEnabled = configuration.sponsorBlock
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
@@ -716,6 +736,7 @@ final class PlayerModel: ObservableObject {
return
}
objectWillChange.send()
replaceSponsorMetadata(videoID: SponsorBlockResolver.videoID(for: url), reusing: change.isQuality)
let request = transition.beginResolution(change)
systemPlayback?.update()
commandError = nil
@@ -818,7 +839,46 @@ final class PlayerModel: ObservableObject {
queueMetadataTask = (item.id, task)
}
func setSponsorBlockEnabled(_ enabled: Bool) {
guard sponsorBlockEnabled != enabled else { return }
sponsorBlockEnabled = enabled
replaceSponsorMetadata(videoID: sponsorVideoID)
}
private func updateSponsorRanges() {
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
}
private func replaceSponsorMetadata(videoID: String?, reusing: Bool = false) {
if reusing, sponsorVideoID == videoID { return }
sponsorTask?.cancel()
sponsorTask = nil
sponsorGeneration &+= 1
sponsorVideoID = videoID
sponsorSegments = []
updateSponsorRanges()
guard sponsorBlockEnabled, let videoID else { return }
let generation = sponsorGeneration
let resolver = sponsorResolver
sponsorTask = Task { [weak self] in
// Optional enrichment never changes the playback resolution/error state.
let segments = (try? await resolver(videoID)) ?? []
guard !Task.isCancelled, let self,
self.sponsorVideoID == videoID, self.sponsorGeneration == generation else { return }
self.objectWillChange.send()
self.sponsorSegments = segments
self.updateSponsorRanges()
self.sponsorTask = nil
}
}
private func replaceMetadata(for source: MediaSource?) {
if case .youtube(let video, _) = source {
replaceSponsorMetadata(videoID: video.isLive ? nil : SponsorBlockResolver.videoID(for: video.webpageURL),
reusing: true)
} else {
replaceSponsorMetadata(videoID: nil)
}
metadataTask?.cancel()
metadataTask = nil
if source?.url.absoluteString != metadataLifecycle.identity {
@@ -2271,6 +2331,7 @@ private enum SunsetPalette {
static let actionFill = accent.withAlphaComponent(0.08)
static let track = secondaryText.withAlphaComponent(0.18)
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
static let sponsor = NSColor(srgbRed: 220.0 / 255, green: 20.0 / 255, blue: 60.0 / 255, alpha: 1)
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
}
@@ -2354,9 +2415,11 @@ private struct PlaybackSlider: NSViewRepresentable {
var enabled = true
var bufferedRange: ClosedRange<Double>?
var boundaries: [Double] = []
var sponsoredRanges: [ClosedRange<Double>] = []
struct Hover: Equatable {
let time: Double
let x: CGFloat
var sponsored = false
}
var onHover: (Hover?) -> Void = { _ in }
var onEditingChanged: (Bool) -> Void = { _ in }
@@ -2394,6 +2457,11 @@ private struct PlaybackSlider: NSViewRepresentable {
cell.bufferedRange = bufferedRange
slider.needsDisplay = true
}
if cell.sponsoredRanges != sponsoredRanges {
cell.sponsoredRanges = sponsoredRanges
slider.needsDisplay = true
DispatchQueue.main.async { [weak slider] in slider?.refreshHover() }
}
}
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
}
@@ -2464,7 +2532,13 @@ private struct PlaybackSlider: NSViewRepresentable {
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
let middle = (lower + upper) / 2
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width))
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width,
sponsored: cell.sponsoredRanges.contains { $0.lowerBound <= time && time < $0.upperBound }))
}
func refreshHover() {
guard hover != nil, let window else { return }
updateHover(at: convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
override func mouseEntered(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
@@ -2557,6 +2631,7 @@ private struct PlaybackSlider: NSViewRepresentable {
var volume = false
var bufferedRange: ClosedRange<Double>?
var boundaries: [Double] = []
var sponsoredRanges: [ClosedRange<Double>] = []
var emphasis: [Double] = []
func segment(at time: Double) -> Int {
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
@@ -2609,6 +2684,15 @@ private struct PlaybackSlider: NSViewRepresentable {
SunsetPalette.bufferedTrack.setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
if !volume, maxValue > minValue {
// Reuse the chapter-shaped clip: sponsorship paints the track, never adds gaps.
SunsetPalette.sponsor.setFill()
let scale = bar.width / (maxValue - minValue)
for range in sponsoredRanges {
NSRect(x: bar.minX + (range.lowerBound - minValue) * scale, y: bar.minY - 1.5,
width: (range.upperBound - range.lowerBound) * scale, height: 6).fill()
}
}
}
override func drawKnob(_ rect: NSRect) {
let knob = visibleKnobRect(rect)
@@ -2656,6 +2740,28 @@ func playbackSliderHitCheck() -> Bool {
NSGraphicsContext.restoreGraphicsState()
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
bitmap.colorAt(x: 140, y: 4)!.alphaComponent == 0 else { return false }
cell.sponsoredRanges = [10...40]
cell.bufferedRange = 0...100
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
NSGraphicsContext.current!.cgContext.clear(NSRect(x: 0, y: 0, width: 280, height: 14))
cell.drawBar(inside: NSRect(x: 0, y: 0, width: 280, height: 14), flipped: false)
NSGraphicsContext.restoreGraphicsState()
func pixel(_ time: Double, y: Int = 7) -> NSColor {
let x = Int((cell.knobThickness / 2 + time / 100 * (280 - cell.knobThickness)).rounded())
return bitmap.colorAt(x: x, y: y)!.usingColorSpace(.sRGB)!
}
for time in [20.0, 30.0] {
let color = pixel(time)
guard abs(color.redComponent * 255 - 220) < 2,
abs(color.greenComponent * 255 - 20) < 2,
abs(color.blueComponent * 255 - 60) < 2 else { return false }
}
guard pixel(25).alphaComponent == 0, // A sponsor crossing a chapter still preserves its gap.
pixel(20, y: 4).alphaComponent > 0, pixel(30, y: 4).alphaComponent == 0,
pixel(5).redComponent != pixel(20).redComponent,
pixel(50).redComponent != pixel(30).redComponent,
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
cell.emphasis = []
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] {
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
@@ -2663,6 +2769,7 @@ func playbackSliderHitCheck() -> Bool {
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
slider.mouseMoved(with: event)
guard hovered?.time == middle,
hovered?.sponsored == (time == 25),
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
}
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
@@ -3192,6 +3299,7 @@ private struct PlayerContent: View {
Divider()
PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model)
PlayerSponsorBlockMenu(model: model)
Divider()
if model.isCompact {
Menu("Subtitles") {
@@ -3261,7 +3369,11 @@ private struct PlayerContent: View {
let intervalStatus = intervals.map {
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
}.joined(separator: ", ")
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus].compactMap { $0 }
let sponsors = model.sponsoredRanges
let sponsorStatus = sponsors.map { "Sponsor \(time($0.lowerBound))–\(time($0.upperBound))" }
.joined(separator: ", ")
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus,
sponsorStatus.isEmpty ? nil : sponsorStatus].compactMap { $0 }
.joined(separator: ", ")
return VStack(spacing: 8) {
HStack(spacing: 0) {
@@ -3311,23 +3423,28 @@ private struct PlayerContent: View {
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
sponsoredRanges: sponsors,
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.overlay(alignment: .topLeading) {
if let hover = hoveredSliderSegment, let title = model.sliderSegmentTitle(at: hover.time) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.lineLimit(1)
.padding(.horizontal, 8).padding(.vertical, 5)
.background { PlayerGlass(cornerRadius: 6) }
.fixedSize()
.position(x: hover.x, y: -16)
.transition(.opacity)
.id(hover.time)
.allowsHitTesting(false)
if let hover = hoveredSliderSegment {
let chapter = model.sliderSegmentTitle(at: hover.time)
let sponsored = hover.sponsored && !sponsors.isEmpty
if let title = sponsored ? chapter.map({ "Sponsor · " + $0 }) ?? "Sponsor" : chapter {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.lineLimit(1)
.padding(.horizontal, 8).padding(.vertical, 5)
.background { PlayerGlass(cornerRadius: 6) }
.fixedSize()
.position(x: hover.x, y: -16)
.transition(.opacity)
.id(hover.time)
.allowsHitTesting(false)
}
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: hoveredSliderSegment)