diff --git a/CHANGELOG.md b/CHANGELOG.md index 871eab9c..9856fdfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ the public-API contract. - External subtitles on a remote HLS source no longer appear early when the origin's media timestamps do not start at the playlist's time 0. The WebVTT renditions the engine injects (#316) carried no `X-TIMESTAMP-MAP`, so AVPlayer pinned cue time 0 to timestamp 0, and an ffmpeg MPEG-TS transcode cut with `-copyts -max_delay 5000000` showed every cue 10 s early. The engine now reads the first media timestamp of the segment the load opens on, off the load path, and serves the renditions with a matching `X-TIMESTAMP-MAP`; an origin already on its playlist timeline, one whose segment starts before its playlist slot (a transcode restarted at the previous keyframe, which AE#616 corrects), or one it cannot read, keeps the plain WebVTT. - After a seek or a resume into densely typeset ASS (signs re-timed on every frame, several hundred events a second), embedded subtitles stayed missing until the passage ended. The drain decoded at most 256 packets per 0.5 s tick from 15 s behind the playhead, slower than such a passage plays, so it never caught up. A text track's tick now decodes through the playhead plus 12 s whatever the cap (`subtitleDrainUrgentLeadSeconds`, at most `subtitleDrainMaxCatchUpPacketsPerTick` packets); bitmap and teletext tracks keep the steady cap. Text cues from one tick are merged into the retained cues in one pass instead of one insert each, so a large batch after a seek back stays linear. - The #362 harvest-hole hold no longer stops the drain at the pump's edge when the forward prefetch has already read past it. Re-harvested packets take a fresh sequence, so the pump re-reading behind the prefetch broke the harvest order at whatever position it had reached, and that moving seam renewed the hold every tick and kept the drain a few seconds ahead of the playhead instead of its 60 s lead. An order break over a span that the prefetch's current run has read since its first packet, and that the store still holds, is a seam, not a hole. Otherwise the hold works as before. +- `play()` no longer loops when the rewind it asks for is rejected. A VOD parked at its end rewinds to 0 before playing (#164), and `play()` then called itself again; when `seek(to:)` rejected the rewind, nothing moved and the same rewind was asked for thousands of times a second while playback stayed paused. It hit a forward-only source whose container reports a duration shorter than the stream, as a fragmented MP4 served as one response does (it reports its first fragment), and an errored session parked at its end. A forward-only source (a sequential origin, declared or promoted, or an unseekable byte source) now resumes where it is without a rewind, including one genuinely paused at its end, which cannot rewind; any other `play()` asks for one rewind per press and then resumes. +- After an audio switch or a reload on the software path, `isSourceSeekable` is published again. The rebuild cleared it and only the native path set it back, so `canSeek` read false on a seekable source and a forward-only source lost its forward-only handling until the next load. - Software video now presents the selected frame when seeking while paused. It decodes through preroll without starting the clock or audio, displays the winning seek's frame, and stays paused instead of retaining the previous picture until Play. - The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count. - Authorized native HLS uses the engine relay from the initial load, without forwarding origin credentials to the loopback asset. Optional subtitle playlist preparation shares the authorizer and has a bounded deadline across redirects and refreshes. diff --git a/README.md b/README.md index 148284e1..6e3ded2c 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,10 @@ player.$state // .idle, .loading, .playing, .paused, .seeking, .ended, // .ended = played to completion (any backend); .idle = pre-load / stopped. // .ended is TERMINAL: seek is rejected and play() does not revive it, so a // replay is another load(). A VOD merely parked at its final frame is the - // other case and play() rewinds it. How every other failure arrives, and - // why a CancellationError out of load() is not one, is in docs/api.md. + // other case and play() rewinds it, once per press; a forward-only source + // (canSeek false) cannot rewind and resumes where it is. How every other + // failure arrives, and why a CancellationError out of load() is not one, is + // in docs/api.md. player.$errorInfo // PlaybackErrorInfo?, the machine-readable half of .error: a stable // PlaybackErrorKind plus the underlying NSError domain and code. Classify on // this. The message cannot: on the native paths it is AVPlayerItem.error diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 74209434..4fe57864 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -2136,6 +2136,9 @@ extension AetherEngine { host.stop() try checkLoadCurrent(generation) } + // A rebuild (audio switch, reload) ran stopInternal, which cleared the capability; without it + // `canSeek` reads false on a seekable source and the forward-only rules stop applying. + isSourceSeekable = host.openedSourceIsSeekable } /// Open `AudioPlaybackHost` for an audio-only source. No HLS pipeline, display layer, or display-criteria handshake. Same lifecycle as `loadSoftware`. diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 07fb58ef..f534ce81 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -99,6 +99,11 @@ public final class AetherEngine: ObservableObject { } } + /// Whether the loaded VOD source can only be read forwards: a sequential origin, declared or + /// promoted, or a byte source the probe found unseekable. A duration does not change this, and + /// a seek or a rewind on such a source can only be refused. + var sourceIsForwardOnly: Bool { loadedOptions.sequentialOrigin || isSourceSeekable == false } + /// Whether the active session accepts a seek. Live playback requires a DVR window; /// forward-only VOD never advertises arbitrary seeking even when its duration is known. /// Remote HLS uses the native item's measured range instead of container metadata. @@ -109,7 +114,7 @@ public final class AetherEngine: ObservableObject { default: break } if isLive { return seekableLiveRange != nil } - guard !loadedOptions.sequentialOrigin, isSourceSeekable != false, + guard !sourceIsForwardOnly, duration.isFinite, duration > 0 else { return false } if videoRoute == .remoteBypass { guard let nativeHost else { return false } @@ -3567,10 +3572,16 @@ public final class AetherEngine: ObservableObject { /// `AVPlayer.play()` at end-of-media is a no-op, freezing the button. `.ended` is deliberately /// excluded: it is terminal (#63), the host revives it by reloading, and a play press racing the end /// card must not silently restart the finished session. + /// + /// A forward-only source never rewinds. Its container can report a duration far short of the + /// stream (a fragmented MP4 served as one response reports its first fragment, a few seconds), + /// which reads as "parked at the end" for the rest of the session, and `seek(to:)` rejects the + /// rewind anyway. nonisolated static func shouldRewindBeforePlay( - state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool + state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool, + sourceIsForwardOnly: Bool ) -> Bool { - guard state != .ended else { return false } + guard state != .ended, !sourceIsForwardOnly else { return false } return isAtEndOfMedia(currentTime: currentTime, duration: duration, isLive: isLive) } @@ -5491,14 +5502,24 @@ public final class AetherEngine: ObservableObject { // first, then resume. `.ended` is excluded (see shouldRewindBeforePlay): it stays terminal so a // play press racing the host end card does not silently revive a finished session (#63). if Self.shouldRewindBeforePlay( - state: state, currentTime: currentTime, duration: duration, isLive: isLive + state: state, currentTime: currentTime, duration: duration, isLive: isLive, + sourceIsForwardOnly: sourceIsForwardOnly ) { + let generation = loadGeneration Task { @MainActor in await self.seek(to: 0) - self.play() + // One rewind per press. A rejected or superseded seek leaves the playhead where it was, + // and calling play() again would ask for the same rewind without end. A load that + // started meanwhile owns the transport now. + guard self.loadGeneration == generation else { return } + self.resumeTransport() } return } + resumeTransport() + } + + private func resumeTransport() { activeTransportHost?.play() if state == .paused || state == .loading { state = .playing @@ -5720,7 +5741,7 @@ public final class AetherEngine: ObservableObject { return } } - if !isLive, loadedOptions.sequentialOrigin || isSourceSeekable == false { + if !isLive, sourceIsForwardOnly { EngineLog.emit("[AetherEngine] seek(to:\(seconds)) rejected: source is forward-only", category: .engine) // A seek stashed during load must relinquish its optimistic clock as well. let wasDeferred = deferredSeekInFlight diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index d34bb3f5..33496d87 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -135,6 +135,9 @@ final class SoftwarePlaybackHost { private var audioDecoder: AudioDecoder? private var audioOutput: AudioOutput? private var demuxer: Demuxer? + /// Whether the source this host opened can be repositioned, taken once in `load()` so the main + /// actor never polls the demuxer the read loop owns. The engine republishes it after a rebuild. + private(set) var openedSourceIsSeekable = false private var vodPacketReadAhead: SoftwarePacketReadAhead? /// #687: this host's entries in the process-wide retention ledger, released on `stop()`. private var retentionClaims: [RetentionClaims.Claim] = [] @@ -890,6 +893,7 @@ final class SoftwarePlaybackHost { forwardBufferSegments: Int? = nil ) async throws { self.demuxer = dem + openedSourceIsSeekable = dem.isSourceSeekable self.duration = dem.duration self.isLive = isLive diff --git a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift index ca1cd0ee..fa4b43d3 100644 --- a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift +++ b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift @@ -1,3 +1,4 @@ +import Combine import Testing @testable import AetherEngine @@ -66,19 +67,33 @@ struct SeekToEndOfMediaTests { @Test("Rewind before play when parked at end-of-media") func rewindWhenParkedAtEnd() { - #expect(AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: false)) - #expect(AetherEngine.shouldRewindBeforePlay(state: .playing, currentTime: 100, duration: 100, isLive: false)) + #expect(AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) + #expect(AetherEngine.shouldRewindBeforePlay(state: .playing, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) } @Test(".ended never rewinds (terminal, #63 end-card must not be revived)") func endedNeverRewinds() { - #expect(!AetherEngine.shouldRewindBeforePlay(state: .ended, currentTime: 100, duration: 100, isLive: false)) + #expect(!AetherEngine.shouldRewindBeforePlay(state: .ended, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) } @Test("No rewind mid-stream or when live") func noRewindMidStreamOrLive() { - #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 50, duration: 100, isLive: false)) - #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: true)) + #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 50, duration: 100, isLive: false, sourceIsForwardOnly: false)) + #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: true, sourceIsForwardOnly: false)) + } + + @Test("A source that cannot be repositioned never rewinds, whatever its duration claims") + func forwardOnlySourceNeverRewinds() { + // A fragmented MP4 served as one response reports its first fragment as the duration: + // 3.6 s for a film with over an hour left. Twenty minutes in, that reads as parked at + // the end, and the rewind is a seek this source cannot serve. + #expect(!AetherEngine.shouldRewindBeforePlay( + state: .paused, currentTime: 1_196.7, duration: 3.6, isLive: false, sourceIsForwardOnly: true)) + #expect(!AetherEngine.shouldRewindBeforePlay( + state: .paused, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: true)) + // The same playhead on a source that can rewind still does. + #expect(AetherEngine.shouldRewindBeforePlay( + state: .paused, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) } // MARK: - Integration: real seek() / play() paths (no hosts) @@ -125,6 +140,47 @@ struct SeekToEndOfMediaTests { #expect(engine.state == .playing) } + @MainActor + @Test("play() on a forward-only source past its container duration resumes without a rewind") + func playOnForwardOnlySourceDoesNotRewind() async throws { + // A fragmented MP4 served as one response reports its first fragment as the duration. The + // rewind play() used to request is a seek this source rejects, and play() then asked again, + // round after round, without ever resuming. + let engine = try AetherEngine() + engine.state = .paused + engine.isSessionReady = true + engine.duration = 3.5 + engine.isSourceSeekable = false + engine.clock.currentTime = 4.6 + var events: [SeekEvent] = [] + let sub = engine.seekEvents.sink { events.append($0) } + defer { sub.cancel(); engine.stop() } + + engine.play() + for _ in 0..<50 { await Task.yield() } + #expect(events.isEmpty) + #expect(engine.state == .playing) + #expect(engine.currentTime == 4.6) + } + + @MainActor + @Test("play() asks for one rewind per press, even when the seek is rejected") + func playRewindsOncePerPress() async throws { + // An errored session keeps its playhead; seek(to:) rejects the rewind with noActiveSession and + // nothing moves. play() used to ask again on every round, thousands of times a second. + let engine = try AetherEngine() + engine.state = .error("decoder failed") + engine.duration = 100 + engine.clock.currentTime = 100 + var events: [SeekEvent] = [] + let sub = engine.seekEvents.sink { events.append($0) } + defer { sub.cancel(); engine.stop() } + + engine.play() + for _ in 0..<50 { await Task.yield() } + #expect(events.map(\.outcome) == [.rejected(.noActiveSession)]) + } + @MainActor @Test("play() at .ended stays terminal, no rewind (#63)") func playWhenEndedStaysTerminal() async throws {