feat: make sponsored blocks skippable

This commit is contained in:
2026-10-25 21:35:46 +01:00
parent 7052fe29af
commit a604810319
7 changed files with 170 additions and 43 deletions
+5 -1
View File
@@ -215,8 +215,12 @@ struct PlayerSponsorBlockMenu: View {
Toggle("Show Sponsored Ranges", isOn: Binding(
get: { model.sponsorBlockEnabled },
set: { model.setSponsorBlockEnabled($0) }))
Toggle("Automatically Skip Sponsors", isOn: Binding(
get: { model.sponsorBlockAutoSkip },
set: { model.setSponsorBlockAutoSkip($0) }))
.disabled(!model.sponsorBlockEnabled)
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
Text("Reduces disclosure; not anonymous. Display only, never skips.")
Text("Reduces disclosure; not anonymous.")
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
Menu("Acknowledgements") {
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
+54 -24
View File
@@ -76,6 +76,8 @@ final class PlayerModel: ObservableObject {
private var metadataLifecycle = MetadataLifecycle()
private var metadataTask: Task<Void, Never>?
@Published private(set) var sponsorBlockEnabled: Bool
@Published private(set) var sponsorBlockAutoSkip: Bool
private var sponsorSeekPosition: Double?
private let preferenceDefaults: UserDefaults
private var sponsorVideoID: String?
private var sponsorGeneration: UInt64 = 0
@@ -113,11 +115,22 @@ final class PlayerModel: ObservableObject {
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
private var offeredSegments: Set<MiruroInterval.Kind> = []
private let skipClock: () -> Double
private var canSkipSegment: Bool {
canSeek && state.has_video && !isSeeking && !transition.seekPending && !state.seek_pending &&
(state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED)
}
private var currentSkipInterval: MiruroInterval? {
guard canSeek, state.has_video, !isSeeking, !transition.seekPending, !state.seek_pending,
state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED else { return nil }
guard canSkipSegment else { return nil }
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
}
private var sponsorToSkip: ClosedRange<Double>? {
guard canSkipSegment else { return nil }
return sponsoredRanges.first { state.position >= $0.lowerBound && state.position < $0.upperBound }
}
var skipButtonTitle: String? {
if sponsorToSkip != nil { return "Skip Sponsor" }
return segmentToSkip.map { $0.kind == .intro ? "Skip Opening" : "Skip Ending" }
}
var segmentToSkip: MiruroInterval? {
guard let skipOffer, skipClock() < skipOffer.deadline,
currentSkipInterval == skipOffer.interval else { return nil }
@@ -129,6 +142,11 @@ final class PlayerModel: ObservableObject {
}
private func updateSegmentSkip() {
if sponsorBlockAutoSkip, state.status == PLAYER_PLAYING, effectivePlaying, !state.buffering,
let range = sponsorToSkip,
sponsorSeekPosition.map({ $0 >= range.lowerBound - 5 && $0 < range.upperBound }) != true {
_ = seek(to: range.upperBound, userInitiated: false)
}
guard let interval = currentSkipInterval else {
if skipOffer != nil { skipOffer = nil }
return
@@ -142,7 +160,8 @@ final class PlayerModel: ObservableObject {
@discardableResult
func skipSegment() -> Bool {
guard let interval = segmentToSkip, seekRelative(interval.end - state.position) else { return false }
guard let end = sponsorToSkip?.upperBound ?? segmentToSkip?.end,
seek(to: end, userInitiated: false) else { return false }
skipOffer = nil
return true
}
@@ -271,6 +290,7 @@ final class PlayerModel: ObservableObject {
showsBufferInfo = configuration.showBufferInfo
preferenceDefaults = defaults
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
sponsorBlockAutoSkip = (defaults.object(forKey: "sponsorBlockAutoSkip") as? Bool) ?? configuration.sponsorBlockAutoSkip
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
@@ -895,6 +915,11 @@ final class PlayerModel: ObservableObject {
replaceSponsorMetadata(videoID: sponsorVideoID)
}
func setSponsorBlockAutoSkip(_ enabled: Bool) {
sponsorBlockAutoSkip = enabled
preferenceDefaults.set(enabled, forKey: "sponsorBlockAutoSkip")
}
private func updateSponsorRanges() {
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
}
@@ -1080,6 +1105,7 @@ final class PlayerModel: ObservableObject {
window?.title = fileName ?? "Terebi"
revealControls()
replaceMetadata(for: source)
if !change.isQuality { sponsorSeekPosition = savedPosition }
systemPlayback?.update()
}
@@ -1203,14 +1229,7 @@ final class PlayerModel: ObservableObject {
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
if casting.active {
seekCast(to: min(max(0, seekPosition), duration))
return
}
let position = min(max(0, seekPosition), duration)
let accepted = transition.seek(position, handle: handle)
commandError = accepted ? nil : "Could not queue the seek. Please try again."
_ = seek(to: min(max(0, seekPosition), duration), userInitiated: true)
revealControls()
}
@@ -1257,12 +1276,21 @@ final class PlayerModel: ObservableObject {
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
guard seconds.isFinite else { return false }
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
return seek(to: target, userInitiated: true)
}
private func seek(to target: Double, userInitiated: Bool) -> Bool {
guard canSeek, let handle, target.isFinite else { return false }
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth()
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
let previousSeek = sponsorSeekPosition
// Record intent before Cast can synchronously publish a new state.
if userInitiated { sponsorSeekPosition = target }
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
if accepted { seekPosition = target }
else { sponsorSeekPosition = previousSeek }
commandError = accepted ? nil : "Could not queue the seek. Please try again."
if !accepted { revealControls() }
return accepted
@@ -1272,6 +1300,7 @@ final class PlayerModel: ObservableObject {
func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false }
let accepted = transition.stepFrame(direction, handle: handle)
if accepted { sponsorSeekPosition = state.position }
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
return accepted
@@ -4085,12 +4114,11 @@ private struct PlayerContent: View {
.accessibilityHidden(model.isCompact || !model.controlsVisible)
}
ZStack(alignment: .topTrailing) {
if let interval = model.segmentToSkip {
if let title = model.skipButtonTitle {
Button {
model.skipSegment()
} label: {
Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending",
systemImage: "forward.end.fill")
Label(title, systemImage: "forward.end.fill")
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
.padding(.horizontal, 12)
.padding(.vertical, 8)
@@ -4101,12 +4129,14 @@ private struct PlayerContent: View {
.background {
ZStack(alignment: .leading) {
PlayerGlass(cornerRadius: 8)
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
GeometryReader { geometry in
let elapsed = 1 - model.skipOfferRemainingFraction
Color(SunsetPalette.text).opacity(0.25)
.frame(width: geometry.size.width *
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed))
if model.segmentToSkip != nil {
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
GeometryReader { geometry in
let elapsed = 1 - model.skipOfferRemainingFraction
Color(SunsetPalette.text).opacity(0.25)
.frame(width: geometry.size.width *
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed))
}
}
}
}
@@ -4120,8 +4150,8 @@ private struct PlayerContent: View {
.padding(.trailing, model.isCompact ? 8 : 16)
.padding(.top, 44)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind)
.allowsHitTesting(model.segmentToSkip != nil)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.skipButtonTitle)
.allowsHitTesting(model.skipButtonTitle != nil)
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)