feat: make sponsored blocks skippable
This commit is contained in:
@@ -136,6 +136,15 @@ struct ConfigurationChecks {
|
||||
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled),
|
||||
"SponsorBlock opt-in and explicit opt-out load without changing other defaults")
|
||||
}
|
||||
for enabled in [true, false] {
|
||||
try Data("[playback]\nsponsorblock_autoskip = \(enabled)\n".utf8).write(to: sponsor)
|
||||
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlockAutoSkip: enabled),
|
||||
"sponsor autoskip loads independently without enabling network lookups")
|
||||
}
|
||||
for value in ["\"true\"", "1", "[]"] {
|
||||
try Data("[playback]\nsponsorblock_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||
try requireLoadError(at: sponsor, "non-boolean sponsor autoskip", containing: ["sponsorblock_autoskip"])
|
||||
}
|
||||
let miruro = root.appendingPathComponent("miruro.toml")
|
||||
for (settings, video, captions) in [
|
||||
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
||||
|
||||
@@ -134,23 +134,28 @@ struct ResolvedPlaybackCheck {
|
||||
let suite = "terebi.sponsor-preference." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
func launch(_ configured: Bool = false) -> PlayerModel {
|
||||
PlayerModel(defaults: UserDefaults(suiteName: suite)!, configuration: .init(sponsorBlock: configured))
|
||||
func launch(_ configured: Bool = false, autoSkip: Bool = false) -> PlayerModel {
|
||||
PlayerModel(defaults: UserDefaults(suiteName: suite)!,
|
||||
configuration: .init(sponsorBlock: configured, sponsorBlockAutoSkip: autoSkip))
|
||||
}
|
||||
let first = launch()
|
||||
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
|
||||
require(!first.sponsorBlockAutoSkip, "sponsors are not skipped without opt-in")
|
||||
first.setSponsorBlockAutoSkip(true)
|
||||
first.setSponsorBlockEnabled(true)
|
||||
first.shutdown()
|
||||
let second = launch()
|
||||
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
|
||||
require(second.sponsorBlockAutoSkip, "autoskip preference survives restart")
|
||||
second.setSponsorBlockAutoSkip(false)
|
||||
second.setSponsorBlockEnabled(false)
|
||||
second.shutdown()
|
||||
let third = launch(true)
|
||||
require(!third.sponsorBlockEnabled, "saved disabled preference survives restart and overrides config")
|
||||
let third = launch(true, autoSkip: true)
|
||||
require(!third.sponsorBlockEnabled && !third.sponsorBlockAutoSkip, "saved disabled preferences override config")
|
||||
third.shutdown()
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configured = launch(true)
|
||||
require(configured.sponsorBlockEnabled, "config supplies the initial choice without a saved preference")
|
||||
let configured = launch(true, autoSkip: true)
|
||||
require(configured.sponsorBlockEnabled && configured.sponsorBlockAutoSkip, "config supplies unsaved choices")
|
||||
configured.shutdown()
|
||||
print("PASS: SponsorBlock preference persists enable/disable across launches, with config fallback")
|
||||
}
|
||||
@@ -167,6 +172,7 @@ struct ResolvedPlaybackCheck {
|
||||
var extraction: CheckedContinuation<YouTubeVideo, Never>?
|
||||
var held: [CheckedContinuation<[SponsorSegment], Never>] = []
|
||||
var extractionCount = 0
|
||||
let clock = Clock()
|
||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||
extractionCount += 1
|
||||
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
|
||||
@@ -177,7 +183,7 @@ struct ResolvedPlaybackCheck {
|
||||
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
|
||||
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
|
||||
return await withCheckedContinuation { held.append($0) }
|
||||
})
|
||||
}, skipClock: { clock.now })
|
||||
defer { model.shutdown() }
|
||||
model.open(url)
|
||||
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
|
||||
@@ -199,7 +205,34 @@ struct ResolvedPlaybackCheck {
|
||||
try await until("sponsor seek acknowledged without automatic skipping", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||
}
|
||||
require(model.segmentToSkip == nil, "sponsors never offer Miruro skip actions")
|
||||
clock.now += 60
|
||||
require(model.skipSegment(), "sponsor skip stays available beyond the opening/ending timeout")
|
||||
try await until("manual sponsor skip reaches range end while paused", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 1.5) < 0.1
|
||||
}
|
||||
require(!model.effectivePlaying, "manual sponsor skip preserves paused intent")
|
||||
model.setSponsorBlockAutoSkip(true)
|
||||
require(model.seekRelative(0.75 - model.state.position), "deliberately revisit sponsor with autoskip enabled")
|
||||
try await until("intentional seek into sponsor settles", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||
}
|
||||
require(model.setPlayback(playing: true), "play deliberately selected sponsor")
|
||||
try await until("deliberately selected sponsor plays", model: model) { model.state.position >= 1 }
|
||||
require(model.state.position < 1.5, "autoskip does not fight a seek into the sponsor")
|
||||
require(model.setPlayback(playing: false), "pause before seeking just before sponsor")
|
||||
try await until("sponsor paused again", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
model.isSeeking = true
|
||||
model.seekPosition = 0.25
|
||||
model.finishSeeking()
|
||||
try await until("slider seek just before sponsor settles", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.25) < 0.1
|
||||
}
|
||||
require(model.setPlayback(playing: true), "play from just before sponsor")
|
||||
try await until("sponsor entered after nearby slider seek", model: model) { model.state.position >= 0.9 }
|
||||
require(model.state.position < 1.5, "autoskip does not fight a nearby slider seek")
|
||||
require(model.skipButtonTitle == "Skip Sponsor", "suppressed autoskip keeps the manual button")
|
||||
require(model.setPlayback(playing: false), "pause before metadata lifecycle checks")
|
||||
try await until("sponsor lifecycle playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
model.setSponsorBlockEnabled(false)
|
||||
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
|
||||
model.setSponsorBlockEnabled(true)
|
||||
@@ -247,7 +280,28 @@ struct ResolvedPlaybackCheck {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
||||
}
|
||||
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
|
||||
print("PASS: SponsorBlock opt-in, independent lookup, crimson-only ranges, quality reuse, same-video cancellation, close, generic sites and timeout isolation")
|
||||
print("PASS: persistent manual sponsor skip, deliberate-seek protection, opt-in, quality reuse, cancellation and failure isolation")
|
||||
try await sponsorAutoSkip(source: source)
|
||||
}
|
||||
|
||||
static func sponsorAutoSkip(source: ResolvedSource) async throws {
|
||||
let suite = "terebi.sponsor-autoskip." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults,
|
||||
configuration: .init(sponsorBlock: true, sponsorBlockAutoSkip: true),
|
||||
youtubeResolver: { url, _ in
|
||||
YouTubeVideo(title: "Autoskip fixture", video: .init(url: source.url, headers: source.headers),
|
||||
audio: nil, duration: 0, isLive: false, webpageURL: url, height: 720, availableHeights: [720], chapters: [])
|
||||
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors })
|
||||
defer { model.shutdown() }
|
||||
model.open(URL(string: "https://www.youtube.com/watch?v=CCCCCCCCCCC")!)
|
||||
try await until("natural sponsor entry queues exact-end autoskip", model: model) { model.seekPosition == 1.5 }
|
||||
try await until("native player acknowledges autoskip and keeps playing", model: model) {
|
||||
!model.state.seek_pending && model.state.position >= 1.5 && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
require(model.effectivePlaying, "autoskip preserves playing intent")
|
||||
print("PASS: natural sponsor entry automatically seeks to range end in the native player")
|
||||
}
|
||||
|
||||
static var fixtureChapters: [YouTubeChapter] {
|
||||
|
||||
@@ -26,7 +26,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
|
||||
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
||||
- **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title; accessibility also identifies sponsored ranges. This is visual information only: it never seeks or skips.
|
||||
- **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title; accessibility also identifies sponsored ranges. Inside a range, **Skip Sponsor** uses the opening/ending button without a timeout or countdown fill. Clicking seeks to the range's end; automatic skipping is a separate opt-in.
|
||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
|
||||
@@ -106,25 +106,28 @@ The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME
|
||||
terebi --default-config
|
||||
```
|
||||
|
||||
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except SponsorBlock, whose saved preference overrides its startup config setting.
|
||||
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except the SponsorBlock toggles, whose saved preferences override their startup config settings.
|
||||
|
||||
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
|
||||
|
||||
### SponsorBlock
|
||||
|
||||
SponsorBlock is **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** saves your choice across launches. A saved choice overrides the config setting; without one, the startup setting applies:
|
||||
SponsorBlock and automatic sponsor skipping are **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** enables lookups, markers and manual skipping; **Automatically Skip Sponsors** separately opts into automatic skipping. Both choices are saved across launches. Saved choices override their respective config settings; without them, the startup settings apply:
|
||||
|
||||
```toml
|
||||
[playback]
|
||||
sponsorblock = true
|
||||
sponsorblock_autoskip = false
|
||||
```
|
||||
|
||||
Use `false` (the generated default) or omit the setting to default to disabled when no preference is saved; non-boolean values are rejected. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
|
||||
Use `false` (the generated defaults) or omit these settings to default to disabled when no preference is saved; non-boolean values are rejected. Autoskip alone does not enable SponsorBlock lookups. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
|
||||
|
||||
Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports.
|
||||
|
||||
Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display.
|
||||
|
||||
Autoskip seeks to the end of a sponsored range during playing, nonbuffering video playback, not while paused or a seek is pending. Seeking into a range or up to **five seconds before its start** leaves that range manual-only for that visit, including when playback resumes or metadata arrives later. A later seek replaces that intent; quality changes preserve it. Saved-position restoration gets the same protection. The manual button remains available throughout the range.
|
||||
|
||||
### Miruro providers
|
||||
|
||||
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
|
||||
@@ -158,7 +161,7 @@ Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`.
|
||||
|
||||
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
||||
|
||||
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
||||
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes persistent manual sponsor skips, native autoskip, deliberate relative/slider seek protection, saved preferences, opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
||||
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
||||
|
||||
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var sponsorBlock: Bool
|
||||
public var sponsorBlockAutoSkip: Bool
|
||||
public var snapToEdges: Bool
|
||||
public var compactTitleProgress: CompactTitleProgress
|
||||
public var miruroVideoProvider: String
|
||||
@@ -16,12 +17,13 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var keys: KeyBindings
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
||||
snapToEdges: Bool = true,
|
||||
sponsorBlockAutoSkip: Bool = false, snapToEdges: Bool = true,
|
||||
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.sponsorBlock = sponsorBlock
|
||||
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
|
||||
self.snapToEdges = snapToEdges
|
||||
self.compactTitleProgress = compactTitleProgress
|
||||
self.miruroVideoProvider = miruroVideoProvider
|
||||
@@ -37,6 +39,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
"[playback]",
|
||||
"automatic_quality = \(defaults.automaticQuality)",
|
||||
"sponsorblock = \(defaults.sponsorBlock)",
|
||||
"sponsorblock_autoskip = \(defaults.sponsorBlockAutoSkip)",
|
||||
"", "[miruro]",
|
||||
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
||||
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
||||
@@ -96,6 +99,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
||||
sponsorBlockAutoSkip: values.playback?.sponsorBlockAutoSkip ?? false,
|
||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||
@@ -181,11 +185,13 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
private struct PlaybackConfiguration {
|
||||
let automaticQuality: Bool?
|
||||
let sponsorBlock: Bool?
|
||||
let sponsorBlockAutoSkip: Bool?
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
try validate(container, allowed: ["automatic_quality", "sponsorblock"])
|
||||
try validate(container, allowed: ["automatic_quality", "sponsorblock", "sponsorblock_autoskip"])
|
||||
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
||||
sponsorBlockAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock_autoskip"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
- [ ] make sponsored blocks skippable:
|
||||
- [x] make sponsored blocks skippable:
|
||||
- display the skip button used for openings and endings but for sponsored blocks it shouldn't disappear after a timeout
|
||||
- add a setting for autoskipping (shouldn't activate if seeking onto the sponsored segment (or just before it) to avoid fighting user intention)
|
||||
- add a setting for autoskipping (shouldn't activate if seeking onto the sponsored segment (or just before it) to avoid fighting user intention)
|
||||
|
||||
### Terebi handoff: restore opening/ending metadata for ZokoAnime
|
||||
|
||||
**Finding:** ZokoAnime already returns episode-relative timestamps in its decoded configuration:
|
||||
```json
|
||||
"skip": {
|
||||
"intro": {"start": 49, "end": 144},
|
||||
"outro": {"start": 1357, "end": 1451}
|
||||
}
|
||||
```
|
||||
Values are seconds. Verified on Cowboy Bebop episode 1—including `/stream/mal/1/1/sub`, the actual resolver path—and Attack on Titan S3 Part 2 episode 1.
|
||||
|
||||
**Current gaps**
|
||||
- `ZokoResolver.swift`: `parse` decodes only `src` and `subtitles`, discarding `skip`.
|
||||
- `TerebiApp.swift`: `replaceMetadata(for:)` only acquires intervals for `miruro:` identities.
|
||||
|
||||
**Requested change:** retain optional provider intervals through resolution/source preparation and feed the existing metadata/display lifecycle for `anilist:` identities. No Tubular changes, Miruro lookup, or external timing service needed.
|
||||
|
||||
**Acceptance:** preserve fractional seconds; validate finite, nonnegative, ordered boundaries against actual media duration; missing/malformed intervals must not block playback. Clear stale intervals on media replacement and retain cancellation/generation guards. Cover direct launches, queues, and saved-episode reopening without changing existing display/skip policy.
|
||||
|
||||
**Evidence:** [research note](docs/miruro-segments.md#current-provider-investigation-2026-10-25). Metadata verified; player rendering and timestamp alignment were not.
|
||||
Reference in New Issue
Block a user