From 3d949154a2ba921fd221c1e22ff770cf88b677b1 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 13:24:37 +0200 Subject: [PATCH] feat: offer timed opening and ending skips --- Checks/cast-playback.swift | 13 +++++- Checks/resolved-playback.swift | 72 +++++++++++++++++++++++++++++++- README.md | 2 +- Sources/App/MiruroMetadata.swift | 2 +- Sources/App/TerebiApp.swift | 71 ++++++++++++++++++++++++++++++- Sources/Core/player.cpp | 4 +- TODO.md | 7 +++- docs/miruro-playback-handoff.md | 4 +- 8 files changed, 165 insertions(+), 10 deletions(-) diff --git a/Checks/cast-playback.swift b/Checks/cast-playback.swift index 771a808..a9df6b5 100644 --- a/Checks/cast-playback.swift +++ b/Checks/cast-playback.swift @@ -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 && diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index 25fd5aa..3617d2a 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -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") } } diff --git a/README.md b/README.md index 6ed9641..bc00946 100644 --- a/README.md +++ b/README.md @@ -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//?ep=` 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. diff --git a/Sources/App/MiruroMetadata.swift b/Sources/App/MiruroMetadata.swift index 5f7d9a8..d103a30 100644 --- a/Sources/App/MiruroMetadata.swift +++ b/Sources/App/MiruroMetadata.swift @@ -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 diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index e0b6ec4..ef1e402 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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 = [] + 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) diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index 30595a2..ac4c887 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -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); diff --git a/TODO.md b/TODO.md index 87e89e3..b7d7b2a 100644 --- a/TODO.md +++ b/TODO.md @@ -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. \ No newline at end of file +- [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 \ No newline at end of file diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index ef0f551..0cf64ad 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -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.