feat: make sponsored blocks skippable
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user