feat: show optional SponsorBlock ranges on playback timeline
This commit is contained in:
+132
-15
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user