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:
@@ -145,6 +145,15 @@ struct ConfigurationChecks {
|
|||||||
try Data("[playback]\nsponsorblock_autoskip = \(value)\n".utf8).write(to: sponsor)
|
try Data("[playback]\nsponsorblock_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||||
try requireLoadError(at: sponsor, "non-boolean sponsor autoskip", containing: ["sponsorblock_autoskip"])
|
try requireLoadError(at: sponsor, "non-boolean sponsor autoskip", containing: ["sponsorblock_autoskip"])
|
||||||
}
|
}
|
||||||
|
for enabled in [true, false] {
|
||||||
|
try Data("[playback]\nopening_ending_autoskip = \(enabled)\n".utf8).write(to: sponsor)
|
||||||
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(openingEndingAutoSkip: enabled),
|
||||||
|
"opening/ending autoskip is independent of SponsorBlock")
|
||||||
|
}
|
||||||
|
for value in ["\"true\"", "1", "[]"] {
|
||||||
|
try Data("[playback]\nopening_ending_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||||
|
try requireLoadError(at: sponsor, "non-boolean opening/ending autoskip", containing: ["opening_ending_autoskip"])
|
||||||
|
}
|
||||||
let miruro = root.appendingPathComponent("miruro.toml")
|
let miruro = root.appendingPathComponent("miruro.toml")
|
||||||
for (settings, video, captions) in [
|
for (settings, video, captions) in [
|
||||||
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
if !input.hasPrefix("https://") {
|
if !input.hasPrefix("https://") {
|
||||||
try await transportProtocol(source: source)
|
try await transportProtocol(source: source)
|
||||||
model.shutdown()
|
model.shutdown()
|
||||||
|
try await automaticSegmentSkips(file: file)
|
||||||
try await providerLifecycle(source: source)
|
try await providerLifecycle(source: source)
|
||||||
try await dubOnlySelection(source: source)
|
try await dubOnlySelection(source: source)
|
||||||
try await queueMetadataLifecycle(source: source)
|
try await queueMetadataLifecycle(source: source)
|
||||||
@@ -134,30 +135,37 @@ struct ResolvedPlaybackCheck {
|
|||||||
let suite = "terebi.sponsor-preference." + UUID().uuidString
|
let suite = "terebi.sponsor-preference." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
func launch(_ configured: Bool = false, autoSkip: Bool = false) -> PlayerModel {
|
func launch(_ configured: Bool = false, autoSkip: Bool = false, openingEnding: Bool = false) -> PlayerModel {
|
||||||
PlayerModel(defaults: UserDefaults(suiteName: suite)!,
|
PlayerModel(defaults: UserDefaults(suiteName: suite)!,
|
||||||
configuration: .init(sponsorBlock: configured, sponsorBlockAutoSkip: autoSkip))
|
configuration: .init(sponsorBlock: configured, sponsorBlockAutoSkip: autoSkip,
|
||||||
|
openingEndingAutoSkip: openingEnding))
|
||||||
}
|
}
|
||||||
let first = launch()
|
let first = launch()
|
||||||
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
|
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
|
||||||
require(!first.sponsorBlockAutoSkip, "sponsors are not skipped without opt-in")
|
require(!first.sponsorBlockAutoSkip, "sponsors are not skipped without opt-in")
|
||||||
|
require(!first.openingEndingAutoSkip, "openings and endings are not skipped without opt-in")
|
||||||
|
first.setOpeningEndingAutoSkip(true)
|
||||||
first.setSponsorBlockAutoSkip(true)
|
first.setSponsorBlockAutoSkip(true)
|
||||||
first.setSponsorBlockEnabled(true)
|
first.setSponsorBlockEnabled(true)
|
||||||
first.shutdown()
|
first.shutdown()
|
||||||
let second = launch()
|
let second = launch()
|
||||||
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
|
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
|
||||||
require(second.sponsorBlockAutoSkip, "autoskip preference survives restart")
|
require(second.sponsorBlockAutoSkip, "autoskip preference survives restart")
|
||||||
|
require(second.openingEndingAutoSkip, "opening/ending autoskip preference survives restart")
|
||||||
|
second.setOpeningEndingAutoSkip(false)
|
||||||
second.setSponsorBlockAutoSkip(false)
|
second.setSponsorBlockAutoSkip(false)
|
||||||
second.setSponsorBlockEnabled(false)
|
second.setSponsorBlockEnabled(false)
|
||||||
second.shutdown()
|
second.shutdown()
|
||||||
let third = launch(true, autoSkip: true)
|
let third = launch(true, autoSkip: true, openingEnding: true)
|
||||||
require(!third.sponsorBlockEnabled && !third.sponsorBlockAutoSkip, "saved disabled preferences override config")
|
require(!third.sponsorBlockEnabled && !third.sponsorBlockAutoSkip && !third.openingEndingAutoSkip,
|
||||||
|
"saved disabled skip preferences override config")
|
||||||
third.shutdown()
|
third.shutdown()
|
||||||
defaults.removePersistentDomain(forName: suite)
|
defaults.removePersistentDomain(forName: suite)
|
||||||
let configured = launch(true, autoSkip: true)
|
let configured = launch(true, autoSkip: true, openingEnding: true)
|
||||||
require(configured.sponsorBlockEnabled && configured.sponsorBlockAutoSkip, "config supplies unsaved choices")
|
require(configured.sponsorBlockEnabled && configured.sponsorBlockAutoSkip && configured.openingEndingAutoSkip,
|
||||||
|
"config supplies unsaved skip choices")
|
||||||
configured.shutdown()
|
configured.shutdown()
|
||||||
print("PASS: SponsorBlock preference persists enable/disable across launches, with config fallback")
|
print("PASS: sponsor and opening/ending skip preferences persist across launches, with config fallback")
|
||||||
}
|
}
|
||||||
|
|
||||||
static var fixtureSponsors: [SponsorSegment] {
|
static var fixtureSponsors: [SponsorSegment] {
|
||||||
@@ -1142,4 +1150,56 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(model.effectivePlaying, "opening skip preserves playing intent")
|
require(model.effectivePlaying, "opening skip preserves playing intent")
|
||||||
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func automaticSegmentSkips(file: URL) async throws {
|
||||||
|
for seekTarget: Double? in [nil, 0.75, 0.25] {
|
||||||
|
let suite = "terebi.segment-autoskip." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let model = PlayerModel(defaults: defaults, configuration: .init(openingEndingAutoSkip: true),
|
||||||
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }, skipClock: { 100 })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.openResolvedSource(file)
|
||||||
|
try await until("automatic segment fixture plays", model: model) {
|
||||||
|
model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||||
|
}
|
||||||
|
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
|
||||||
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
||||||
|
if let seekTarget {
|
||||||
|
require(model.setPlayback(playing: false), "pause before intentional opening seek")
|
||||||
|
try await until("automatic segment fixture pauses", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
if seekTarget == 0.25 {
|
||||||
|
model.isSeeking = true
|
||||||
|
model.seekPosition = seekTarget
|
||||||
|
model.finishSeeking()
|
||||||
|
} else {
|
||||||
|
require(model.seekRelative(seekTarget - model.state.position), "seek into opening")
|
||||||
|
}
|
||||||
|
try await until("intentional opening seek settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - seekTarget) < 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.supplyMetadataForCheck(.init(title: nil, intervals: [opening, ending]), episode: 1)
|
||||||
|
if let seekTarget {
|
||||||
|
require(abs(model.seekPosition - seekTarget) < 0.1 && model.state.status == PLAYER_PAUSED,
|
||||||
|
"late metadata does not autoskip paused playback")
|
||||||
|
require(model.setPlayback(playing: true), "resume deliberately selected opening")
|
||||||
|
try await until("deliberately selected opening plays", model: model) { model.state.position >= 1 }
|
||||||
|
require(model.state.position < opening.end && model.skipButtonTitle == "Skip Opening",
|
||||||
|
"opening autoskip respects seeking into or just before it and keeps the manual button")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try await until("opening autoskip queues its exact end", model: model) { model.seekPosition == opening.end }
|
||||||
|
try await until("native player acknowledges opening autoskip", model: model) {
|
||||||
|
!model.state.seek_pending && model.state.position >= opening.end && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
try await until("ending autoskip queues exact media end before playing through ending", model: model) {
|
||||||
|
model.seekPosition == ending.end && model.state.position < ending.end - 0.5
|
||||||
|
}
|
||||||
|
try await until("native player acknowledges ending autoskip", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("PASS: native opening/ending autoskip, paused/late metadata and deliberate relative/slider seek protection")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ 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.
|
- **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.
|
- **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.
|
- **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. 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.
|
- **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, centered over the sponsored range itself even when it crosses chapter boundaries; 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.
|
- **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. Automatic skipping is configurable below.
|
||||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
- **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.
|
- **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.
|
||||||
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
||||||
@@ -106,10 +106,21 @@ The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME
|
|||||||
terebi --default-config
|
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 the SponsorBlock toggles, whose saved preferences override their startup config settings.
|
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 and opening/ending autoskip 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.
|
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.
|
||||||
|
|
||||||
|
### Opening/ending autoskip
|
||||||
|
|
||||||
|
**Playback → Automatically Skip Openings and Endings**, also available in the player's gear menu, skips valid opening/ending intervals to their exact ends. It is **off by default**, independent of SponsorBlock, and saved across launches. Without a saved preference, this startup setting applies:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[playback]
|
||||||
|
opening_ending_autoskip = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `false` or omit the setting to keep automatic skipping disabled; non-boolean values are rejected. Autoskip runs only during playing, nonbuffering video playback, never while paused or a seek is pending. Seeking into an interval or up to **five seconds before its start** keeps it manual-only for that visit, including when metadata arrives later. Quality changes preserve that intent; saved-position restoration gets the same protection. The existing timed manual buttons remain available when autoskip is suppressed. No interval metadata means no automatic skip.
|
||||||
|
|
||||||
### SponsorBlock
|
### SponsorBlock
|
||||||
|
|
||||||
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:
|
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:
|
||||||
@@ -161,7 +172,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.
|
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 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-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 sponsor/opening/ending autoskip, deliberate relative/slider seek protection, paused/late metadata, 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, sponsor-centered hover anchors, buffering, active emphasis 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-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.
|
`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.
|
||||||
|
|||||||
@@ -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
|
@MainActor
|
||||||
struct PlayerSponsorBlockMenu: View {
|
struct PlayerSponsorBlockMenu: View {
|
||||||
@ObservedObject var model: PlayerModel
|
@ObservedObject var model: PlayerModel
|
||||||
@@ -337,6 +348,7 @@ struct PlayerCommands: Commands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||||
|
PlayerOpeningEndingSkipToggle(model: model)
|
||||||
PlayerSponsorBlockMenu(model: model)
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||||
model.performMenuAction(bufferInfo)
|
model.performMenuAction(bufferInfo)
|
||||||
|
|||||||
+31
-14
@@ -77,7 +77,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var metadataTask: Task<Void, Never>?
|
private var metadataTask: Task<Void, Never>?
|
||||||
@Published private(set) var sponsorBlockEnabled: Bool
|
@Published private(set) var sponsorBlockEnabled: Bool
|
||||||
@Published private(set) var sponsorBlockAutoSkip: 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 let preferenceDefaults: UserDefaults
|
||||||
private var sponsorVideoID: String?
|
private var sponsorVideoID: String?
|
||||||
private var sponsorGeneration: UInt64 = 0
|
private var sponsorGeneration: UInt64 = 0
|
||||||
@@ -142,9 +143,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateSegmentSkip() {
|
private func updateSegmentSkip() {
|
||||||
if sponsorBlockAutoSkip, state.status == PLAYER_PLAYING, effectivePlaying, !state.buffering,
|
var automaticRange = sponsorBlockAutoSkip ? sponsorToSkip : nil
|
||||||
let range = sponsorToSkip,
|
if automaticRange == nil, openingEndingAutoSkip, let interval = currentSkipInterval {
|
||||||
sponsorSeekPosition.map({ $0 >= range.lowerBound - 5 && $0 < range.upperBound }) != true {
|
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)
|
_ = seek(to: range.upperBound, userInitiated: false)
|
||||||
}
|
}
|
||||||
guard let interval = currentSkipInterval else {
|
guard let interval = currentSkipInterval else {
|
||||||
@@ -291,6 +295,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
preferenceDefaults = defaults
|
preferenceDefaults = defaults
|
||||||
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
|
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
|
||||||
sponsorBlockAutoSkip = (defaults.object(forKey: "sponsorBlockAutoSkip") as? Bool) ?? configuration.sponsorBlockAutoSkip
|
sponsorBlockAutoSkip = (defaults.object(forKey: "sponsorBlockAutoSkip") as? Bool) ?? configuration.sponsorBlockAutoSkip
|
||||||
|
openingEndingAutoSkip = (defaults.object(forKey: "openingEndingAutoSkip") as? Bool) ?? configuration.openingEndingAutoSkip
|
||||||
miruroVideoProvider = configuration.miruroVideoProvider
|
miruroVideoProvider = configuration.miruroVideoProvider
|
||||||
miruroCaptionProvider = configuration.miruroCaptionProvider
|
miruroCaptionProvider = configuration.miruroCaptionProvider
|
||||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||||||
@@ -920,6 +925,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
preferenceDefaults.set(enabled, forKey: "sponsorBlockAutoSkip")
|
preferenceDefaults.set(enabled, forKey: "sponsorBlockAutoSkip")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setOpeningEndingAutoSkip(_ enabled: Bool) {
|
||||||
|
openingEndingAutoSkip = enabled
|
||||||
|
preferenceDefaults.set(enabled, forKey: "openingEndingAutoSkip")
|
||||||
|
}
|
||||||
|
|
||||||
private func updateSponsorRanges() {
|
private func updateSponsorRanges() {
|
||||||
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
|
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
|
||||||
}
|
}
|
||||||
@@ -1105,7 +1115,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
window?.title = fileName ?? "Terebi"
|
window?.title = fileName ?? "Terebi"
|
||||||
revealControls()
|
revealControls()
|
||||||
replaceMetadata(for: source)
|
replaceMetadata(for: source)
|
||||||
if !change.isQuality { sponsorSeekPosition = savedPosition }
|
if !change.isQuality { segmentSeekPosition = savedPosition }
|
||||||
systemPlayback?.update()
|
systemPlayback?.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,12 +1295,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard canSeek, let handle, target.isFinite else { return false }
|
guard canSeek, let handle, target.isFinite else { return false }
|
||||||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||||
automaticQuality.resetHealth()
|
automaticQuality.resetHealth()
|
||||||
let previousSeek = sponsorSeekPosition
|
let previousSeek = segmentSeekPosition
|
||||||
// Record intent before Cast can synchronously publish a new state.
|
// 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)
|
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
|
||||||
if accepted { seekPosition = target }
|
if accepted { seekPosition = target }
|
||||||
else { sponsorSeekPosition = previousSeek }
|
else { segmentSeekPosition = previousSeek }
|
||||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||||
if !accepted { revealControls() }
|
if !accepted { revealControls() }
|
||||||
return accepted
|
return accepted
|
||||||
@@ -1300,7 +1310,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
func stepFrame(_ direction: Int32) -> Bool {
|
func stepFrame(_ direction: Int32) -> Bool {
|
||||||
guard canStepFrame, let handle else { return false }
|
guard canStepFrame, let handle else { return false }
|
||||||
let accepted = transition.stepFrame(direction, handle: handle)
|
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."
|
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
||||||
revealControls()
|
revealControls()
|
||||||
return accepted
|
return accepted
|
||||||
@@ -2611,8 +2621,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
||||||
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
||||||
let middle = (lower + upper) / 2
|
let middle = (lower + upper) / 2
|
||||||
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width,
|
let sponsor = cell.sponsoredRanges.first { $0.lowerBound <= time && time < $0.upperBound }
|
||||||
sponsored: cell.sponsoredRanges.contains { $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() {
|
func refreshHover() {
|
||||||
@@ -2842,14 +2854,18 @@ func playbackSliderHitCheck() -> Bool {
|
|||||||
pixel(50).redComponent != pixel(30).redComponent,
|
pixel(50).redComponent != pixel(30).redComponent,
|
||||||
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
|
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
|
||||||
cell.emphasis = []
|
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 point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
|
||||||
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
|
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
|
||||||
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
||||||
slider.mouseMoved(with: event)
|
slider.mouseMoved(with: event)
|
||||||
guard hovered?.time == middle,
|
guard hovered?.time == middle,
|
||||||
hovered?.sponsored == (time == 25),
|
hovered?.sponsored == sponsored,
|
||||||
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
|
abs(hovered!.x - (left + anchor / 100 * (right - left))) < 0.5 else { return false }
|
||||||
}
|
}
|
||||||
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
||||||
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
|
||||||
@@ -3378,6 +3394,7 @@ private struct PlayerContent: View {
|
|||||||
Divider()
|
Divider()
|
||||||
PlayerQualityMenu(model: model)
|
PlayerQualityMenu(model: model)
|
||||||
PlayerMiruroMenu(model: model)
|
PlayerMiruroMenu(model: model)
|
||||||
|
PlayerOpeningEndingSkipToggle(model: model)
|
||||||
PlayerSponsorBlockMenu(model: model)
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Divider()
|
Divider()
|
||||||
if model.isCompact {
|
if model.isCompact {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
public var automaticQuality: Bool
|
public var automaticQuality: Bool
|
||||||
public var sponsorBlock: Bool
|
public var sponsorBlock: Bool
|
||||||
public var sponsorBlockAutoSkip: Bool
|
public var sponsorBlockAutoSkip: Bool
|
||||||
|
public var openingEndingAutoSkip: Bool
|
||||||
public var snapToEdges: Bool
|
public var snapToEdges: Bool
|
||||||
public var compactTitleProgress: CompactTitleProgress
|
public var compactTitleProgress: CompactTitleProgress
|
||||||
public var miruroVideoProvider: String
|
public var miruroVideoProvider: String
|
||||||
@@ -17,13 +18,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
public var keys: KeyBindings
|
public var keys: KeyBindings
|
||||||
|
|
||||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
||||||
sponsorBlockAutoSkip: Bool = false, snapToEdges: Bool = true,
|
sponsorBlockAutoSkip: Bool = false, openingEndingAutoSkip: Bool = false, snapToEdges: Bool = true,
|
||||||
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||||
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||||
self.showBufferInfo = showBufferInfo
|
self.showBufferInfo = showBufferInfo
|
||||||
self.automaticQuality = automaticQuality
|
self.automaticQuality = automaticQuality
|
||||||
self.sponsorBlock = sponsorBlock
|
self.sponsorBlock = sponsorBlock
|
||||||
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
|
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
|
||||||
|
self.openingEndingAutoSkip = openingEndingAutoSkip
|
||||||
self.snapToEdges = snapToEdges
|
self.snapToEdges = snapToEdges
|
||||||
self.compactTitleProgress = compactTitleProgress
|
self.compactTitleProgress = compactTitleProgress
|
||||||
self.miruroVideoProvider = miruroVideoProvider
|
self.miruroVideoProvider = miruroVideoProvider
|
||||||
@@ -40,6 +42,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
"automatic_quality = \(defaults.automaticQuality)",
|
"automatic_quality = \(defaults.automaticQuality)",
|
||||||
"sponsorblock = \(defaults.sponsorBlock)",
|
"sponsorblock = \(defaults.sponsorBlock)",
|
||||||
"sponsorblock_autoskip = \(defaults.sponsorBlockAutoSkip)",
|
"sponsorblock_autoskip = \(defaults.sponsorBlockAutoSkip)",
|
||||||
|
"opening_ending_autoskip = \(defaults.openingEndingAutoSkip)",
|
||||||
"", "[miruro]",
|
"", "[miruro]",
|
||||||
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
||||||
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
||||||
@@ -100,6 +103,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||||
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
||||||
sponsorBlockAutoSkip: values.playback?.sponsorBlockAutoSkip ?? false,
|
sponsorBlockAutoSkip: values.playback?.sponsorBlockAutoSkip ?? false,
|
||||||
|
openingEndingAutoSkip: values.playback?.openingEndingAutoSkip ?? false,
|
||||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||||
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||||
@@ -186,12 +190,15 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
let automaticQuality: Bool?
|
let automaticQuality: Bool?
|
||||||
let sponsorBlock: Bool?
|
let sponsorBlock: Bool?
|
||||||
let sponsorBlockAutoSkip: Bool?
|
let sponsorBlockAutoSkip: Bool?
|
||||||
|
let openingEndingAutoSkip: Bool?
|
||||||
|
|
||||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||||
try validate(container, allowed: ["automatic_quality", "sponsorblock", "sponsorblock_autoskip"])
|
try validate(container, allowed: ["automatic_quality", "sponsorblock", "sponsorblock_autoskip",
|
||||||
|
"opening_ending_autoskip"])
|
||||||
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||||
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
||||||
sponsorBlockAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock_autoskip"))
|
sponsorBlockAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock_autoskip"))
|
||||||
|
openingEndingAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("opening_ending_autoskip"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user