Skip to content

seek() fires SeekCompleted and returns without moving playback — silent no-op stub #398

Description

@forkwright

Finding

AndroidEngine::seek validates that playback is not stopped, emits a SeekCompleted event, and returns the clamped position — but it never communicates the requested position to the running playback_task. The task drives playback by looping over decoder.next_frame(); there is no seek channel, atomic, or any other signal it consumes. The decoder continues from wherever its internal cursor already sits, so the audio position is unchanged. The method reports success for an operation it does not perform.

Evidence

crates/akouo-android/src/lib.rs:247-259 — the entire body after the state guard only emits an event and returns:

pub async fn seek(&self, position_secs: f64) -> Result<f64, AndroidEngineError> {
    if self.state.load(Ordering::SeqCst) == STATE_STOPPED {
        return Err(AndroidEngineError::NotPlaying);
    }
    self.notify(AndroidEngineEvent {
        kind: AndroidEngineEventKind::SeekCompleted,
        path: None,
        message: None,
        position_secs: Some(position_secs.max(0.0)),
        underrun_count: None,
    });
    Ok(position_secs.max(0.0))
}

No channel send, no atomic store, no handle to playback_task is touched.

crates/akouo-android/src/lib.rs:345-359 — the playback loop only checks STATE_STOPPED/STATE_PAUSED and otherwise pulls the next decoded frame; it has no path to accept a target position:

loop {
    match context.state.load(Ordering::SeqCst) {
        STATE_STOPPED => break,
        STATE_PAUSED => {
            tokio::time::sleep(PAUSE_SLEEP).await;
            continue;
        }
        _ => {}
    }

    let frame = match decoder.next_frame().await {
        Ok(Some(frame)) => frame,
        Ok(None) => break,
        Err(e) => return Err(e.to_string()),
    };

Why this matters

Every seek request from the UI silently no-ops while reporting success. The seek bar appears responsive — a SeekCompleted event fires and the returned position echoes the request — but the audio stream never moves. The mismatch between the reported state and the actual decoder position desynchronizes any caller that trusts the returned value (progress display, resume-position persistence), and for long-form content (audiobooks, podcasts) it disables the primary navigation control entirely. A success result that does not reflect real engine state is also a correctness hazard for higher layers that branch on it.

Desired correction

Give the engine a way to push a target position into the running task — e.g. a watch::Sender<Option<f64>> (or equivalent) owned by AndroidEngine and read inside the playback_task loop. seek() sends the clamped target; the task observes the new value, calls decoder.seek(position), drops any in-flight buffered frames, and only then emits SeekCompleted reflecting the position the decoder actually reached. If no decoder seek is available, seek() must return an error rather than a fabricated success. Done when: calling seek(t) on a playing engine causes decoder.next_frame() to resume from position t (verified by a test asserting the post-seek frame timestamp/sample offset corresponds to t), and SeekCompleted carries the decoder's actual resumed position.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions