diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 21fb1f1..bc172e0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -264,9 +264,21 @@ const mediaLane = new LatestLane( (request) => previewRevision.accepts(request.revision), (request, error) => { if (!previewRevision.accepts(request.revision)) return; - element("media-preview-status").hidden = false; - element("media-preview-status").textContent = - `Preview unavailable: ${String(error)}. Use Open file.`; + const status = element("media-preview-status"); + status.hidden = false; + status.textContent = `Preview unavailable: ${String(error)}. Use Open file.`; + const retry = document.createElement("button"); + retry.type = "button"; + retry.textContent = "Retry preview"; + retry.addEventListener("click", () => { + const revision = previewRevision.retry(request.revision); + if (revision === undefined) return; + unloadMedia(); + status.hidden = false; + status.textContent = "Preparing media preview…"; + mediaLane.submit({ revision, result: request.result }); + }); + status.append(" ", retry); }, ); let displayedRequestId: number | null = null; diff --git a/apps/desktop/src/media-display.ts b/apps/desktop/src/media-display.ts index 85e5ca3..e7b3b62 100644 --- a/apps/desktop/src/media-display.ts +++ b/apps/desktop/src/media-display.ts @@ -98,6 +98,10 @@ export class PreviewRevision { accepts(revision: number) { return revision === this.revision && this.key !== ""; } + retry(revision: number): number | undefined { + if (!this.accepts(revision)) return undefined; + return ++this.revision; + } clear() { this.key = ""; this.revision++; diff --git a/apps/desktop/tests/media-display.test.mjs b/apps/desktop/tests/media-display.test.mjs index 260716f..e1bafd9 100644 --- a/apps/desktop/tests/media-display.test.mjs +++ b/apps/desktop/tests/media-display.test.mjs @@ -11,6 +11,18 @@ import { import { LatestLane } from "../src/search-state.ts"; const settle = () => new Promise((resolve) => setImmediate(resolve)); +test("explicit preview retry invalidates old work and cannot retry a stale selection", () => { + const state = new PreviewRevision(); + const first = state.select("first"); + const retried = state.retry(first.revision); + assert.equal(typeof retried, "number"); + assert.equal(state.accepts(first.revision), false); + assert.equal(state.accepts(retried), true); + assert.equal(state.retry(first.revision), undefined); + state.select("second"); + assert.equal(state.retry(retried), undefined); +}); + test("indexed content refresh invalidates a decoded preview at the same path", () => { const revision = new PreviewRevision(); const source = { kind: "timestamp_ms", number: 1200 }; diff --git a/docs/MEDIA-PREVIEW.md b/docs/MEDIA-PREVIEW.md index faedfd9..f9e410f 100644 --- a/docs/MEDIA-PREVIEW.md +++ b/docs/MEDIA-PREVIEW.md @@ -19,6 +19,10 @@ Arbitrary paths, remote URLs, query strings and traversal are not preview grants The generic Tauri asset protocol is disabled in the new configuration. Index-change notifications also advance the preview selection identity, so a same-path image/recording reloads rather than retaining previously decoded media. +Failed native preview preparation now offers an explicit **Retry preview** +button. It invalidates the old request revision and cannot restart a selection +that has since changed. There is no automatic retry loop. The added regression +passed with all 60 frontend tests, formatting and the production frontend build. This does not hash every recording on every request. A same-size/mtime in-place write may remain undetected until a filesystem hint or byte-verifying rescan