Skip to content

Commit 97cf279

Browse files
Merge feat/unified-playback-phase-85: unified playbackPhase enum (#85)
2 parents 96ad9ab + 6f58a7b commit 97cf279

9 files changed

Lines changed: 298 additions & 6 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ player.$duration
112112
player.$videoFormat // .sdr, .hdr10, .hdr10Plus, .dolbyVision, .hlg
113113
player.$isSeeking // true until a seek physically lands (programmatic + native scrubs)
114114
player.$seekTarget // in-flight seek destination (source-PTS), nil otherwise
115+
player.$playbackPhase // unified: .idle/.loading/.playing/.paused/.seeking/.rebuffering/
116+
// .stalled(reconnecting:)/.ended/.error. One source of truth for a status
117+
// spinner; derived from state + isBuffering + isSeeking + source reconnect.
118+
// Prefer this over stitching the raw signals or matching EngineLog text.
115119
player.$currentAVPlayer // active AVPlayer, re-emitted on every reload (MPNowPlayingSession)
116120

117121
// Time lives on player.clock, a SEPARATE ObservableObject, so the ~10 Hz

Sources/AetherEngine/AetherEngine+Loading.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ extension AetherEngine {
209209
self.setNativeScrubSeek(inFlight: inFlight, target: target)
210210
}
211211
}
212+
session.onNetworkPhaseChanged = { [weak self] phase in
213+
Task { @MainActor in self?.setReaderNetworkPhase(phase) }
214+
}
212215
// #65: let the producer read AVPlayer's real position off-main when it re-anchors on a backpressure wedge.
213216
session.currentPlaybackPositionProvider = { [renderedPositionMirror] in renderedPositionMirror.get() }
214217
// #65 pause false-positive: let the producer read AVPlayer's play intent off-main so its backpressure
@@ -472,15 +475,20 @@ extension AetherEngine {
472475
// Capture the caller's probe budget (#68) before the detach: loadedOptions is @MainActor-isolated and unreachable inside the closure. Only used on the fallback open (probe absent).
473476
let probesize = loadedOptions.probesize
474477
let maxAnalyzeDuration = loadedOptions.maxAnalyzeDuration
478+
// Built on the main actor, captured into the detach: surfaces source stall/reconnect to playbackPhase (#85).
479+
let networkPhaseSink: @Sendable (ReaderNetworkPhase) -> Void = { [weak self] phase in
480+
Task { @MainActor in self?.setReaderNetworkPhase(phase) }
481+
}
475482
try await Task.detached(priority: .userInitiated) {
476-
[host, preopenedDemuxer, url, sourceHTTPHeaders, isLive, dvrWindowSeconds, probesize, maxAnalyzeDuration] in
483+
[host, preopenedDemuxer, url, sourceHTTPHeaders, isLive, dvrWindowSeconds, probesize, maxAnalyzeDuration, networkPhaseSink] in
477484
let dem: Demuxer
478485
if let pre = preopenedDemuxer {
479486
dem = pre
480487
} else {
481488
dem = Demuxer()
482489
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration), isLive: isLive)
483490
}
491+
dem.onNetworkPhaseChanged = networkPhaseSink
484492
try await host.load(
485493
demuxer: dem,
486494
startPosition: startPosition,
@@ -534,15 +542,20 @@ extension AetherEngine {
534542
// Caller's probe budget (#68) captured before the detach; only used on the fallback open (probe absent).
535543
let probesize = loadedOptions.probesize
536544
let maxAnalyzeDuration = loadedOptions.maxAnalyzeDuration
545+
// Built on the main actor, captured into the detach: surfaces source stall/reconnect to playbackPhase (#85).
546+
let networkPhaseSink: @Sendable (ReaderNetworkPhase) -> Void = { [weak self] phase in
547+
Task { @MainActor in self?.setReaderNetworkPhase(phase) }
548+
}
537549
try await Task.detached(priority: .userInitiated) {
538-
[host, preopenedDemuxer, url, sourceHTTPHeaders, probesize, maxAnalyzeDuration] in
550+
[host, preopenedDemuxer, url, sourceHTTPHeaders, probesize, maxAnalyzeDuration, networkPhaseSink] in
539551
let dem: Demuxer
540552
if let pre = preopenedDemuxer {
541553
dem = pre
542554
} else {
543555
dem = Demuxer()
544556
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration))
545557
}
558+
dem.onNetworkPhaseChanged = networkPhaseSink
546559
try await host.load(
547560
demuxer: dem,
548561
startPosition: startPosition,

Sources/AetherEngine/AetherEngine.swift

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,55 @@ public final class AetherEngine: ObservableObject {
5454

5555
// MARK: - Public State
5656

57-
@Published public internal(set) var state: PlaybackState = .idle
57+
@Published public internal(set) var state: PlaybackState = .idle {
58+
didSet { recomputePlaybackPhase() }
59+
}
5860

5961
/// Mid-playback rebuffer flag. `state` stays `.playing` across a rebuffer to avoid icon flicker;
6062
/// gate on this when you need to distinguish a stall from real playback (AetherEngine#35).
6163
/// Always false during initial load spin-up (`state == .loading`).
62-
@Published public internal(set) var isBuffering: Bool = false
64+
@Published public internal(set) var isBuffering: Bool = false {
65+
didSet { recomputePlaybackPhase() }
66+
}
6367

6468
/// True from seek entry until physical landing, covering both programmatic and native AVKit scrubs.
6569
/// Unlike `state == .seeking` (optimistically flipped to `.playing`), this spans the real
6670
/// loopback-HLS landing, which resolves seconds after the call (AetherEngine#38). Paired with `seekTarget`.
67-
@Published public internal(set) var isSeeking: Bool = false
71+
@Published public internal(set) var isSeeking: Bool = false {
72+
didSet { recomputePlaybackPhase() }
73+
}
6874

6975
/// Source-PTS seek destination, or nil when idle. Cleared on landing. For native scrubs, set to the
7076
/// out-of-range segment time AVPlayer requested (AetherEngine#38).
7177
@Published public internal(set) var seekTarget: Double? = nil
7278

79+
/// Single source of truth for what playback is doing right now (#85), derived from
80+
/// `state` / `isBuffering` / `isSeeking` / the reader network phase. Recomputed on every input change;
81+
/// never a parallel state machine. Hosts should observe this instead of stitching the raw signals or
82+
/// regex-matching `EngineLog`.
83+
@Published public internal(set) var playbackPhase: PlaybackPhase = .idle
84+
85+
/// Reader source-fetch axis feeding `playbackPhase`. Updated off the demux thread via
86+
/// `setReaderNetworkPhase`. `didSet` keeps `playbackPhase` in sync (#85).
87+
private var readerStall: ReaderNetworkPhase = .flowing {
88+
didSet { recomputePlaybackPhase() }
89+
}
90+
91+
/// Idempotent: assigns `playbackPhase` only when the derived value actually changes, so a flapping
92+
/// origin or redundant input write never fires a spurious `objectWillChange`.
93+
private func recomputePlaybackPhase() {
94+
let next = PlaybackPhase.derive(state: state,
95+
isBuffering: isBuffering,
96+
isSeeking: isSeeking,
97+
stall: readerStall)
98+
if playbackPhase != next { playbackPhase = next }
99+
}
100+
101+
/// Main-actor entry point for the demuxer's `@Sendable onNetworkPhaseChanged` callback (#85).
102+
func setReaderNetworkPhase(_ phase: ReaderNetworkPhase) {
103+
if readerStall != phase { readerStall = phase }
104+
}
105+
73106
/// Bumped at every `seek(to:)` entry; a seek finalizes isSeeking only when its generation still matches,
74107
/// preventing a superseded seek from clobbering a newer one.
75108
private var seekGeneration: UInt64 = 0
@@ -796,6 +829,7 @@ public final class AetherEngine: ObservableObject {
796829
: nil
797830
state = .loading
798831
isBuffering = false
832+
readerStall = .flowing
799833
clock.currentTime = 0
800834
clock.bufferedPosition = 0
801835
nativeClockSeconds = 0
@@ -1873,6 +1907,7 @@ public final class AetherEngine: ObservableObject {
18731907
clock.sourceTime = 0
18741908
clock.bufferedPosition = 0
18751909
isBuffering = false
1910+
readerStall = .flowing
18761911
// Hard-clear in-flight seek state: late callbacks are dropped by generation guards, but isSeeking
18771912
// must not strand (#38).
18781913
programmaticSeekInFlight = false

Sources/AetherEngine/Demuxer/AVIOReader.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ import Libavutil
1313
///
1414
/// AVIO callbacks run on the demux queue; prefetch/delivery on background queues.
1515
/// Shared state protected by locks.
16+
17+
/// Dedupes `ReaderNetworkPhase` emissions so a flapping origin does not spam the callback (#85).
18+
/// Mutated only on the demux thread (the read loop), so it needs no locking.
19+
struct NetworkPhaseGate {
20+
private var last: ReaderNetworkPhase = .flowing
21+
mutating func shouldEmit(_ next: ReaderNetworkPhase) -> Bool {
22+
guard next != last else { return false }
23+
last = next
24+
return true
25+
}
26+
}
27+
1628
final class AVIOReader: AVIOProvider, @unchecked Sendable {
1729

1830
private let url: URL
@@ -36,6 +48,21 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
3648
private var position: Int64 = 0
3749
private var fileSize: Int64 = -1
3850

51+
/// Typed source-fetch network phase, pushed on every stall/reconnect/recovery transition (#85).
52+
/// Mirrors `HLSVideoEngine.onSeekStateChanged`. `@Sendable`: invoked from the demux thread, the
53+
/// consumer hops to the main actor. Set only on the MAIN playback reader, never the subtitle side reader.
54+
var onNetworkPhaseChanged: (@Sendable (ReaderNetworkPhase) -> Void)?
55+
56+
/// Demux-thread-only dedupe for `onNetworkPhaseChanged`.
57+
private var networkPhaseGate = NetworkPhaseGate()
58+
59+
/// Emit a phase transition through the gate (demux thread only).
60+
private func emitNetworkPhase(_ phase: ReaderNetworkPhase) {
61+
if networkPhaseGate.shouldEmit(phase) {
62+
onNetworkPhaseChanged?(phase)
63+
}
64+
}
65+
3966
/// Cached CDN URL after redirect resolution; skips proxy hop on subsequent chunks.
4067
/// Auth-expiry statuses (401/403/404/410) against it invalidate and fall back to
4168
/// the source URL. See AetherEngine#12.
@@ -710,6 +737,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
710737
totalRead += n
711738
unproductiveReconnects = 0
712739
rateLimitStreak = 0
740+
emitNetworkPhase(.flowing) // detour cache served: not stalled (#85)
713741
detourTrackSequential(at: curPosition, length: n)
714742
continue
715743
case .rateLimited(let retryAfter):
@@ -748,6 +776,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
748776
trimWindowLocked()
749777
unproductiveReconnects = 0 // real progress
750778
rateLimitStreak = 0 // real progress clears the 429 give-up streak (#71)
779+
emitNetworkPhase(.flowing) // recovered: source delivering again (#85)
751780
winCond.broadcast() // window may have shrunk: wake backpressure
752781
winCond.unlock()
753782
continue
@@ -779,6 +808,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
779808
totalRead += n
780809
unproductiveReconnects = 0
781810
rateLimitStreak = 0
811+
emitNetworkPhase(.flowing) // detour cache served: not stalled (#85)
782812
detourTrackSequential(at: curPosition, length: n)
783813
continue
784814
case .rateLimited, .miss:
@@ -800,13 +830,15 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
800830
if !signaled {
801831
if recordReconnectAndShouldGiveUp() {
802832
EngineLog.emit("[AVIOReader] Persistent stall gave up at offset \(frontier) (\(unproductiveReconnects) unproductive)\(isLive ? " [live source lost]" : "")", category: .demux)
833+
emitNetworkPhase(.flowing) // reader is exiting; let state carry the terminal outcome (#85)
803834
if isLive {
804835
return totalRead > 0 ? Int32(totalRead) : AVERROR_EIO_VALUE
805836
}
806837
return totalRead > 0 ? Int32(totalRead) : -1
807838
}
808839
EngineLog.emit("[AVIOReader] Persistent stall at offset \(frontier), reconnecting", category: .demux)
809840
lastUnplannedReconnectAt = Date()
841+
emitNetworkPhase(.reconnecting) // unplanned reconnect now in flight (#85)
810842
backoffBeforeReconnect(streak: unproductiveReconnects, retryAfter: 0)
811843
startPersistentConnection(at: frontier)
812844
}
@@ -824,6 +856,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
824856
if giveUp {
825857
let streakDesc = isRateLimited ? "\(rateLimitStreak) consecutive 429/503" : "\(unproductiveReconnects) unproductive"
826858
EngineLog.emit("[AVIOReader] Persistent reconnect exhausted at offset \(frontier) status=\(status) (\(streakDesc))\(isLive ? " [live source lost]" : "")", category: .demux)
859+
emitNetworkPhase(.flowing) // reader is exiting; let state carry the terminal outcome (#85)
827860
if isLive {
828861
return totalRead > 0 ? Int32(totalRead) : AVERROR_EIO_VALUE
829862
}
@@ -832,6 +865,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
832865
let backoffStreak = isRateLimited ? rateLimitStreak : unproductiveReconnects
833866
EngineLog.emit("[AVIOReader] Persistent conn ended at offset \(frontier) status=\(status), reconnecting (streak=\(backoffStreak) retryAfter=\(retryAfter)s)", category: .demux)
834867
lastUnplannedReconnectAt = Date()
868+
emitNetworkPhase(.reconnecting) // unplanned reconnect now in flight (#85)
835869
backoffBeforeReconnect(streak: backoffStreak, retryAfter: retryAfter)
836870
startPersistentConnection(at: frontier)
837871
}

Sources/AetherEngine/Demuxer/Demuxer.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ public final class Demuxer: @unchecked Sendable {
105105
(avioProvider as? AVIOReader)?.lastUnplannedReconnectAt
106106
}
107107

108+
/// Forwarded to the playback `AVIOReader` so source stall/reconnect transitions reach the engine (#85).
109+
/// `didSet` re-forwards so it works whether set before or after `open()`. Set only on the playback
110+
/// demuxer; the subtitle side-demuxer leaves it nil so its stalls never move `playbackPhase`. Disc /
111+
/// custom providers without an `AVIOReader` simply never emit.
112+
var onNetworkPhaseChanged: (@Sendable (ReaderNetworkPhase) -> Void)? {
113+
didSet { (avioProvider as? AVIOReader)?.onNetworkPhaseChanged = onNetworkPhaseChanged }
114+
}
115+
108116
// MARK: - Disc titles / chapters (#67)
109117

110118
private(set) var discTitles: [DiscTitle] = []
@@ -225,6 +233,7 @@ public final class Demuxer: @unchecked Sendable {
225233
chunkRequestTimeout: openProfile.avioRequestTimeout,
226234
chunkMaxRetries: openProfile.avioMaxRetries
227235
)
236+
reader.onNetworkPhaseChanged = onNetworkPhaseChanged
228237
try openWithProvider(reader, isLive: isLive)
229238
}
230239

Sources/AetherEngine/PlayerState.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,56 @@ public enum PlaybackBackend: String, Sendable, Equatable {
3131
case audio
3232
}
3333

34+
/// What playback is doing right now, as one observable (#85). Derived from `state`, `isBuffering`,
35+
/// `isSeeking`, and the reader network phase, so it can never desync from them. Observe `$playbackPhase`
36+
/// instead of stitching `state == .loading` + `$isBuffering` + `$isSeeking` together, and instead of
37+
/// regex-matching `EngineLog` for stall/reconnect, which is no longer necessary.
38+
///
39+
/// `.stalled(reconnecting:)` reports a source-connection problem (drop / 429 / 503 backoff) distinct from
40+
/// `.rebuffering` (a healthy-connection buffer underrun). The associated value is `true` whenever the
41+
/// reader is retrying; a future "stalled, retries paused" distinction will surface as `false` without
42+
/// changing the case. Not available on the direct AVPlayer-HLS live path (no demuxer / reader): a reconnect
43+
/// there reads as `.rebuffering`.
44+
public enum PlaybackPhase: Sendable, Equatable {
45+
case idle
46+
case loading
47+
case playing
48+
case paused
49+
case seeking
50+
case rebuffering
51+
case stalled(reconnecting: Bool)
52+
case ended
53+
case error(String)
54+
}
55+
56+
/// Source-fetch network axis feeding `PlaybackPhase` (#85). Binary today; `.reconnecting` covers the
57+
/// `AVIOReader` stall / drop / backoff loop, `.flowing` covers normal delivery.
58+
enum ReaderNetworkPhase: Sendable, Equatable {
59+
case flowing
60+
case reconnecting
61+
}
62+
63+
extension PlaybackPhase {
64+
/// Pure fold of the four playback axes into one phase, with fixed precedence
65+
/// (highest first): error > ended > idle > loading > seeking > stalled > rebuffering > playing/paused.
66+
static func derive(state: PlaybackState,
67+
isBuffering: Bool,
68+
isSeeking: Bool,
69+
stall: ReaderNetworkPhase) -> PlaybackPhase {
70+
switch state {
71+
case .error(let message): return .error(message)
72+
case .ended: return .ended
73+
case .idle: return .idle
74+
case .loading: return .loading
75+
case .playing, .paused, .seeking:
76+
if isSeeking { return .seeking }
77+
if stall == .reconnecting { return .stalled(reconnecting: true) }
78+
if isBuffering { return .rebuffering }
79+
return state == .paused ? .paused : .playing
80+
}
81+
}
82+
}
83+
3484
/// Static snapshot of what the current display can present. Single source of truth shared with the host.
3585
public struct DisplayCapabilities: Sendable, Equatable {
3686
public let supportsHDR: Bool

Sources/AetherEngine/Video/HLSVideoEngine.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ public final class HLSVideoEngine: @unchecked Sendable {
190190
/// `playlistShiftSeconds` onto the source-PTS `seekTarget`.
191191
var onSeekStateChanged: (@Sendable (Bool, Double?) -> Void)?
192192

193+
/// Source stall/reconnect transitions from the main demuxer's `AVIOReader` (#85). Forwarded to
194+
/// `demuxer` at every install site (start + live/restart reopen); the side-audio demuxer stays unwired.
195+
var onNetworkPhaseChanged: (@Sendable (ReaderNetworkPhase) -> Void)?
196+
193197
/// AVPlayer's rendered (playlist-axis) position, readable off the main actor. Wired by AetherEngine
194198
/// to a thread-safe mirror of the host clock. Used to re-anchor the producer on AVPlayer's REAL
195199
/// position when a VOD backpressure wedge breaks (#65).
@@ -392,6 +396,7 @@ public final class HLSVideoEngine: @unchecked Sendable {
392396
}
393397
}
394398
demuxer = dem
399+
dem.onNetworkPhaseChanged = onNetworkPhaseChanged // surface source stall/reconnect to playbackPhase (#85)
395400

396401
let videoIndex = dem.videoStreamIndex
397402
guard videoIndex >= 0, let videoStream = dem.stream(at: videoIndex) else {
@@ -1443,7 +1448,10 @@ public final class HLSVideoEngine: @unchecked Sendable {
14431448
}
14441449
// #79: install the reopened demuxer only inside the validated section so makeProducer reads it and a
14451450
// concurrent teardown can't race a resurrected demuxer into a torn-down session.
1446-
if let freshDemuxer { demuxer = freshDemuxer }
1451+
if let freshDemuxer {
1452+
demuxer = freshDemuxer
1453+
freshDemuxer.onNetworkPhaseChanged = onNetworkPhaseChanged // re-wire stall signal onto the reopened demuxer (#85)
1454+
}
14471455
do {
14481456
let newProd = try makeProducer(baseIndex: idx)
14491457
producer = newProd

0 commit comments

Comments
 (0)