Skip to content

Zero-delay stream switching: prepare(next:) and seamless load - #31

Closed
bilipp wants to merge 2 commits into
mainfrom
feature/zero-delay-switching
Closed

Zero-delay stream switching: prepare(next:) and seamless load#31
bilipp wants to merge 2 commits into
mainfrom
feature/zero-delay-switching

Conversation

@bilipp

@bilipp bilipp commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Closes #16.

Implements PLAN.md §6 zero-delay switching (roadmap P9): a second session opens through first-frame-decoded, then the renderer attachment swaps atomically; the old session tears down asynchronously.

What's in here

  • PlayerSession.waitForFirstFrame(timeout:) — the sample-buffer renderers accept media at rate 0, so a freshly opened session reaches first-frame-decoded with the clock paused; its display layer already shows a picture, which is what makes the swap gapless. state becomes publicly readable so a standby session's health can be checked before adoption.
  • LumePlayer.prepare(next:) — stages a URL in a fully independent standby session (own demuxer/decoders/renderer/layer) through the first-frame gate. Powers Lume's next-episode auto-advance.
  • LumePlayer.load(url:) — three tiers: a prepared match swaps instantly; otherwise, with the new PlayerConfiguration.seamlessSwitching option (default on), the current session keeps rendering while the replacement opens to first frame, then swaps (channel zapping without a black gap); a failed seamless open retries cold — that covers IPTV providers that refuse a second concurrent connection.
  • Generation counters make overlapping load/prepare/stop calls safe: a superseded call discards its session instead of installing it or clobbering the winner's state.

Invariants

Session epochs are untouched — there is no rebuild-in-place, just two independent sessions and one atomic promotion on the main actor. LumePlayerView needed no changes: it re-installs the layer whenever player.displayLayer changes.

Tests

Six new tests in SeamlessSwitchingTests (first-frame gate on a never-played session, gate failure path, prepare→load atomic swap incl. "no second open" timing, seamless load without prepare, dead-source cold fallback ending in .failed, option-off cold path). Full suite: 69/69 passing.

Consumer note

With the option on, a failed switch costs up to open-timeout + one cold retry before the error surfaces — the trade for never blanking the screen. The companion Lume PR wires this into channel zapping, next-episode prepare, and a settings toggle.

PlayerSession gains waitForFirstFrame(timeout:) — the renderers accept
media with the clock paused, so a freshly opened session reaches
first-frame-decoded without ever playing; its display layer already
shows a picture. state becomes publicly readable so consumers can check
a standby session's health before adopting it.

LumePlayer.prepare(next:) stages a URL in a fully independent standby
session through that gate, and load(url:) swaps it in atomically (the
old session tears down asynchronously — never blocking on thread
joins). Without a prepared session, the new seamlessSwitching
configuration option (default on) keeps the current session rendering
while the replacement opens to its first frame, then swaps — channel
zapping never shows a black gap. A failed seamless open retries cold,
which covers providers that refuse a second concurrent connection.
Generation counters make overlapping load/prepare/stop calls discard
their superseded sessions instead of installing them.

The session-epoch invariant is untouched: two sessions, one atomic
promotion, no rebuild-in-place (PLAN.md §3.1, §6).

Closes #16
IPTV accounts are often capped at one concurrent connection, and the
overlapped switch holds two by design. seamlessSwitching becomes
PlayerConfiguration.SwitchPolicy: .overlapped (unchanged default),
.sequential, and .none (the old cold path).

.sequential closes the current session *first* — a bounded join, so the
connection is genuinely free before the replacement opens — while its
display layer keeps showing the last decoded frame (SystemRenderer's
shutdown flushes without removing the displayed image). The layers swap
once the replacement holds its first frame: playback pauses during the
switch, but the screen never blanks and at most one source connection
exists at any moment. A failed overlapped open now retries sequentially
instead of cold, keeping the frozen frame up during the retry.

prepare(next:) documents that a standby costs the overlapped budget;
consuming a prepared session stays allowed under any policy since the
swap opens nothing new.
@bilipp

bilipp commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Added single-connection support (6dacd95): seamlessSwitching is now PlayerConfiguration.SwitchPolicy.overlapped (default, two connections, old keeps playing), .sequential (one connection: the current session closes first with a bounded join, its last decoded frame stays frozen on screen, and the layers swap once the replacement holds its first frame — the screen never blanks), and .none (cold). A refused overlapped open now falls back to the sequential path instead of a cold retry, so even that failure keeps a picture up. Tests extended to 10 (sequential switch, sequential failure keeps the frozen surface); full suite 71/71.

bilipp added a commit to bilipp/Lume that referenced this pull request Jul 13, 2026
LumeEngineCoordinator.configure now keeps the current session on screen
while the replacement opens through its first decoded frame, then swaps
the display layers atomically — channel surfing and episode changes
never show a black gap. prepareNext(media:) stages the queued episode
in a standby session so auto-advance lands on an already-decoded frame;
PlayerNextUpOverlay fires a latched onPrepareNext hook ~30 s before the
end (only when an advance is actually coming), wired up by
LumeEngineEngineView.

While a switch is in flight, the outgoing session's stall/failure
events no longer schedule retries and its position no longer leaks into
the new stream's clock. A failed seamless open falls back to the cold
teardown-first path, which also covers providers that cap concurrent
connections; reload() stays cold on purpose — stall recovery re-opens
the same URL and must release the connection first. Generation counters
let rapid zapping cancel in-flight switches and prepares cleanly.

New "Seamless Switching" toggle (default on) in the Lume Engine options
(iOS/macOS form + tvOS detail), persisted and covered by Restore
Defaults. Requires the LumeEngine prepare/first-frame API
(bilipp/LumeEngine#31).
@bilipp bilipp closed this Jul 25, 2026
@bilipp
bilipp deleted the feature/zero-delay-switching branch July 25, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zero-delay stream switching (prepare(next:))

1 participant