Skip to content

Seamless stream switching for the Lume Engine (zero-delay zap/advance) - #134

Closed
bilipp wants to merge 16 commits into
mainfrom
feature/lume-engine-seamless-switching
Closed

Seamless stream switching for the Lume Engine (zero-delay zap/advance)#134
bilipp wants to merge 16 commits into
mainfrom
feature/lume-engine-seamless-switching

Conversation

@bilipp

@bilipp bilipp commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Wires the engine's zero-delay switching (bilipp/LumeEngine#31, engine issue bilipp/LumeEngine#16) into the app. Merge the engine PR first — this branch calls the new waitForFirstFrame / public-state API.

What changes

  • Channel zapping / episode switches: LumeEngineCoordinator.configure now keeps the current session on screen while the replacement opens through its first decoded frame, then swaps the display layers atomically (displayLayer became @Published so the video surface re-installs). The screen never goes black; a spinner shows over the still-playing outgoing stream.
  • Next-episode auto-advance: prepareNext(media:) stages the queued episode in a standby session through first-frame-decoded. PlayerNextUpOverlay fires a latched onPrepareNext hook ~30 s before the end — only when premium + auto-advance/Next-button are actually enabled — so the advance lands on an already-decoded frame with zero delay. Other engines are unaffected (onPrepareNext defaults to nil).
  • Safety during a switch: the outgoing session's stall/failure events no longer schedule retries (the switch supersedes them), and its position no longer leaks into the new stream's clock. Generation counters let rapid zapping cancel in-flight switches/prepares cleanly.
  • Fallbacks: a failed seamless open falls back to the cold teardown-first path — that also covers providers that cap concurrent connections (teardown frees the slot before the retry). reload() stays cold on purpose: stall recovery re-opens the same URL and must release the connection first.
  • Setting: new "Seamless Switching" toggle (default on) in Lume Engine Options — iOS/macOS form and tvOS detail — persisted as player.lume.seamlessSwitching, covered by Restore Defaults. The footer warns that a switch briefly holds two provider connections (the reason to turn it off on single-connection accounts).

Trade-off to know

With the option on, a failed switch costs up to open-timeout + one cold retry before the failure overlay appears — the price of never blanking the screen.

Verification

Builds clean for macOS and tvOS Simulator against the local engine package; the engine-side behavior is covered by 6 new tests in LumeEngine (SeamlessSwitchingTests, full suite 69/69).

bilipp added 2 commits July 11, 2026 11:35
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).
The overlapped seamless switch holds two provider connections, which a
max_connections=1 account cannot serve — the second open gets refused
or, worse, kills the playing stream. The coordinator now budgets per
switch: overlapped only when the two streams belong to different
playlists (they never contend), either side is a local file, or the
account is known to allow at least two concurrent connections;
otherwise a new sequential switch closes the outgoing session first
(bounded join, so the connection is genuinely free), keeps its last
decoded frame frozen on screen, and swaps layers once the replacement
holds its first frame — one connection at any moment, and still no
black gap. prepareNext is gated on the same overlap budget, since a
standby session holds a connection of its own. A refused overlapped
open retries sequentially before falling back cold.

PlayableMedia carries the data: a stored maxConnections (from the
owning playlist's Xtream max_connections, populated by the factories
and forwarded through resuming/replacingURL) and a derived playlistID
(the 36-char playlist-UUID prefix every catalog id embeds — the same
recovery StalkerStreamResolver uses). Unknown caps (m3u, stalker,
panels reporting "0") count as one connection: a wrong overlap can kill
both streams, while a sequential switch only costs the moving picture
during the switch.

The switch flows move to LumeEngineCoordinator+Switching.swift (file
length); the plumbing that mutates private(set) published state
(beginSwitch, detachOutgoingSession, adopt, discardPreparedNext) stays
on the class.
@bilipp

bilipp commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Connection-aware update (12eb1b2): switching now never exceeds the playlist's connection cap.

  • PlayableMedia carries maxConnections (from the owning playlist's Xtream max_connections, populated by the factories, forwarded through resuming/replacingURL) and derives playlistID from the 36-char playlist-UUID prefix every catalog id embeds (same recovery StalkerStreamResolver uses).
  • Per switch, the coordinator picks: overlapped (old stream keeps playing) only when the streams belong to different playlists, either side is a local file, or the account is known to allow ≥2 concurrent connections; otherwise a new sequential switch closes the outgoing session first (bounded join — the connection is genuinely free before the new open), keeps its last decoded frame frozen on screen, and swaps layers at the replacement's first frame. One provider connection at any moment, still no black gap.
  • prepareNext obeys the same budget (a standby holds its own connection), so single-connection accounts skip the pre-open and auto-advance goes through the sequential path instead.
  • Unknown caps (m3u, stalker, panels reporting "0") are treated as one connection — a wrong overlap can kill both streams, while sequential only costs the moving picture during the switch. A refused overlapped open retries sequentially before falling back cold.
  • The switch flows moved to LumeEngineCoordinator+Switching.swift (file-length lint); the plumbing that mutates private(set) published state stays on the class.

Companion engine change: bilipp/LumeEngine#31 now ships SwitchPolicy (.overlapped/.sequential/.none). Settings footer updated — the toggle no longer needs to be turned off for single-connection providers.

Verified: macOS + tvOS builds, PlayableMediaTests (incl. Codable round-trip) green, engine suite 71/71.

bilipp and others added 14 commits July 12, 2026 21:55
Explicit-self errors in Logger interpolation can't use self.prop —
swiftformat's redundantSelf strips it and re-breaks the build.
feat(settings): add appearance override (System / Dark / Light)
A user's movie sync failed reproducibly: categories synced, then
get_vod_streams was rejected through all three attempts and the whole
content phase aborted. The launch EPG refresh was downloading xmltv.php
from the same provider on a second connection (M3UClient has its own
URLSession), tripping the one-connection account cap XtreamClient
deliberately serializes around — and since neither the playlist nor the
EPG schedule records a successful sync after the collision, both were
due again on every launch, reproducing it forever.

- AutoSync.blocksEPGRefresh: pure gate — a playlist blocks a background
  guide refresh while its content sync is running or due to start.
- EPGSyncService.syncIfDue (launch) defers behind that gate; the new
  syncAfterContentSync (post-sync hook) ignores EPG dueness but defers
  while another queued playlist sync is pending. Manual "Sync Now"
  stays ungated.
- Diagnostic logs: credential-free logDescription on XtreamError and
  M3UError, interpolated with privacy .public (the user's log only
  showed "<private>"), plus a new error line when Xtream retries
  exhaust — the final failure used to be silent. XtreamError moved to
  its own file (XtreamClient crossed the 600-line cap).
- Tests: cover the new gate; the two EPGSyncSchedule tests now run in
  a serialized suite — they race each other via UserDefaults.standard.
fix(sync): stop the launch EPG refresh from racing the playlist sync
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).
The overlapped seamless switch holds two provider connections, which a
max_connections=1 account cannot serve — the second open gets refused
or, worse, kills the playing stream. The coordinator now budgets per
switch: overlapped only when the two streams belong to different
playlists (they never contend), either side is a local file, or the
account is known to allow at least two concurrent connections;
otherwise a new sequential switch closes the outgoing session first
(bounded join, so the connection is genuinely free), keeps its last
decoded frame frozen on screen, and swaps layers once the replacement
holds its first frame — one connection at any moment, and still no
black gap. prepareNext is gated on the same overlap budget, since a
standby session holds a connection of its own. A refused overlapped
open retries sequentially before falling back cold.

PlayableMedia carries the data: a stored maxConnections (from the
owning playlist's Xtream max_connections, populated by the factories
and forwarded through resuming/replacingURL) and a derived playlistID
(the 36-char playlist-UUID prefix every catalog id embeds — the same
recovery StalkerStreamResolver uses). Unknown caps (m3u, stalker,
panels reporting "0") count as one connection: a wrong overlap can kill
both streams, while a sequential switch only costs the moving picture
during the switch.

The switch flows move to LumeEngineCoordinator+Switching.swift (file
length); the plumbing that mutates private(set) published state
(beginSwitch, detachOutgoingSession, adopt, discardPreparedNext) stays
on the class.
@bilipp
bilipp marked this pull request as draft July 14, 2026 12:57
@bilipp bilipp closed this Jul 25, 2026
@bilipp
bilipp deleted the feature/lume-engine-seamless-switching branch August 20, 2026 07:54
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.

1 participant