Skip to navigation

Seek with precision

Learn how to request a seek precision, read back where the player actually landed, coalesce scrub gestures, and step frames in the FastPix iOS Player.

The FastPix iOS Player SDK lets you ask how precisely a seek should land, and then tells you where it actually landed. Nothing here promises frame accuracy. AVFoundation seeks in media time, the frame grid of an HLS rendition is not knowable from the player, and a zero-tolerance request on a sparsely-keyframed encode still has to decode forward from the preceding keyframe. What .exact guarantees is that no tolerance is requested and that the landing position is reported.

The verdict “did this land inside the tolerance I asked for?” is computed in rational media time with CMTimeCompare, never from Double seconds. TimeInterval cannot represent 1001/30000 s, so a seconds-based comparison flips on rounding error at exactly the boundary you care about. The TimeInterval members are derived for display and are never read back as inputs.

Precision seek requires the seek manager, which is created by setupSeekManager(delegate:). For a thumbnail preview while scrubbing, see Add seek preview thumbnails.

Seek with an explicit precision

Set up the seek manager, then pass a FastPixSeekPrecision on each seek. The completion handler reports what you asked for, where the player landed, the delta, whether it was within tolerance, and whether the target was clamped:

playerViewController.setupSeekManager(delegate: self)
playerViewController.seek(to: 92.5, precision: .exact) { outcome in
print("requested:", outcome.requestedTime,
"landed:", outcome.landedTime,
"delta:", outcome.accuracy.delta,
"within tolerance:", outcome.accuracy.withinRequestedTolerance,
"finished:", outcome.finished,
"latency:", outcome.latency)
if outcome.clamp.didClamp {
// Snap your scrubber thumb to where the player really went.
print(outcome.clamp.explanation)
}
}
// Cheap and usually instant.
playerViewController.seek(to: 92.5, precision: .nearestKeyframe(tolerance: 0.5))

FastPixSeekPrecision

CaseMeaning
.exactZero tolerance requested. Not a frame-accuracy promise: the landing depends on the encode, and is reported
.nearestKeyframe(tolerance:)Land at the nearest keyframe within tolerance seconds. Cheap, usually instant
.defaultThe SDK’s historical behavior, exactly .nearestKeyframe(tolerance: 0.1), so every pre-existing caller seeks identically

FastPixSeekOutcome

PropertyTypeDescription
requestedTimeTimeIntervalWhat you asked for
landedTimeTimeIntervalWhere the player ended up
precisionFastPixSeekPrecisionThe precision this seek was issued with
latencyTimeIntervalWall-clock request-to-completion. Reported for aggregation; never an input to any classification
finishedBoolFalse when AVFoundation cancelled the seek or a newer seek superseded it
accuracyFastPixSeekAccuracyThe media-time verdict
clampFastPixSeekClampWhether the target was moved into the seekable range first

FastPixSeekAccuracy

The CMTime members are authoritative. requestedTime, landedTime, delta, and absoluteDelta are lossy conveniences for display and logging.

PropertyTypeDescription
requestedMediaTimeCMTimeWhat the host asked for, not the clamped target
landedMediaTimeCMTimeWhere the player ended up, read once from the completion handler
toleranceBefore / toleranceAfterCMTimeThe tolerances actually handed to AVPlayer.seek, in the item’s own timescale
withinRequestedToleranceBoolThe verdict, computed with CMTimeCompare
deltaTimeIntervalSigned; positive means the player landed after the requested position
absoluteDeltaTimeIntervalUnsigned magnitude of delta

FastPixSeekClamp

CaseMeaning
.noneThe target was inside the seekable range. Nothing was moved
.toSeekableStart(requested:clampedTo:)The target was before the start of the range
.toSeekableEnd(requested:clampedTo:)The target was past the end, the common live case, where the end is the live edge and moves between reads
.seekableRangeUnknownNo seekable ranges were published, so the target could not be range-checked. The seek still proceeds unmodified

Derived properties: didClamp (false for .none and .seekableRangeUnknown), clampedTarget, and explanation. The distinction matters for a scrubber: a real clamp should snap the thumb, an unknown range should not.

Listen for precision events

Every method has a no-op default, so adopting one event does not oblige you to implement the rest. Events are delivered on the main queue. Listeners are held weakly, so retain yours.

playerViewController.addSeekPrecisionListener(self)
extension VideoPlayerViewController: SeekPrecisionListener {
func onSeekAccuracy(_ accuracy: FastPixSeekAccuracy, outcome: FastPixSeekOutcome) {
print("[Seek] off by", accuracy.absoluteDelta, "s; ok:", accuracy.withinRequestedTolerance)
}
// Fired only when a target was actually moved.
func onSeekClamped(_ clamp: FastPixSeekClamp) {
print("[Seek]", clamp.explanation)
}
func onScrubStateChanged(_ state: FastPixScrubState) { updateScrubUI(state) }
// Throttled positions during a drag. NO seek is associated with this event.
func onScrubPositionChanged(_ position: FastPixScrubPosition) {
timeLabel.text = format(position.time)
}
func onFrameStep(_ result: FastPixFrameStepResult) {
if let reason = result.unavailableReason { print("[Step] refused:", reason.explanation) }
}
}
playerViewController.removeSeekPrecisionListener(self)

Coalesce a scrub into one seek

updateScrubbing(to:) issues zero seeks, which is precisely why it is safe to call at touch rate. One seek is issued, at endScrubbing:

guard let scrub = playerViewController.scrubSession else { return }
scrub.positionThrottle = FastPixScrubSessionCoordinator.defaultPositionThrottle // 0.1 s
// Gesture began
scrub.beginScrubbing(at: currentTime)
// Gesture moved: sample freely, nothing seeks
scrub.updateScrubbing(to: proposedTime)
// Gesture ended: the one seek
scrub.endScrubbing(at: proposedTime, precision: .exact) { result in
print("samples:", result.sampleCount,
"cancelled:", result.wasCancelled,
"landed:", result.outcome?.landedTime as Any)
}
// Gesture cancelled: nothing to undo, because update never seeked
scrub.cancelScrubbing()

FastPixScrubState

CaseMeaning
.idleNo gesture in progress
.scrubbingReceiving samples. No seek has been issued and none will be until end
.settlingThe gesture ended and the single coalesced seek is in flight

isActive is derived and true for anything but .idle. FastPixScrubPosition carries time and sampleCount, the latter published so you can see the coalescing working rather than taking it on trust. FastPixScrubSessionResult carries outcome (nil when cancelled), sampleCount, and the derived wasCancelled.

Step frames

Step a single frame forward or backward. The result is either the landed media time or a typed reason the step could not happen:

let result = playerViewController.stepFrame(.forward, count: 1)
switch result {
case .stepped(let direction, let landedMediaTime):
print("[Step]", direction, "->", landedMediaTime.seconds)
case .unavailable(let reason):
print("[Step] cannot:", reason.explanation)
}

FastPixFrameStepUnavailableReason

CaseMeaning
.noCurrentItemNothing is attached to the player
.itemNotReadyThe item is not .readyToPlay, so a step has no defined landing position
.noVideoTrackPermanent for this item. An audio-only item will never become steppable, so disable your step controls for good rather than retrying
.atLiveEdgeForward stepping at the live edge: the next frame has not been published yet
.cannotStepInDirection(FastPixFrameStepDirection)The player reports it cannot step that way right now. Backward is the common case: it needs frames the decoder may no longer hold

didStep and unavailableReason are derived on FastPixFrameStepResult. landedMediaTime is read immediately after the step is issued. AVFoundation’s step is asynchronous in general, so treat it as the position at the moment of the call and take the periodic time observer as authoritative afterwards.

Seek by wall clock, and to the live edge

For a date-addressable live stream, seek to a wall-clock time, or jump to the live edge:

let window = playerViewController.liveSeekWindow
if window.isDateAddressable, let start = window.startDate {
playerViewController.seek(toDate: start.addingTimeInterval(120),
precision: .nearestKeyframe(tolerance: 0.5)) { result in
switch result {
case .seeked(let outcome):
print("[Seek] landed:", outcome.landedTime)
case .unavailable(let reason):
print("[Seek] unavailable:", reason.explanation)
}
}
}
playerViewController.seekToLiveEdge { outcome in
print("[Seek] live edge:", outcome?.landedTime as Any)
}

FastPixLiveSeekWindow

PropertyTypeDescription
start / endCMTimeThe seekable window, read fresh at the instant of the call
isLiveBoolWhether this is a live or DVR stream
isDateAddressableBoolWhether the manifest publishes program date time
startDate / endDateDate?Wall-clock bounds, when date-addressable
duration / durationSecondsCMTime / TimeIntervalWindow length
isAvailableBoolFalse for FastPixLiveSeekWindow.unavailable

FastPixDateSeekUnavailableReason

CaseMeaning
.noCurrentItemNothing is attached to the player
.streamIsNotDateAddressableThe manifest publishes no program date time. The SDK does not invent one; seek by time instead
.dateOutsideSeekableWindow(requested:windowStart:windowEnd:)Reported rather than clamped, so a host asking for yesterday is told, not quietly moved

Note:

  • No frame-accuracy promise. .exact requests zero tolerance and reports the landing; it does not guarantee a nominated frame. On a sparsely-keyframed encode, AVFoundation decodes forward from the preceding keyframe, and “one frame” is not a meaningful unit for audio-only content at all.
  • Judge tolerance from accuracy.withinRequestedTolerance or the CMTime members, not from delta in seconds. TimeInterval cannot represent 1001/30000 s, and a seconds-based comparison flips on rounding error exactly at the boundary.
  • accuracy.requestedMediaTime is what you asked for, not the clamped target. A seek to -5 s on a stream starting at 0 is clamped and lands at 0; the delta is +5, and clamp says why.
  • finished == false means cancelled or superseded. AVFoundation’s own flag covers only the first; a host scrubbing quickly generates the second constantly, and they are different facts.
  • didClamp is false for .seekableRangeUnknown. That case reports an absence of information, the seek proceeded unmodified, and treating it as a clamp would move your scrubber thumb for no reason.
  • updateScrubbing(to:) issues no seek. Sampling it at touch rate is safe by construction, and a cancelled gesture needs no undo because the playhead never moved.
  • Precision seek needs setupSeekManager(delegate:). Without it, seek(to:precision:) still calls back, with finished: false and clamp: .seekableRangeUnknown, rather than silently doing nothing.
  • Seek precision listeners are held weakly. Keep a strong reference, or your events stop arriving.

Frequently asked questions

Does the FastPix iOS Player support frame-accurate seeking?

The SDK does not promise frame accuracy. AVFoundation seeks in media time, and the frame grid of an HLS rendition is not knowable from the player. The .exact precision requests zero tolerance and reports where the seek actually landed, but on a sparsely-keyframed encode it still decodes forward from the preceding keyframe. For single-frame movement, use stepFrame(_:count:), which refuses with a typed reason when stepping is not possible.

How do I find out where a seek actually landed in the FastPix iOS Player?

The seek completion handler returns a FastPixSeekOutcome with landedTime, the requested time, and an accuracy verdict. Judge whether the seek hit the tolerance from accuracy.withinRequestedTolerance or the CMTime members, not from delta in seconds, because TimeInterval rounding flips exactly at the boundary. Precision seeking requires setupSeekManager(delegate:).

Why does my seek report finished: false?

finished == false means the seek was cancelled or superseded. AVFoundation’s own flag covers only cancellation, while a newer seek overtaking an in-flight one, which happens constantly when a user scrubs quickly, produces the superseded case. They are different facts reported through the same flag.

How do I avoid firing a seek on every scrub movement?

Use the scrub session. Call beginScrubbing(at:) when the gesture starts, updateScrubbing(to:) as it moves, and endScrubbing(at:precision:) when it ends. updateScrubbing(to:) issues zero seeks, so it is safe to call at touch rate, and exactly one coalesced seek is issued at endScrubbing. A cancelled gesture needs no undo because the playhead never moved.