From dd6e4c8cb8d902991aeea54fc49ff52cdd818fce Mon Sep 17 00:00:00 2001 From: Philipp Bischoff Date: Sat, 11 Jul 2026 11:31:35 +0200 Subject: [PATCH 1/2] Zero-delay stream switching: prepare(next:) and seamless load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/LumeEngine/LumePlayer.swift | 159 ++++++++++++++++-- .../Session/PlayerSession.swift | 39 ++++- .../SeamlessSwitchingTests.swift | 147 ++++++++++++++++ 3 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift diff --git a/Sources/LumeEngine/LumePlayer.swift b/Sources/LumeEngine/LumePlayer.swift index 4ed25d1..66cb123 100644 --- a/Sources/LumeEngine/LumePlayer.swift +++ b/Sources/LumeEngine/LumePlayer.swift @@ -49,6 +49,17 @@ public final class LumePlayer { private var tickTask: Task? private let configuration: PlayerConfiguration + /// Standby session pre-opened by `prepare(next:)`, held through + /// first-frame-decoded until `load(url:)` consumes it. + private var preparedNext: (url: String, session: PlayerSession, info: MediaInfo)? + /// Supersession guards: a `load`/`prepare` that lost a race against a + /// newer call (or `stop`) discards its session instead of installing it. + private var loadGeneration: UInt64 = 0 + private var prepareGeneration: UInt64 = 0 + /// Bound on the first-frame wait during a seamless swap; past it the swap + /// proceeds anyway (losing only the no-gap guarantee). + private static let firstFrameTimeout: Double = 10 + public init(configuration: PlayerConfiguration = PlayerConfiguration()) { self.configuration = configuration } @@ -57,13 +68,148 @@ public final class LumePlayer { /// Opens `url`, replacing any previous session (each open is a fresh /// engine session — PLAN.md §3.1). + /// + /// Zero-delay switching (PLAN.md §6, on by default via + /// `PlayerConfiguration.seamlessSwitching`): when a session matching + /// `url` was staged by `prepare(next:)`, the swap is immediate. Otherwise, + /// if something is already open, the current session keeps rendering + /// while the replacement opens through its first decoded frame, and only + /// then is the renderer attachment swapped — the old session tears down + /// asynchronously. If the seamless open fails (dead source, or a provider + /// that refuses a second concurrent connection), the old session is torn + /// down and one cold open is retried before the error surfaces. public func load(url: String) async throws -> MediaInfo { + loadGeneration &+= 1 + let generation = loadGeneration + + // A prepared session for this exact URL swaps in with no open cost. + if configuration.seamlessSwitching, + let prepared = preparedNext, prepared.url == url { + preparedNext = nil + if await prepared.session.state != .failed { + guard generation == loadGeneration else { + Task { await prepared.session.shutdown() } + throw EngineError(code: .invalidState, message: "load superseded by a newer load") + } + adopt(session: prepared.session, info: prepared.info) + return prepared.info + } + // The standby died while waiting (network drop) — fall through. + let dead = prepared.session + Task { await dead.shutdown() } + } + discardPreparedSession() + + if configuration.seamlessSwitching, session != nil, state != .failed { + let next = PlayerSession(configuration: configuration) + do { + let info = try await next.open(url: url) + await next.waitForFirstFrame(timeout: Self.firstFrameTimeout) + guard generation == loadGeneration else { + Task { await next.shutdown() } + throw EngineError(code: .invalidState, message: "load superseded by a newer load") + } + adopt(session: next, info: info) + return info + } catch { + Task { await next.shutdown() } + guard generation == loadGeneration else { throw error } + // Cold retry with the old session gone: a provider capped at + // one concurrent connection refuses the overlapped open but + // accepts the same URL once the current stream is closed. + } + } + await teardownSession() + guard generation == loadGeneration else { + throw EngineError(code: .invalidState, message: "load superseded by a newer load") + } let session = PlayerSession(configuration: configuration) self.session = session state = .opening + startObservation(of: session) + do { + let info = try await session.open(url: url) + guard generation == loadGeneration else { + throw EngineError(code: .invalidState, message: "load superseded by a newer load") + } + mediaInfo = info + duration = info.duration.map(MediaTime.seconds) + return info + } catch { + // A superseded load must not clobber the newer load's state. + guard generation == loadGeneration else { throw error } + lastError = error as? EngineError + state = .failed + throw error + } + } + + /// Stages `url` in a standby session, opened through first-frame-decoded + /// but never played (PLAN.md §6). A following `load(url:)` for the same + /// URL swaps it in with zero delay — this powers next-episode + /// auto-advance and channel zapping. Current playback is untouched. + /// + /// Only one URL is staged at a time; a newer `prepare` replaces the + /// previous standby. The standby holds its own source connection and + /// read-ahead buffer until consumed or discarded. + @discardableResult + public func prepare(next url: String) async throws -> MediaInfo { + discardPreparedSession() + prepareGeneration &+= 1 + let generation = prepareGeneration + + let session = PlayerSession(configuration: configuration) + do { + let info = try await session.open(url: url) + await session.waitForFirstFrame(timeout: Self.firstFrameTimeout) + guard generation == prepareGeneration else { + Task { await session.shutdown() } + throw EngineError(code: .invalidState, message: "prepare superseded by a newer prepare") + } + preparedNext = (url, session, info) + return info + } catch { + Task { await session.shutdown() } + throw error + } + } + + /// Discards the standby session staged by `prepare(next:)`, if any. + public func discardPreparedSession() { + prepareGeneration &+= 1 + guard let prepared = preparedNext else { return } + preparedNext = nil + Task { await prepared.session.shutdown() } + } + + /// Atomic swap of `prepare`d/seamlessly opened media: the new session + /// becomes the active one (its display layer replaces the old one via the + /// `displayLayer` observation) and the old session is torn down + /// asynchronously — the swap never waits for thread joins. + private func adopt(session next: PlayerSession, info: MediaInfo) { + eventTask?.cancel() + tickTask?.cancel() + if let old = session { + Task { await old.shutdown() } + } + session = next + state = .ready + mediaInfo = info + duration = info.duration.map(MediaTime.seconds) + lastError = nil + subtitleText = nil + startObservation(of: next) + if rate != 1.0 { + let rate = rate + Task { await next.setRate(rate) } + } + } + + /// Event pump + 10 Hz position tick for the active session. + private func startObservation(of session: PlayerSession) { eventTask = Task { [events = session.events] in for await event in events { self.handle(event: event) @@ -75,17 +221,6 @@ public final class LumePlayer { await self.tick() } } - - do { - let info = try await session.open(url: url) - mediaInfo = info - duration = info.duration.map(MediaTime.seconds) - return info - } catch { - lastError = error as? EngineError - state = .failed - throw error - } } public func play() { @@ -127,6 +262,8 @@ public final class LumePlayer { } public func stop() async { + loadGeneration &+= 1 + discardPreparedSession() await teardownSession() state = .idle position = 0 diff --git a/Sources/LumeEngineCore/Session/PlayerSession.swift b/Sources/LumeEngineCore/Session/PlayerSession.swift index e10f287..2da0d27 100644 --- a/Sources/LumeEngineCore/Session/PlayerSession.swift +++ b/Sources/LumeEngineCore/Session/PlayerSession.swift @@ -45,6 +45,14 @@ public struct PlayerConfiguration: Sendable { public var muted = false /// Seconds without clock progress (while expected) before `.stalled` fires. public var stallThreshold: Double = 8 + /// Zero-delay stream switching (PLAN.md §6), consumed by the `LumePlayer` + /// facade: `load(url:)` on an already-open player keeps the current + /// session rendering while the replacement opens through its first + /// decoded frame, then swaps the renderer attachment atomically (the old + /// session is torn down asynchronously). Also gates `prepare(next:)` + /// consumption. The switch briefly holds two source connections — turn + /// this off for providers that allow only one concurrent stream. + public var seamlessSwitching = true public init() {} } @@ -78,7 +86,7 @@ public actor PlayerSession { /// `activeCues(at: renderer.currentTime)` from any thread. public nonisolated let subtitles = SubtitleStore() - private(set) var state: State = .idle { + public private(set) var state: State = .idle { didSet { if state != oldValue { eventSink.yield(.stateChanged(state)) } } @@ -372,6 +380,35 @@ public actor PlayerSession { ) } + // MARK: Zero-delay switching support (PLAN.md §6) + + /// Suspends until the renderer holds the first decoded frame of the + /// presentation lane — video when a video track is active, else audio. + /// The renderers accept media while the clock is paused, so a freshly + /// opened session reaches this point without ever playing: this is the + /// "prepared through first-frame-decoded" gate for zero-delay switching + /// (a standby session whose display layer already shows a picture can be + /// swapped in with no black gap). + /// + /// Returns `false` if the session fails, is torn down, or the timeout + /// elapses first — callers may still swap then; they just lose the + /// no-gap guarantee. + @discardableResult + public func waitForFirstFrame(timeout: Double = 10) async -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + if state == .failed || state == .idle && info != nil { return false } + let lowWater = renderer.enqueuedLowWaterMark + if videoDecoder != nil { + if MediaTime.isValid(lowWater.video) { return true } + } else if audioDecoder != nil { + if MediaTime.isValid(lowWater.audio) { return true } + } + try? await Task.sleep(for: .milliseconds(50)) + } + return false + } + // MARK: Seek /// Seeks to `position` seconds (media-relative). Flush order matters: diff --git a/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift b/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift new file mode 100644 index 0000000..a16e780 --- /dev/null +++ b/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import LumeEngine +@testable import LumeEngineCore + +/// Zero-delay stream switching (PLAN.md §6, issue #16): `prepare(next:)` +/// stages a standby session through first-frame-decoded and `load(url:)` +/// swaps it in atomically; without a prepared session, a seamless load keeps +/// the old session alive until the replacement holds its first frame. +@Suite("Zero-delay switching", .serialized) +struct SeamlessSwitchingTests { + private func makeConfiguration() -> PlayerConfiguration { + var configuration = PlayerConfiguration() + configuration.muted = true + configuration.bufferTarget = 0.3 + return configuration + } + + @Test("waitForFirstFrame resolves on an opened, never-played session", .timeLimit(.minutes(1))) + func firstFrameGate() async throws { + let session = PlayerSession(configuration: makeConfiguration()) + _ = try await session.open(url: try Fixtures.path("basic.mp4")) + + // Paused throughout: the renderers must accept the first frame with + // the clock stopped (that's what makes a standby swap gapless). + let ready = await session.waitForFirstFrame(timeout: 10) + #expect(ready, "first frame should decode without playback starting") + + let lowWater = session.renderer.enqueuedLowWaterMark + #expect(MediaTime.isValid(lowWater.video), "video renderer should hold the first frame") + #expect(session.renderer.rate == 0, "the standby clock must never run") + + await session.shutdown() + } + + @Test("waitForFirstFrame reports failure instead of hanging", .timeLimit(.minutes(1))) + func firstFrameGateFailure() async { + let session = PlayerSession(configuration: makeConfiguration()) + _ = try? await session.open(url: "/nonexistent/definitely-missing.mp4") + let ready = await session.waitForFirstFrame(timeout: 5) + #expect(!ready) + await session.shutdown() + } + + @Test("prepare(next:) then load swaps sessions atomically", .timeLimit(.minutes(1))) + @MainActor + func prepareThenLoad() async throws { + let player = LumePlayer(configuration: makeConfiguration()) + + _ = try await player.load(url: try Fixtures.path("basic.mp4")) + let firstLayer = player.displayLayer + player.play() + try await eventually(player, reaches: .playing) + + let nextURL = try Fixtures.path("multitrack.mkv") + let preparedInfo = try await player.prepare(next: nextURL) + #expect(preparedInfo.audioTracks.count == 2) + // Preparation must not disturb current playback. + #expect(player.state == .playing) + #expect(player.displayLayer === firstLayer) + + // The staged session is consumed: the swap needs no second open. + let start = Date() + let info = try await player.load(url: nextURL) + #expect(info.audioTracks.count == 2) + #expect(Date().timeIntervalSince(start) < 2, "a prepared load must not re-open the source") + #expect(player.displayLayer !== firstLayer, "the swap must install the standby session's layer") + + player.play() + try await eventually(player, reaches: .playing) + #expect(player.position < 9, "position must belong to the new session") + + await player.stop() + } + + @Test("seamless load keeps the old session until the new one has a frame", .timeLimit(.minutes(1))) + @MainActor + func seamlessLoadWithoutPrepare() async throws { + let player = LumePlayer(configuration: makeConfiguration()) + + _ = try await player.load(url: try Fixtures.path("basic.mp4")) + let firstLayer = player.displayLayer + player.play() + try await eventually(player, reaches: .playing) + + let info = try await player.load(url: try Fixtures.path("multitrack.mkv")) + #expect(info.audioTracks.count == 2) + #expect(player.displayLayer !== firstLayer) + + player.play() + try await eventually(player, reaches: .playing) + await player.stop() + } + + @Test("seamless load of a dead source falls back cold and fails loudly", .timeLimit(.minutes(1))) + @MainActor + func seamlessLoadFailure() async throws { + let player = LumePlayer(configuration: makeConfiguration()) + + _ = try await player.load(url: try Fixtures.path("basic.mp4")) + player.play() + try await eventually(player, reaches: .playing) + + await #expect(throws: EngineError.self) { + _ = try await player.load(url: "/nonexistent/definitely-missing.mp4") + } + // The failed load replaced the old session (load's contract), so the + // player ends in a reportable failed state — never a silent limbo. + // (The event pump delivers the session's state trail asynchronously, + // so allow it to settle.) + try await eventually(player, reaches: .failed) + + await player.stop() + } + + @Test("seamlessSwitching off restores the cold teardown-first path", .timeLimit(.minutes(1))) + @MainActor + func coldSwitchWhenDisabled() async throws { + var configuration = makeConfiguration() + configuration.seamlessSwitching = false + let player = LumePlayer(configuration: configuration) + + _ = try await player.load(url: try Fixtures.path("basic.mp4")) + player.play() + try await eventually(player, reaches: .playing) + + let info = try await player.load(url: try Fixtures.path("multitrack.mkv")) + #expect(info.audioTracks.count == 2) + player.play() + try await eventually(player, reaches: .playing) + + await player.stop() + } + + @MainActor + private func eventually( + _ player: LumePlayer, + reaches state: LumePlayer.State, + within seconds: Double = 10 + ) async throws { + let deadline = Date(timeIntervalSinceNow: seconds) + while player.state != state && Date() < deadline { + try await Task.sleep(for: .milliseconds(100)) + } + #expect(player.state == state) + } +} From 6dacd95877bca6acd9b8bcd69f2d05e7412bd37c Mon Sep 17 00:00:00 2001 From: Philipp Bischoff Date: Sun, 12 Jul 2026 11:45:20 +0200 Subject: [PATCH 2/2] Single-connection switching: SwitchPolicy with a sequential mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/LumeEngine/LumePlayer.swift | 80 +++++++++++++++---- .../Session/PlayerSession.swift | 30 +++++-- .../SeamlessSwitchingTests.swift | 52 +++++++++++- 3 files changed, 136 insertions(+), 26 deletions(-) diff --git a/Sources/LumeEngine/LumePlayer.swift b/Sources/LumeEngine/LumePlayer.swift index 66cb123..e4f9cb7 100644 --- a/Sources/LumeEngine/LumePlayer.swift +++ b/Sources/LumeEngine/LumePlayer.swift @@ -69,22 +69,23 @@ public final class LumePlayer { /// Opens `url`, replacing any previous session (each open is a fresh /// engine session — PLAN.md §3.1). /// - /// Zero-delay switching (PLAN.md §6, on by default via - /// `PlayerConfiguration.seamlessSwitching`): when a session matching - /// `url` was staged by `prepare(next:)`, the swap is immediate. Otherwise, - /// if something is already open, the current session keeps rendering - /// while the replacement opens through its first decoded frame, and only - /// then is the renderer attachment swapped — the old session tears down - /// asynchronously. If the seamless open fails (dead source, or a provider - /// that refuses a second concurrent connection), the old session is torn - /// down and one cold open is retried before the error surfaces. + /// Zero-delay switching (PLAN.md §6), governed by + /// `PlayerConfiguration.switchPolicy`: when a session matching `url` was + /// staged by `prepare(next:)`, the swap is immediate under any policy. + /// Otherwise `.overlapped` keeps the current session rendering while the + /// replacement opens through its first decoded frame (two connections + /// briefly), and `.sequential` closes the current session *first* — + /// freeing its source connection — while its last decoded frame stays + /// frozen on screen until the replacement is ready. A failed overlapped + /// open (e.g. a provider capped at one concurrent connection) falls back + /// to the sequential path before the error surfaces. public func load(url: String) async throws -> MediaInfo { loadGeneration &+= 1 let generation = loadGeneration - // A prepared session for this exact URL swaps in with no open cost. - if configuration.seamlessSwitching, - let prepared = preparedNext, prepared.url == url { + // A prepared session for this exact URL swaps in with no open cost + // (consuming it opens nothing new), so it is honored under any policy. + if let prepared = preparedNext, prepared.url == url { preparedNext = nil if await prepared.session.state != .failed { guard generation == loadGeneration else { @@ -100,7 +101,8 @@ public final class LumePlayer { } discardPreparedSession() - if configuration.seamlessSwitching, session != nil, state != .failed { + let canSwitch = session != nil && state != .failed + if canSwitch, configuration.switchPolicy == .overlapped { let next = PlayerSession(configuration: configuration) do { let info = try await next.open(url: url) @@ -114,11 +116,14 @@ public final class LumePlayer { } catch { Task { await next.shutdown() } guard generation == loadGeneration else { throw error } - // Cold retry with the old session gone: a provider capped at + // Fall through to the sequential retry: a provider capped at // one concurrent connection refuses the overlapped open but // accepts the same URL once the current stream is closed. } } + if canSwitch, configuration.switchPolicy != .none { + return try await sequentialLoad(url: url, generation: generation) + } await teardownSession() guard generation == loadGeneration else { @@ -147,14 +152,57 @@ public final class LumePlayer { } } + /// Single-connection switch: the current session shuts down first (its + /// display layer keeps the last decoded frame — `SystemRenderer.shutdown` + /// flushes without removing the displayed image, so the screen never + /// blanks), then the replacement opens and the layers swap at its first + /// frame. At most one source connection exists at any moment. Playback + /// pauses for the duration of the open — the price of the connection cap. + private func sequentialLoad(url: String, generation: UInt64) async throws -> MediaInfo { + eventTask?.cancel() + tickTask?.cancel() + if let old = session { + // Bounded join: the connection must actually be closed before the + // replacement opens, or a one-connection provider refuses it. + await old.shutdown() + } + state = .opening + guard generation == loadGeneration else { + throw EngineError(code: .invalidState, message: "load superseded by a newer load") + } + + let next = PlayerSession(configuration: configuration) + do { + let info = try await next.open(url: url) + await next.waitForFirstFrame(timeout: Self.firstFrameTimeout) + guard generation == loadGeneration else { + Task { await next.shutdown() } + throw EngineError(code: .invalidState, message: "load superseded by a newer load") + } + adopt(session: next, info: info) + return info + } catch { + Task { await next.shutdown() } + guard generation == loadGeneration else { throw error } + // The dead session stays attached: its frozen frame keeps the + // surface alive behind whatever failure UI the app raises. + lastError = error as? EngineError + state = .failed + throw error + } + } + /// Stages `url` in a standby session, opened through first-frame-decoded /// but never played (PLAN.md §6). A following `load(url:)` for the same /// URL swaps it in with zero delay — this powers next-episode /// auto-advance and channel zapping. Current playback is untouched. /// /// Only one URL is staged at a time; a newer `prepare` replaces the - /// previous standby. The standby holds its own source connection and - /// read-ahead buffer until consumed or discarded. + /// previous standby. The standby holds its **own source connection** and + /// read-ahead buffer until consumed or discarded — only call this when + /// the source allows a stream beside the playing one (the same budget as + /// `SwitchPolicy.overlapped`); on one-connection providers rely on the + /// sequential switch instead. @discardableResult public func prepare(next url: String) async throws -> MediaInfo { discardPreparedSession() diff --git a/Sources/LumeEngineCore/Session/PlayerSession.swift b/Sources/LumeEngineCore/Session/PlayerSession.swift index 2da0d27..45abe14 100644 --- a/Sources/LumeEngineCore/Session/PlayerSession.swift +++ b/Sources/LumeEngineCore/Session/PlayerSession.swift @@ -45,14 +45,28 @@ public struct PlayerConfiguration: Sendable { public var muted = false /// Seconds without clock progress (while expected) before `.stalled` fires. public var stallThreshold: Double = 8 - /// Zero-delay stream switching (PLAN.md §6), consumed by the `LumePlayer` - /// facade: `load(url:)` on an already-open player keeps the current - /// session rendering while the replacement opens through its first - /// decoded frame, then swaps the renderer attachment atomically (the old - /// session is torn down asynchronously). Also gates `prepare(next:)` - /// consumption. The switch briefly holds two source connections — turn - /// this off for providers that allow only one concurrent stream. - public var seamlessSwitching = true + + /// How `LumePlayer.load(url:)` replaces an already-open source + /// (zero-delay switching, PLAN.md §6). + public enum SwitchPolicy: Sendable, Equatable { + /// Zero-delay: the replacement opens behind the still-playing session + /// and swaps in once it holds its first decoded frame. Briefly holds + /// **two** source connections — use only when the source allows a + /// second concurrent stream (a refused overlapped open falls back to + /// `.sequential` automatically). + case overlapped + /// Single-connection zero-black: the current session closes *first* + /// (releasing its connection), its last decoded frame stays frozen on + /// screen while the replacement opens, and the layers swap at first + /// frame. Playback pauses during the switch but the screen never + /// blanks. The safe choice for providers capped at one connection. + case sequential + /// Teardown-first with a blank surface (no switching aid). + case none + } + + /// Switch behavior for `LumePlayer.load(url:)`; see `SwitchPolicy`. + public var switchPolicy: SwitchPolicy = .overlapped public init() {} } diff --git a/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift b/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift index a16e780..93c9f7d 100644 --- a/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift +++ b/Tests/LumeEngineCoreTests/SeamlessSwitchingTests.swift @@ -113,11 +113,59 @@ struct SeamlessSwitchingTests { await player.stop() } - @Test("seamlessSwitching off restores the cold teardown-first path", .timeLimit(.minutes(1))) + @Test("sequential policy switches on one connection at a time", .timeLimit(.minutes(1))) + @MainActor + func sequentialSwitch() async throws { + var configuration = makeConfiguration() + configuration.switchPolicy = .sequential + let player = LumePlayer(configuration: configuration) + + _ = try await player.load(url: try Fixtures.path("basic.mp4")) + let firstLayer = player.displayLayer + player.play() + try await eventually(player, reaches: .playing) + + // The old session closes before the new one opens (one connection); + // the surface swaps only once the replacement holds its first frame. + let info = try await player.load(url: try Fixtures.path("multitrack.mkv")) + #expect(info.audioTracks.count == 2) + #expect(player.displayLayer !== firstLayer) + + player.play() + try await eventually(player, reaches: .playing) + #expect(player.position < 9, "position must belong to the new session") + + await player.stop() + } + + @Test("sequential load of a dead source keeps the frozen surface and fails", .timeLimit(.minutes(1))) + @MainActor + func sequentialLoadFailure() async throws { + var configuration = makeConfiguration() + configuration.switchPolicy = .sequential + let player = LumePlayer(configuration: configuration) + + _ = try await player.load(url: try Fixtures.path("basic.mp4")) + let firstLayer = player.displayLayer + player.play() + try await eventually(player, reaches: .playing) + + await #expect(throws: EngineError.self) { + _ = try await player.load(url: "/nonexistent/definitely-missing.mp4") + } + try await eventually(player, reaches: .failed) + // The dead session stays attached so its last frame keeps the surface + // alive behind the app's failure UI. + #expect(player.displayLayer === firstLayer) + + await player.stop() + } + + @Test("switchPolicy .none restores the cold teardown-first path", .timeLimit(.minutes(1))) @MainActor func coldSwitchWhenDisabled() async throws { var configuration = makeConfiguration() - configuration.seamlessSwitching = false + configuration.switchPolicy = .none let player = LumePlayer(configuration: configuration) _ = try await player.load(url: try Fixtures.path("basic.mp4"))