Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions iosApp/Tests/LoopbackBufferPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import XCTest
@testable import Silo

final class LoopbackBufferPolicyTests: XCTestCase {
func testRemoteHLSUsesOneSecondFastStartPolicy() {
let policy = AVPlayerBackend.startupBufferPolicy(
for: .remoteHLS(url: URL(string: "https://silo.invalid/master.m3u8")!, headers: [:])
)

XCTAssertEqual(policy, .fastStart(forwardBufferDuration: 1))
}

func testRemoteDirectKeepsSystemStartupBuffering() {
let policy = AVPlayerBackend.startupBufferPolicy(
for: .remoteDirect(url: URL(string: "https://silo.invalid/video.mp4")!, headers: [:])
)

XCTAssertEqual(policy, .systemDefault)
}

func testEventGeneratedMediaBitrateDrivesSteadyStateBufferTarget() {
let target = AVPlayerBackend.loopbackSteadyStateForwardBufferTarget(
forBitsPerSecond: 69_000_000,
Expand Down
32 changes: 32 additions & 0 deletions iosApp/Tests/PlaybackOriginStreamPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,38 @@ final class PlaybackOriginStreamPolicyTests: XCTestCase {
)
)
}

func testStartupLeadLimitParksSpeculativeFillWithBudgetAvailable() {
let limit = PlaybackSourcePrefetchPolicy.loopbackStartupMaximumAheadBytes
XCTAssertTrue(
PlaybackOriginStreamPolicy.shouldPause(
writeCursor: limit,
demandMark: 0,
globalBudgetAvailable: true,
maximumAheadBytes: limit
)
)
XCTAssertFalse(
PlaybackOriginStreamPolicy.shouldPause(
writeCursor: limit - 1,
demandMark: 0,
globalBudgetAvailable: true,
maximumAheadBytes: limit
)
)
}

func testBlockedDemandOverridesStartupLeadLimit() {
let limit = PlaybackSourcePrefetchPolicy.loopbackStartupMaximumAheadBytes
XCTAssertFalse(
PlaybackOriginStreamPolicy.shouldPause(
writeCursor: limit,
demandMark: limit,
globalBudgetAvailable: false,
maximumAheadBytes: limit
)
)
}
}

final class PlaybackOriginReconnectPolicyTests: XCTestCase {
Expand Down
114 changes: 81 additions & 33 deletions iosApp/iosApp/Screens/Player/AVPlayerRoute/AVPlayerBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,11 @@ final class AVPlayerBackend {
case siloLoopback(spec: LoopbackSessionSpec)
}

enum StartupBufferPolicy: Equatable {
case systemDefault
case fastStart(forwardBufferDuration: Double)
}

private struct MediaSelectionState {
let kind: PlayerTrack.Kind
let group: AVMediaSelectionGroup
Expand All @@ -399,6 +404,10 @@ final class AVPlayerBackend {
/// creation. Sized for fast initial readyToPlay — AVPlayer otherwise
/// waits for whole GOP-sized fragments before declaring ready.
private static let loopbackStartupForwardBuffer: Double = 4.0
/// Server HLS already arrives as short, independently playable media
/// fragments. Keep only a small explicit startup cushion so AVPlayer does
/// not add several seconds of conservative pre-roll after media is ready.
private static let remoteHLSStartupForwardBuffer: Double = 1.0
/// Local loopback startup watchdog (AetherEngine-style, replaces the old
/// fixed 12 s readiness timeout that killed healthy-but-slow startups —
/// living-room DV P7 + TrueHD→FLAC at a far resume needed >12 s while
Expand Down Expand Up @@ -530,6 +539,17 @@ final class AVPlayerBackend {
loopbackStartupForwardBuffer
}

static func startupBufferPolicy(for strategy: SourceStrategy?) -> StartupBufferPolicy {
switch strategy {
case .some(.remoteHLS):
return .fastStart(forwardBufferDuration: remoteHLSStartupForwardBuffer)
case .some(.siloLoopback):
return .fastStart(forwardBufferDuration: loopbackStartupForwardBuffer)
case .some(.remoteDirect), .none:
return .systemDefault
}
}

private static func loopbackLiveEdgeForwardBufferFloor(
targetDuration: Double?,
longestSegmentDuration: Double?
Expand Down Expand Up @@ -2185,7 +2205,7 @@ final class AVPlayerBackend {
if previousBitrate == nil, bitsPerSecond != nil,
let item = self.currentItem,
self.canRampLoopbackBufferToSteadyState {
self.rampLoopbackBufferToSteadyStateIfNeeded(for: item)
self.transitionStartupBufferToSteadyStateIfNeeded(for: item)
}
}
}
Expand All @@ -2197,7 +2217,7 @@ final class AVPlayerBackend {
self.emitPlaybackStats(referenceTime: self.currentTime(), force: true)
if let item = self.currentItem,
self.canRampLoopbackBufferToSteadyState {
self.rampLoopbackBufferToSteadyStateIfNeeded(for: item)
self.transitionStartupBufferToSteadyStateIfNeeded(for: item)
self.sampleLocalLoopbackEdge(item: item, referenceTime: self.currentTime(), trigger: "generated_stats")
}
}
Expand Down Expand Up @@ -2347,16 +2367,25 @@ final class AVPlayerBackend {
// over the source's bitrate is small (e.g. 4K DV at 72 Mbps over
// 80 Mbps).
//
// Remote routes keep AVPlayer's defaults since automatic buffering
// is genuinely useful over the WAN.
if case .siloLoopback = currentSourceStrategy {
// Server HLS gets the same two-phase treatment with a smaller startup
// target: start from the first available fragment, then restore
// AVPlayer's automatic WAN buffering immediately after the first
// displayed frame. Direct-file playback keeps the system defaults.
switch Self.startupBufferPolicy(for: currentSourceStrategy) {
case .fastStart(let forwardBufferDuration):
avPlayer.automaticallyWaitsToMinimizeStalling = false
item.preferredForwardBufferDuration = Self.loopbackStartupForwardBuffer
// Do not let AVPlayer poll the local EVENT playlist while paused.
// Under disk pressure the writer may pause appends until playback
// frees spill capacity; paused polling can therefore see an
// unchanged playlist long enough for CoreMedia to fail the item.
item.canUseNetworkResourcesForLiveStreamingWhilePaused = false
item.preferredForwardBufferDuration = forwardBufferDuration
if case .siloLoopback = currentSourceStrategy {
// Do not let AVPlayer poll the local EVENT playlist while
// paused. Under disk pressure the writer may pause appends
// until playback frees spill capacity; paused polling can
// therefore see an unchanged playlist long enough for
// CoreMedia to fail the item.
item.canUseNetworkResourcesForLiveStreamingWhilePaused = false
}
case .systemDefault:
avPlayer.automaticallyWaitsToMinimizeStalling = true
item.preferredForwardBufferDuration = 0
}
currentItem = item
beginInitialVideoDisplayGate()
Expand Down Expand Up @@ -3722,7 +3751,13 @@ final class AVPlayerBackend {

private func startPlaybackIfNeeded(for item: AVPlayerItem) {
armInitialVideoDisplayGateIfNeeded()
avPlayer.play()
if isWaitingForInitialVideoDisplay,
case .fastStart = Self.startupBufferPolicy(for: currentSourceStrategy) {
avPlayer.playImmediately(atRate: 1.0)
Self.logger.info("[CMP-AVP] requested immediate startup playback")
} else {
avPlayer.play()
}
if isWaitingForInitialVideoDisplay {
scheduleInitialVideoDisplayFallback(for: item)
} else {
Expand Down Expand Up @@ -3934,35 +3969,48 @@ final class AVPlayerBackend {
avPlayer.isMuted = false
didTemporarilyMuteForInitialVideoDisplay = false
}
rampLoopbackBufferToSteadyStateIfNeeded(for: item)
transitionStartupBufferToSteadyStateIfNeeded(for: item)
Self.logger.info("[CMP-AVP] initial video display gate released reason=\(reason, privacy: .public)")
}

/// Once the first frame is on screen, apply the serving mode's steady-
/// state target and re-enable automatic waiting. EVENT playlists expand
/// their live-edge cushion; static VOD playlists stay near one segment so
/// AVPlayer cannot outrun the bounded producer window.
private func rampLoopbackBufferToSteadyStateIfNeeded(for item: AVPlayerItem) {
guard case .siloLoopback(let spec) = currentSourceStrategy else { return }
guard canRampLoopbackBufferToSteadyState else { return }
let generatedStats = latestLoopbackGeneratedStats
let mediaBitrate = generatedStats?.rollingBitrateBps ?? spec.sourceBitrateBps
let target = Self.loopbackSteadyStateForwardBufferTarget(
forBitsPerSecond: mediaBitrate,
targetDuration: generatedStats.map { Double($0.targetDuration) },
longestSegmentDuration: generatedStats?.longestSegmentDuration,
servingMode: spec.servingMode
)
let shouldRaiseForwardBuffer = item.preferredForwardBufferDuration < target
let shouldEnableAutomaticWaiting = !avPlayer.automaticallyWaitsToMinimizeStalling
guard shouldRaiseForwardBuffer || shouldEnableAutomaticWaiting else { return }
if shouldRaiseForwardBuffer {
item.preferredForwardBufferDuration = target
private func transitionStartupBufferToSteadyStateIfNeeded(for item: AVPlayerItem) {
switch currentSourceStrategy {
case .some(.remoteHLS):
let shouldResetForwardBuffer = item.preferredForwardBufferDuration != 0
let shouldEnableAutomaticWaiting = !avPlayer.automaticallyWaitsToMinimizeStalling
guard shouldResetForwardBuffer || shouldEnableAutomaticWaiting else { return }
item.preferredForwardBufferDuration = 0
avPlayer.automaticallyWaitsToMinimizeStalling = true
Self.logger.info(
"[CMP-AVP] remote HLS startup buffer released forwardBuffer=system automaticallyWaits=1"
)
case .some(.siloLoopback(let spec)):
guard canRampLoopbackBufferToSteadyState else { return }
let generatedStats = latestLoopbackGeneratedStats
let mediaBitrate = generatedStats?.rollingBitrateBps ?? spec.sourceBitrateBps
let target = Self.loopbackSteadyStateForwardBufferTarget(
forBitsPerSecond: mediaBitrate,
targetDuration: generatedStats.map { Double($0.targetDuration) },
longestSegmentDuration: generatedStats?.longestSegmentDuration,
servingMode: spec.servingMode
)
let shouldRaiseForwardBuffer = item.preferredForwardBufferDuration < target
let shouldEnableAutomaticWaiting = !avPlayer.automaticallyWaitsToMinimizeStalling
guard shouldRaiseForwardBuffer || shouldEnableAutomaticWaiting else { return }
if shouldRaiseForwardBuffer {
item.preferredForwardBufferDuration = target
}
avPlayer.automaticallyWaitsToMinimizeStalling = true
Self.logger.info(
"[CMP-AVP] loopback buffer ramp servingMode=\(String(describing: spec.servingMode), privacy: .public) forwardBuffer=\(target, privacy: .public)s automaticallyWaits=1 mediaBitrate=\(mediaBitrate ?? 0, privacy: .public)bps generatedBitrate=\(generatedStats?.rollingBitrateBps ?? 0, privacy: .public)bps declaredBitrate=\(spec.sourceBitrateBps ?? 0, privacy: .public)bps sourceReadBitrate=\(self.loopbackSourceDownloadBitrateBps ?? 0, privacy: .public)bps targetDuration=\(generatedStats?.targetDuration ?? 0, privacy: .public) longestSegment=\(generatedStats?.longestSegmentDuration ?? 0, privacy: .public)"
)
case .some(.remoteDirect), .none:
return
}
avPlayer.automaticallyWaitsToMinimizeStalling = true
Self.logger.info(
"[CMP-AVP] loopback buffer ramp servingMode=\(String(describing: spec.servingMode), privacy: .public) forwardBuffer=\(target, privacy: .public)s automaticallyWaits=1 mediaBitrate=\(mediaBitrate ?? 0, privacy: .public)bps generatedBitrate=\(generatedStats?.rollingBitrateBps ?? 0, privacy: .public)bps declaredBitrate=\(spec.sourceBitrateBps ?? 0, privacy: .public)bps sourceReadBitrate=\(self.loopbackSourceDownloadBitrateBps ?? 0, privacy: .public)bps targetDuration=\(generatedStats?.targetDuration ?? 0, privacy: .public) longestSegment=\(generatedStats?.longestSegmentDuration ?? 0, privacy: .public)"
)
}

private var canRampLoopbackBufferToSteadyState: Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,15 @@ enum PlaybackOriginStreamPolicy {
static func shouldPause(
writeCursor: Int64,
demandMark: Int64,
globalBudgetAvailable: Bool
globalBudgetAvailable: Bool,
maximumAheadBytes: Int64? = nil
) -> Bool {
if demandMark >= writeCursor { return false }
if let maximumAheadBytes,
maximumAheadBytes >= 0,
writeCursor - demandMark >= maximumAheadBytes {
return true
}
return !globalBudgetAvailable
}
}
Expand Down
14 changes: 14 additions & 0 deletions iosApp/iosApp/Screens/Player/PlaybackSourcePrefetchPolicy.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import Foundation

enum PlaybackSourcePrefetchPolicy {
/// Bound only the speculative lead while SiloPlayer is preparing its
/// first local-HLS fragment. The MKV planner performs latency-sensitive
/// head, tail, and midpoint reads before it can publish the VOD manifest.
/// Letting the unrelated sequential window fill the complete 256 MiB
/// source-cache budget during those probes can consume the WAN, evict
/// startup bytes, and make the real reader re-anchor the window.
///
/// This is an ahead-of-demand limit, not a download or startup quota:
/// cached reads advance the demand mark every 256 KiB, and a blocked read
/// at the cursor always overrides the limit. The view model releases it
/// after first frame so steady-state playback recovers the full cache
/// budget and its normal outage runway.
static let loopbackStartupMaximumAheadBytes: Int64 = 64 * 1024 * 1024

static func initialOffset(
sourceStartTimeSeconds: Double,
sourceBitrateBps: Double?
Expand Down
39 changes: 35 additions & 4 deletions iosApp/iosApp/Screens/Player/PlaybackSourceProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,11 @@ private final class PlaybackSourceResource {
private var windowStream: PlaybackOriginStream?
private var chunkFetcher: PlaybackOriginChunkFetcher?
private var demandCounter: UInt64 = 0
/// Optional startup-only lead limit for the sequential origin window.
/// Guarded by `stateLock`; nil means the normal cache high-water policy.
/// The cap follows the consumer's demand mark, so it can never strand a
/// blocked read and does not reduce the total bytes a title may consume.
private var startupPrefetchMaximumAheadBytes: Int64?
/// Current playback rate (1.0 = normal). Cache drain time — and thus the
/// adaptive detach grace — scales with consumption speed, not the file's
/// nominal bitrate. Guarded by `stateLock`.
Expand Down Expand Up @@ -776,7 +781,8 @@ private final class PlaybackSourceResource {
outageRideThroughEnabled: Bool = PlaybackOriginOutagePolicy.rideThroughEnabled(),
resumeCapable: Bool,
serverAdvertisesDirectStreamResume: Bool,
originStreamClock: PlaybackOriginStreamClock
originStreamClock: PlaybackOriginStreamClock,
startupPrefetchMaximumAheadBytes: Int64? = nil
) {
self.token = Self.makeToken()
self.originURL = originURL
Expand All @@ -789,6 +795,7 @@ private final class PlaybackSourceResource {
self.resumeCapable = resumeCapable
self.serverAdvertisesDirectStreamResume = serverAdvertisesDirectStreamResume
self.originStreamClock = originStreamClock
self.startupPrefetchMaximumAheadBytes = startupPrefetchMaximumAheadBytes.map { max(0, $0) }
}

deinit {
Expand Down Expand Up @@ -1024,6 +1031,22 @@ private final class PlaybackSourceResource {
stateLock.unlock()
}

/// Lift the startup-only speculative lead limit without replacing the
/// warm URLSession connection. A parked stream is nudged after the state
/// transition so it can immediately fill toward the cache high-water mark.
func releaseStartupPrefetchLimit() {
var windowToResume: PlaybackOriginStream?
stateLock.lock()
if startupPrefetchMaximumAheadBytes != nil {
startupPrefetchMaximumAheadBytes = nil
windowToResume = windowStream
}
stateLock.unlock()
guard let windowToResume else { return }
Self.logger.info("[CMP-SOURCE-CACHE] startup prefetch lead limit released")
windowToResume.resumeFillingIfNeeded()
}

private func currentPlaybackRate() -> Double {
stateLock.lock()
defer { stateLock.unlock() }
Expand Down Expand Up @@ -2008,11 +2031,13 @@ private final class PlaybackSourceResource {
stateLock.unlock()
return false
}
let maximumAheadBytes = startupPrefetchMaximumAheadBytes
stateLock.unlock()
return !PlaybackOriginStreamPolicy.shouldPause(
writeCursor: cursor,
demandMark: demandMark,
globalBudgetAvailable: cache.shouldPrefetch
globalBudgetAvailable: cache.shouldPrefetch,
maximumAheadBytes: maximumAheadBytes
)
}

Expand Down Expand Up @@ -2072,7 +2097,8 @@ final class PlaybackSourceProxy {
outageRideThroughEnabled: Bool = PlaybackOriginOutagePolicy.rideThroughEnabled(),
resumeCapable: Bool = false,
serverAdvertisesDirectStreamResume: Bool = false,
originStreamClock: PlaybackOriginStreamClock = SystemPlaybackOriginStreamClock()
originStreamClock: PlaybackOriginStreamClock = SystemPlaybackOriginStreamClock(),
startupPrefetchMaximumAheadBytes: Int64? = nil
) {
self.resource = PlaybackSourceResource(
originURL: originURL,
Expand All @@ -2084,7 +2110,8 @@ final class PlaybackSourceProxy {
outageRideThroughEnabled: outageRideThroughEnabled,
resumeCapable: resumeCapable,
serverAdvertisesDirectStreamResume: serverAdvertisesDirectStreamResume,
originStreamClock: originStreamClock
originStreamClock: originStreamClock,
startupPrefetchMaximumAheadBytes: startupPrefetchMaximumAheadBytes
)
}

Expand Down Expand Up @@ -2186,6 +2213,10 @@ final class PlaybackSourceProxy {
resource.startPrefetch(at: offset)
}

func releaseStartupPrefetchLimit() {
resource.releaseStartupPrefetchLimit()
}

/// Swap the origin endpoint in place after a silent session renewal.
/// See `PlaybackSourceResource.retargetOrigin`.
func retargetOrigin(url: URL, headers: [String: String]) {
Expand Down
Loading