> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://fastpix.com/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://fastpix.com/docs/_mcp/server.

# 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.

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](/docs/ios-player/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:

```swift
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

| Case                           | Meaning                                                                                                                   |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `.exact`                       | Zero 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                                           |
| `.default`                     | The SDK's historical behavior, exactly `.nearestKeyframe(tolerance: 0.1)`, so every pre-existing caller seeks identically |

### FastPixSeekOutcome

| Property        | Type                   | Description                                                                                      |
| --------------- | ---------------------- | ------------------------------------------------------------------------------------------------ |
| `requestedTime` | `TimeInterval`         | What you asked for                                                                               |
| `landedTime`    | `TimeInterval`         | Where the player ended up                                                                        |
| `precision`     | `FastPixSeekPrecision` | The precision this seek was issued with                                                          |
| `latency`       | `TimeInterval`         | Wall-clock request-to-completion. Reported for aggregation; never an input to any classification |
| `finished`      | `Bool`                 | False when AVFoundation cancelled the seek or a newer seek superseded it                         |
| `accuracy`      | `FastPixSeekAccuracy`  | The media-time verdict                                                                           |
| `clamp`         | `FastPixSeekClamp`     | Whether 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.

| Property                             | Type           | Description                                                                    |
| ------------------------------------ | -------------- | ------------------------------------------------------------------------------ |
| `requestedMediaTime`                 | `CMTime`       | What the host asked for, not the clamped target                                |
| `landedMediaTime`                    | `CMTime`       | Where the player ended up, read once from the completion handler               |
| `toleranceBefore` / `toleranceAfter` | `CMTime`       | The tolerances actually handed to `AVPlayer.seek`, in the item's own timescale |
| `withinRequestedTolerance`           | `Bool`         | The verdict, computed with `CMTimeCompare`                                     |
| `delta`                              | `TimeInterval` | Signed; positive means the player landed after the requested position          |
| `absoluteDelta`                      | `TimeInterval` | Unsigned magnitude of `delta`                                                  |

### FastPixSeekClamp

| Case                                     | Meaning                                                                                                         |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `.none`                                  | The 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       |
| `.seekableRangeUnknown`                  | No 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.

```swift
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`:

```swift
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

| Case         | Meaning                                                                 |
| ------------ | ----------------------------------------------------------------------- |
| `.idle`      | No gesture in progress                                                  |
| `.scrubbing` | Receiving samples. No seek has been issued and none will be until `end` |
| `.settling`  | The 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:

```swift
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

| Case                                                | Meaning                                                                                                                              |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `.noCurrentItem`                                    | Nothing is attached to the player                                                                                                    |
| `.itemNotReady`                                     | The item is not `.readyToPlay`, so a step has no defined landing position                                                            |
| `.noVideoTrack`                                     | Permanent for this item. An audio-only item will never become steppable, so disable your step controls for good rather than retrying |
| `.atLiveEdge`                                       | Forward 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:

```swift
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

| Property                       | Type                      | Description                                                |
| ------------------------------ | ------------------------- | ---------------------------------------------------------- |
| `start` / `end`                | `CMTime`                  | The seekable window, read fresh at the instant of the call |
| `isLive`                       | `Bool`                    | Whether this is a live or DVR stream                       |
| `isDateAddressable`            | `Bool`                    | Whether the manifest publishes program date time           |
| `startDate` / `endDate`        | `Date?`                   | Wall-clock bounds, when date-addressable                   |
| `duration` / `durationSeconds` | `CMTime` / `TimeInterval` | Window length                                              |
| `isAvailable`                  | `Bool`                    | False for `FastPixLiveSeekWindow.unavailable`              |

### FastPixDateSeekUnavailableReason

| Case                                                           | Meaning                                                                                        |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `.noCurrentItem`                                               | Nothing is attached to the player                                                              |
| `.streamIsNotDateAddressable`                                  | The 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

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.

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:)`.

`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.

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.