feat: add opening/ending autoskip and correct sponsor hover anchors

Sponsor hover labels used the overlapping chapter midpoint; retain chapter-title lookup but anchor the label to the sponsored range midpoint.
This commit is contained in:
2026-10-25 22:12:08 +01:00
parent a604810319
commit 599e2f548a
6 changed files with 143 additions and 27 deletions
+12
View File
@@ -206,6 +206,17 @@ struct PlayerMiruroMenu: View {
}
}
@MainActor
struct PlayerOpeningEndingSkipToggle: View {
@ObservedObject var model: PlayerModel
var body: some View {
Toggle("Automatically Skip Openings and Endings", isOn: Binding(
get: { model.openingEndingAutoSkip },
set: { model.setOpeningEndingAutoSkip($0) }))
}
}
@MainActor
struct PlayerSponsorBlockMenu: View {
@ObservedObject var model: PlayerModel
@@ -337,6 +348,7 @@ struct PlayerCommands: Commands {
}
}
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
PlayerOpeningEndingSkipToggle(model: model)
PlayerSponsorBlockMenu(model: model)
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo)
+31 -14
View File
@@ -77,7 +77,8 @@ final class PlayerModel: ObservableObject {
private var metadataTask: Task<Void, Never>?
@Published private(set) var sponsorBlockEnabled: Bool
@Published private(set) var sponsorBlockAutoSkip: Bool
private var sponsorSeekPosition: Double?
@Published private(set) var openingEndingAutoSkip: Bool
private var segmentSeekPosition: Double?
private let preferenceDefaults: UserDefaults
private var sponsorVideoID: String?
private var sponsorGeneration: UInt64 = 0
@@ -142,9 +143,12 @@ 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 {
var automaticRange = sponsorBlockAutoSkip ? sponsorToSkip : nil
if automaticRange == nil, openingEndingAutoSkip, let interval = currentSkipInterval {
automaticRange = interval.start...interval.end
}
if let range = automaticRange, state.status == PLAYER_PLAYING, effectivePlaying, !state.buffering,
segmentSeekPosition.map({ $0 >= range.lowerBound - 5 && $0 < range.upperBound }) != true {
_ = seek(to: range.upperBound, userInitiated: false)
}
guard let interval = currentSkipInterval else {
@@ -291,6 +295,7 @@ final class PlayerModel: ObservableObject {
preferenceDefaults = defaults
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
sponsorBlockAutoSkip = (defaults.object(forKey: "sponsorBlockAutoSkip") as? Bool) ?? configuration.sponsorBlockAutoSkip
openingEndingAutoSkip = (defaults.object(forKey: "openingEndingAutoSkip") as? Bool) ?? configuration.openingEndingAutoSkip
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
@@ -920,6 +925,11 @@ final class PlayerModel: ObservableObject {
preferenceDefaults.set(enabled, forKey: "sponsorBlockAutoSkip")
}
func setOpeningEndingAutoSkip(_ enabled: Bool) {
openingEndingAutoSkip = enabled
preferenceDefaults.set(enabled, forKey: "openingEndingAutoSkip")
}
private func updateSponsorRanges() {
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
}
@@ -1105,7 +1115,7 @@ final class PlayerModel: ObservableObject {
window?.title = fileName ?? "Terebi"
revealControls()
replaceMetadata(for: source)
if !change.isQuality { sponsorSeekPosition = savedPosition }
if !change.isQuality { segmentSeekPosition = savedPosition }
systemPlayback?.update()
}
@@ -1285,12 +1295,12 @@ final class PlayerModel: ObservableObject {
guard canSeek, let handle, target.isFinite else { return false }
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth()
let previousSeek = sponsorSeekPosition
let previousSeek = segmentSeekPosition
// Record intent before Cast can synchronously publish a new state.
if userInitiated { sponsorSeekPosition = target }
if userInitiated { segmentSeekPosition = target }
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
if accepted { seekPosition = target }
else { sponsorSeekPosition = previousSeek }
else { segmentSeekPosition = previousSeek }
commandError = accepted ? nil : "Could not queue the seek. Please try again."
if !accepted { revealControls() }
return accepted
@@ -1300,7 +1310,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 }
if accepted { segmentSeekPosition = state.position }
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
return accepted
@@ -2611,8 +2621,10 @@ 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,
sponsored: cell.sponsoredRanges.contains { $0.lowerBound <= time && time < $0.upperBound }))
let sponsor = cell.sponsoredRanges.first { $0.lowerBound <= time && time < $0.upperBound }
let anchor = sponsor.map { ($0.lowerBound + $0.upperBound) / 2 } ?? middle
setHover(Hover(time: middle, x: start + (anchor - minValue) / (maxValue - minValue) * width,
sponsored: sponsor != nil))
}
func refreshHover() {
@@ -2842,14 +2854,18 @@ func playbackSliderHitCheck() -> Bool {
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)] {
for (time, middle, anchor, sponsored) in [
(0.0, 12.5, 12.5, false), (20.0, 12.5, 25.0, true),
(25.0, 50.0, 25.0, true), (30.0, 50.0, 25.0, true),
(40.0, 50.0, 50.0, false), (75.0, 87.5, 87.5, false), (100.0, 87.5, 87.5, false)
] {
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
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 }
hovered?.sponsored == sponsored,
abs(hovered!.x - (left + anchor / 100 * (right - left))) < 0.5 else { return false }
}
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
@@ -3378,6 +3394,7 @@ private struct PlayerContent: View {
Divider()
PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model)
PlayerOpeningEndingSkipToggle(model: model)
PlayerSponsorBlockMenu(model: model)
Divider()
if model.isCompact {