Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Player IPC — Tauri commands + status events (M-PLAY.2)

The chunk that lifts playback::Player out of standalone-binary territory and behind a Tauri IPC surface. The Leptos shell can now drive a real Rust player from a button click, and the player can push state changes back without polling.

Data flow

sequenceDiagram
    participant UI as Leptos UI<br/>(transport buttons)
    participant Bridge as __TAURI__.core<br/>(JS bridge)
    participant Cmd as Tauri commands<br/>(crates/app)
    participant Session as PlayerSession<br/>(playback crate)
    participant Tick as Tick thread

    UI ->> Bridge: PlayerControls on_toggle (invoke)
    Bridge ->> Cmd: player_play / player_pause
    Cmd ->> Session: .play() / .pause()

    UI ->> Bridge: DropZone file path (invoke)
    Bridge ->> Cmd: player_open
    Cmd ->> Session: .open(path)

    rect rgb(245, 245, 250)
        Note over Tick: every 33 ms
        Tick ->> Session: .tick(dt) → .status()
        Session -->> Cmd: PlayerStatus
        Cmd -->> Bridge: emit("player-status")
        Bridge -->> UI: CustomEvent
    end

Every IPC hop is a one-liner. The bridge in index.html exposes three top-level helpers (__screenOpen / __screenPlay / __screenPause) and re-emits Tauri's player-status event as a browser CustomEvent. No tauri-sys crate; the WASM bundle stays dependency-free of Tauri's JS API.

Tauri commands (crates/app/src/commands.rs)

#![allow(unused)]
fn main() {
#[tauri::command]
pub fn player_open(state: State<'_, PlayerSession>, path: String) -> Result<PlayerStatus, String> {
    state.open(&PathBuf::from(path))
}

#[tauri::command]
pub fn player_play(state: State<'_, PlayerSession>) { state.play(); }

#[tauri::command]
pub fn player_pause(state: State<'_, PlayerSession>) { state.pause(); }

#[tauri::command]
pub fn player_status(state: State<'_, PlayerSession>) -> PlayerStatus { state.status() }
}

The four commands are thin wrappers around PlayerSession. The session itself is pure Rust (no Tauri types), so its lifecycle is testable end-to-end without booting Tauri — see crates/app/tests/player_session.rs (6 tests covering empty/open/play/ pause/tick/error paths).

Tick thread (crates/app/src/main.rs)

#![allow(unused)]
fn main() {
fn spawn_tick_thread(app_handle: tauri::AppHandle) {
    thread::spawn(move || {
        let mut last: Option<PlayerStatus> = None;
        loop {
            thread::sleep(TICK_INTERVAL);                  // 33 ms
            let session = app_handle.state::<PlayerSession>();
            session.tick();
            let status = session.status();
            if status_changed(last.as_ref(), &status) {
                let _ = app_handle.emit("player-status", &status);
                last = Some(status);
            }
        }
    });
}
}

status_changed throttles emits to:

  • every state transition (Empty → Paused → Playing → Ended),
  • every 100 ms of elapsed_ms change while playing (10 Hz UI updates).

A 33 ms tick × always-emit would be 30 events / sec hitting the webview; the 10 Hz throttle keeps the IPC bandwidth flat at the cost of slightly choppy timer animation. The play/pause UI flip is still instantaneous because the state change emits immediately.

JS bridge (crates/app-ui/index.html)

<script>
  window.addEventListener("DOMContentLoaded", () => {
    if (window.__TAURI__?.event) {
      window.__TAURI__.event.listen("player-status", (event) => {
        window.dispatchEvent(new CustomEvent("player-status", {
          detail: event.payload
        }));
      });
    }
  });

  window.__screenOpen  = (path) => window.__TAURI__?.core?.invoke("player_open", { path });
  window.__screenPlay  = ()     => window.__TAURI__?.core?.invoke("player_play");
  window.__screenPause = ()     => window.__TAURI__?.core?.invoke("player_pause");
</script>

Outbound is a thin core.invoke wrapper; inbound is the same CustomEvent re-emit pattern M-INT.2 introduced for file-dropped. Both directions degrade to no-ops when window.__TAURI__ is absent — a trunk serve dev session against the standalone Leptos shell still flips the drop-zone-to-player view via the demo affordance.

Leptos side (crates/app-ui/src/player_ipc.rs + app.rs)

#![allow(unused)]
fn main() {
#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = window, js_name = "__screenOpen", catch)]
    pub fn screen_open(path: &str) -> Result<JsValue, JsValue>;
    #[wasm_bindgen(js_namespace = window, js_name = "__screenPlay", catch)]
    pub fn screen_play() -> Result<JsValue, JsValue>;
    #[wasm_bindgen(js_namespace = window, js_name = "__screenPause", catch)]
    pub fn screen_pause() -> Result<JsValue, JsValue>;
}

pub fn install_player_status_listener(set_status: WriteSignal<PlayerStatus>) {
    /* CustomEvent listener — same shape as install_file_drop_listener */
    /* parses CE.detail() via serde-wasm-bindgen, calls set_status.set */
}
}

The PlayerStatus and SessionState types are mirrored on the Leptos side (Deserialize matches the Rust-side Serialize's rename_all = "lowercase" form). The mirror lives in crates/app-ui/src/player_ipc.rs and must stay in sync with crates/app/src/player_session.rs — they're a contract pair.

The transport buttons in <PlayerView> are wrapped in a reactive closure that re-renders <PlayerControls> whenever player_status changes, with an on_toggle: Callback<()> that picks screen_play or screen_pause based on the current state.

Testable surface (crates/app/tests/player_session.rs)

  • empty_session_reports_empty — fresh session, nothing loaded.
  • open_transitions_to_paused_with_metadata — open the test fixture, assert width/height/fps come through.
  • play_pause_lifecycle — round-trip play/pause/play.
  • tick_advances_elapsed_when_playing — confirms wallclock pumping.
  • tick_is_noop_when_empty — guard for the always-running tick thread.
  • open_with_invalid_path_errors — error string flows out cleanly.

How to run

# Dev — hot-reload via Trunk, Tauri webview on top.
cd crates/app && cargo tauri dev

# Drop the test fixture onto the window:
#   crates/decode/tests/fixtures/sample.mp4
# The status bar reflects the player's metadata; the play button toggles
# the Rust-side player; the timer ticks at 10 Hz.

App-shell visual references

The player view, post-drop, with its transport bar wired to the IPC commands. (Component-level layout is unchanged from M-INT.2 — what changed is the wiring underneath, not the rendered HTML.)

Visible playback — <video> element bound to convertFileSrc (M-PLAY.3)

The IPC plumbing above tracks state, but on its own renders no pixels. M-PLAY.3 wires the user-visible playback surface: an HTML5 <video> element whose src is derived from the dropped path via Tauri 2's convertFileSrc JS helper.

sequenceDiagram
    participant Drop as Drop event
    participant Signal as loaded signal
    participant Convert as window.__screenConvertFileSrc
    participant Video as &lt;video&gt; element<br/>(node_ref=video_ref)
    participant Toggle as PlayerControls toggle
    participant State as Tauri state<br/>(screen_play/pause)
    participant Event as player-status event

    Drop ->> Signal: file dropped
    Signal ->> Convert: video_src() resolves path
    Note over Convert: returns asset:// or<br/>http://asset.localhost URL
    Convert -->> Video: src= asset URL

    par user gesture (sync)
        Toggle ->> Video: video.play() / pause()
    and Tauri state mirror
        Toggle ->> State: screen_play() / screen_pause()
    end

    Event ->> Signal: Effect listens
    Signal ->> Video: catch-up play / pause / future seek

Why two paths to the <video> element

WebKit blocks programmatic .play() outside a user gesture. So:

  • Click handler drives <video> synchronously inside the Callback<()>. The browser sees this as user-initiated and allows playback to start.
  • Effect::new over player_status is the catch-up path for state changes that aren't user clicks — Tauri pushing Ended on EOF, future seek commands, etc. Idempotent: it only acts when video.paused() doesn't already match the target state, so it doesn't fight the click handler.

Why HTML5 video for the playback surface (and not wisp)

The recorder's editor preview surface will eventually be a winit-child window driven by wisp (so we can apply filters, transforms, animation). But for the MVP "user dropped a file and wants to see it", HTML5 <video> with the asset protocol is:

  • one element, no decoder integration,
  • hardware-accelerated by the WebView,
  • scrub-bar/seek/audio for free.

The Tauri-side PlayerSession keeps running alongside — it owns the gstreamer-decoded VideoTexture that future wisp-rendered surfaces will read. Two-source-of-truth is a deliberate trade for shipping the playback MVP today.

tauri.conf.json requirements

The assetProtocol.scope must include the dropped file's path. Our config uses ["**"] (any local file). For a production build we'd tighten to user-selected directories.

"security": {
  "assetProtocol": {
    "enable": true,
    "scope": ["**"]
  }
}

Without "enable": true, convertFileSrc returns the path unchanged and the <video> element fails to load with a CSP / protocol error.

PlayerSession API · Tauri commands · PlayerControls component · Tauri ↔ Leptos integration (M-INT.2)