feat: offer timed opening and ending skips

This commit is contained in:
2026-10-22 13:24:37 +02:00
parent 7b53b00b15
commit 3d949154a2
8 changed files with 165 additions and 10 deletions
+12 -1
View File
@@ -211,7 +211,7 @@ struct CastPlaybackCheck {
let suite = "terebi.cast-playback-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults)
let model = PlayerModel(defaults: defaults, skipClock: { 100 })
defer { model.shutdown() }
if CommandLine.arguments.count == 5 {
if CommandLine.arguments[3] == "queue" {
@@ -249,6 +249,17 @@ struct CastPlaybackCheck {
require(model.setPlayback(playing: false), "remote pause accepted")
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
requirePlaybackPower(system: false, display: false, "receiver pause permits sleep")
let opening = MiruroInterval(kind: .intro, start: 23, end: 24.25, episode: 1)
model.supplyMetadataForCheck(MiruroMetadata(title: nil, intervals: [opening]), episode: 1)
require(model.seekRelative(24 - model.state.position), "enter opening on receiver")
try await until(model, "receiver offers opening only after acknowledging seek") {
!model.state.seek_pending && model.segmentToSkip == opening
}
require(model.skipSegment(), "segment skip queues receiver seek")
try await until(model, "receiver acknowledges segment end without starting playback") {
!model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 &&
model.state.status == PLAYER_PAUSED && model.segmentToSkip == nil
}
model.stopCasting()
try await until(model, "local decoder restores receiver position paused") {
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
+70 -2
View File
@@ -3,6 +3,7 @@ import AppKit
@main
@MainActor
struct ResolvedPlaybackCheck {
final class Clock { var now = 100.0 }
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
@@ -10,7 +11,7 @@ struct ResolvedPlaybackCheck {
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
while !predicate(), ContinuousClock.now < deadline {
if model.state.status == PLAYER_ERROR || model.commandError != nil {
fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
fatalError("FAIL: \(message): " + (model.errorText ?? "Playback failed"))
}
try await Task.sleep(for: .milliseconds(10))
}
@@ -43,7 +44,9 @@ struct ResolvedPlaybackCheck {
let suite = "terebi.resolved-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
let clock = Clock()
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil,
skipClock: { clock.now })
defer { model.shutdown() }
if input.hasPrefix("https://") { model.openStream(input) }
else { model.openResolvedSource(file) }
@@ -90,5 +93,70 @@ struct ResolvedPlaybackCheck {
model.subtitleText == document.text(at: model.state.position)
}
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
}
static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws {
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)
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
require(model.seekRelative(0.75 - model.state.position), "enter opening")
try await until("opening position acknowledged", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
}
model.supplyMetadataForCheck(metadata, episode: 1)
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
clock.now = 102.999
require(model.segmentToSkip == opening, "opening remains available before three seconds")
clock.now = 103
require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at three seconds while paused")
clock.now = 104
try await Task.sleep(for: .milliseconds(100))
require(model.segmentToSkip == nil, "polling does not reoffer ignored opening")
require(model.seekRelative(2.25 - model.state.position), "enter ending")
require(model.segmentToSkip == nil, "pending seek cannot offer stale opening")
try await until("ending offer", model: model) {
!model.state.seek_pending && model.segmentToSkip == ending
}
require(model.skipSegment(), "ending skip accepted")
require(model.segmentToSkip == nil, "accepted skip immediately removes button")
try await until("ending skip reaches exact media end", model: model) {
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
}
require(!model.effectivePlaying, "paused skip does not start playback")
require(model.seekRelative(0.75 - model.state.position), "revisit ignored opening")
try await until("opening revisit acknowledged", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
}
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
model.closeFile()
require(model.segmentToSkip == nil, "close removes offer")
model.openResolvedSource(file)
try await until("reopened media playing", model: model) {
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
}
require(model.setPlayback(playing: false), "pause reopened media")
try await until("reopened media paused", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.75 - model.state.position), "reenter opening after reopen")
try await until("reopened opening acknowledged", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
}
model.supplyMetadataForCheck(metadata, episode: 1)
try await until("new open resets dismissal after seek settles", model: model) {
model.segmentToSkip == opening
}
require(model.setPlayback(playing: true), "resume inside opening")
try await until("playing opening offer", model: model) {
model.state.status == PLAYER_PLAYING && !model.state.buffering &&
model.state.position > 0.85 && model.segmentToSkip == opening
}
require(model.skipSegment(), "opening skip accepted")
try await until("opening skip reaches segment end", model: model) {
!model.state.seek_pending && abs(model.state.position - opening.end) < 0.1
}
require(model.segmentToSkip == nil, "segment end is outside opening")
require(model.effectivePlaying, "opening skip preserves playing intent")
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported.
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider, without visible labels; accessibility descriptions retain the interval names and times. Missing/invalid metadata leaves playback usable. Timings may differ between cuts. No automatic skipping is performed.
- **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 for three seconds; clicking seeks to its end. 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. Use the titlebar pin to keep the normal window on top.
- **Quality:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available resolutions or automatic quality.
+1 -1
View File
@@ -2,7 +2,7 @@ import Foundation
import CoreFoundation
struct MiruroInterval: Sendable, Equatable {
enum Kind: Sendable { case intro, outro }
enum Kind: Sendable, Hashable { case intro, outro }
let kind: Kind
var start: Double
var end: Double
+70 -1
View File
@@ -64,6 +64,38 @@ final class PlayerModel: ObservableObject {
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
return metadataLifecycle.visibleIntervals(duration: state.duration)
}
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
private var offeredSegments: Set<MiruroInterval.Kind> = []
private let skipClock: () -> Double
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 }
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
}
var segmentToSkip: MiruroInterval? {
guard let skipOffer, skipClock() < skipOffer.deadline,
currentSkipInterval == skipOffer.interval else { return nil }
return skipOffer.interval
}
private func updateSegmentSkip() {
guard let interval = currentSkipInterval else {
if skipOffer != nil { skipOffer = nil }
return
}
if offeredSegments.insert(interval.kind).inserted {
skipOffer = (interval, skipClock() + 3)
} else if let skipOffer, skipClock() >= skipOffer.deadline || skipOffer.interval != interval {
self.skipOffer = nil
}
}
@discardableResult
func skipSegment() -> Bool {
guard let interval = segmentToSkip, seekRelative(interval.end - state.position) else { return false }
skipOffer = nil
return true
}
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingStream: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
@@ -144,8 +176,10 @@ final class PlayerModel: ObservableObject {
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil,
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
self.skipClock = skipClock
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
@@ -273,6 +307,7 @@ final class PlayerModel: ObservableObject {
revealControls()
}
systemPlayback?.update()
updateSegmentSkip()
}
@discardableResult
@@ -488,6 +523,10 @@ final class PlayerModel: ObservableObject {
private func replaceMetadata(for source: MediaSource?) {
metadataTask?.cancel()
metadataTask = nil
if source?.url.absoluteString != metadataLifecycle.identity {
offeredSegments.removeAll()
skipOffer = nil
}
let parts = source?.url.absoluteString.split(separator: ":") ?? []
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
@@ -500,6 +539,7 @@ final class PlayerModel: ObservableObject {
self.currentMedia?.url.absoluteString == identity,
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
self.objectWillChange.send()
self.updateSegmentSkip()
if let title = metadata.title {
self.fileName = title
self.window?.title = title
@@ -1171,6 +1211,7 @@ final class PlayerModel: ObservableObject {
}
}
if !casting.active { applyState(player_get_state(handle)) }
else { updateSegmentSkip() }
}
func applyState(_ next: PlayerState) {
@@ -1182,6 +1223,7 @@ final class PlayerModel: ObservableObject {
handle: handle, history: playbackProgress)
guard observation.ready else { return }
defer { systemPlayback?.update() }
defer { updateSegmentSkip() }
state = next
updateSubtitle()
if shouldRecordRecentMedia, !isBusy, next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED,
@@ -1602,6 +1644,15 @@ final class PlayerModel: ObservableObject {
}
#if PLAYER_CHECK
func supplyMetadataForCheck(_ metadata: MiruroMetadata, episode: Int) {
metadataTask?.cancel()
metadataTask = nil
let identity = currentMedia!.url.absoluteString
let generation = metadataLifecycle.replace(identity: identity, episode: episode)
precondition(metadataLifecycle.accept(metadata, identity: identity, generation: generation))
updateSegmentSkip()
}
static func metadataLifecycleCheck() {
var lifecycle = MetadataLifecycle()
let a = "miruro:A:1"
@@ -3225,6 +3276,24 @@ private struct PlayerContent: View {
.allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible)
}
if let interval = model.segmentToSkip {
Button {
model.skipSegment()
} label: {
Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending",
systemImage: "forward.end.fill")
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.foregroundStyle(Color(SunsetPalette.text))
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)))
.padding(.trailing, model.isCompact ? 8 : 16)
.padding(.bottom, model.isCompact ? compactControlHeight + 20 : controlHeight + 40)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
}
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
+3 -1
View File
@@ -766,7 +766,9 @@ struct Player {
for (auto &input : inputs) {
if (!input) continue;
input->discardAudio(false);
if (input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target))
// EOF is a timeline position, not a demux seek target (HLS rejects it).
if ((duration > 0 && target >= duration) ||
(input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target)))
input->finish();
else
input->seek(-1, timestamp);
+5 -2
View File
@@ -21,5 +21,8 @@
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
- audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged.
- [x] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense. it should do the job of a readme only, nothing more.
- [ ] add a skip button when current playback is inside either an opening or an ending segment.
- skip button should disappear within 3 seconds; if not clicked, the user probably wants to watch the segment.
- [x] add a skip button when current playback is inside either an opening or an ending segment.
- Skip Opening/Ending uses validated metadata, seeks to the segment end and disappears after three seconds even while paused; ignored segments stay dismissed until close/media replacement. Native HLS, exact-EOF, controlled-clock dismissal and loopback Cast checks passed. Button placement and physical-Cast verification remain unverified; rapid play/seek probes encountered Core Audio startup errors on Bluetooth output.
- [ ] improve: subtitles.
- decrease padding on captions
- decrease font size of captions by 10 percent
+3 -1
View File
@@ -31,7 +31,7 @@ The native resolver requests watch-route data and safely follows bounded same-or
Titles come from returned series/selected-episode metadata. Only `op`/`mixed-op` and `ed`/`mixed-ed` become opening/ending bands. Bands retain fractional seconds, require finite positive player-reported duration, and reject nonfinite timestamps, originally reversed/zero-length intervals and wrong-episode timings. Display intervals are clipped to `[0, duration]`; intervals with no positive-length overlap are hidden. Raw timestamps remain unchanged, and duration changes recompute clipping without shifting or scaling. Optional provenance/votes/reference-length checks remain in force; a finite positive reference length differing from actual duration by more than 5% rejects the interval, and missing fields are not invented. Highest-vote eligible opening and ending win, with response order breaking ties. Overlaps are not merged.
The controls mark interval boundaries with small rounded gaps in the slider track, without visible labels or boxes. Buffer rendering shares the segmented track; accessibility descriptions retain opening/ending names and times. Neither automatic seeking nor skip buttons are added. Bounds do not prove synchronization across cuts. Metadata tasks are bound to identity and lifecycle generation; replacement clears intervals and rejects stale results, including A → B → A.
The controls mark interval boundaries with small rounded gaps in the slider track, without visible labels or boxes. Buffer rendering shares the segmented track; accessibility descriptions retain opening/ending names and times. While acknowledged, seekable video playback is inside a band, a **Skip Opening** or **Skip Ending** button offers a seek to its end for three seconds of monotonic time, including while paused. Each kind is offered once until close or media-identity replacement; pointer movement and seeking back do not reoffer ignored segments. Pending seeks and slider previews cannot expose a stale offer. The same action routes to local or Cast playback and retains playback intent. No automatic skipping is performed. Bounds do not prove synchronization across cuts. Metadata tasks are bound to identity and lifecycle generation; replacement clears intervals and rejects stale results, including A → B → A.
### External resolved-source compatibility
@@ -169,6 +169,8 @@ Metadata implementation verification (2026-10-22): protocol/security/bounds chec
Controls alignment correction: removed the obsolete label-height reservation after switching to gapped segments. A native offscreen SwiftUI/AppKit layout smoke reproduced a 9-point top-row shift when intervals appeared; after restoring the standard 14-point slider frame, volume and timeline geometry match exactly with and without intervals. Production build passed; visible confirmation remains a human check.
Segment-skip verification (2026-10-22): local HLS/VTT playback exercised late metadata, the controlled-clock three-second boundary while paused, persistent dismissal, pending-seek suppression, opening/ending seeks, exact media-end seeking, close/reopen reset and playing/paused intent. Exact-end HLS seeks initially failed with `Operation not permitted`; the shared native seek path now finishes network inputs at EOF instead of asking the demuxer to seek beyond its final packet. Network playback regression checks, metadata checks, production build and TLS loopback Cast segment skipping/restoration passed. Rapid play-then-seek probes encountered Core Audio startup errors on the active Bluetooth output; the settled-playback smoke waits for actual advancing playback and passed. Visible button placement, rapid audio transitions and physical-TV behavior remain unverified.
## Remaining limits
- Confirm title readability and gapped slider segments in normal/compact controls, buffer/playhead visibility and subtitle alignment in the visible app using Tubular's real launch path. Offscreen native rasterization at compact/normal widths confirmed all four interval-boundary gaps remain clear even with full buffer coverage.