diff --git a/release/release-notes.md b/release/release-notes.md new file mode 100644 index 00000000..476c69fc --- /dev/null +++ b/release/release-notes.md @@ -0,0 +1,33 @@ +## Highlights +### Fixed + +- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure. +- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names. +- **Overlapping Subtitles**: + - Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek. + - Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue. + - Half-size ASS furigana no longer shows up as if it were its own subtitle line. +- **YouTube Auto-Generated Captions**: + - Captions now follow their intended timing instead of drifting off sync. + - Long speech is paged across two rows instead of piling into a wall of text. + - Timed sound cues like `[音楽]` no longer linger over later dialogue. + +## What's Changed + +- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218 +- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219 +- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221 +- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223 + +## Installation + +See the README and docs/installation guide for full setup steps. + +## Assets + +- Linux: `SubMiner.AppImage` +- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` +- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` +- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher + +Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts index 1eec6406..27207e80 100644 --- a/src/main/runtime/media-timing-review.test.ts +++ b/src/main/runtime/media-timing-review.test.ts @@ -549,14 +549,14 @@ test('media timing review rejects stale and out-of-range actions before allowing assert.deepEqual( await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }), - { ok: false, message: 'This timing review is no longer active.' }, + { ok: false, stale: true, message: 'This timing review is no longer active.' }, ); assert.deepEqual( runtime.resolveReview({ reviewId: 'stale-review', decision: { action: 'confirm', startTime: 10, endTime: 12 }, }), - { ok: false, message: 'This timing review is no longer active.' }, + { ok: false, stale: true, message: 'This timing review is no longer active.' }, ); assert.deepEqual( runtime.resolveReview({ diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index 88bb9cee..d14befa7 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -159,6 +159,15 @@ export function collectMediaTimingContextLines(options: { return { previous, next }; } +/** + * Result for requests that name a review main has already resolved or disposed (decision + * watchdog, overlay teardown, duplicate modal). The renderer closes on it instead of + * leaving the user with controls that can never succeed. + */ +function staleReviewResult(): MediaTimingReviewActionResult { + return { ok: false, stale: true, message: 'This timing review is no longer active.' }; +} + function isValidMediaTimingRange( payload: MediaTimingReviewOpenPayload, startTime: number, @@ -443,7 +452,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep ): Promise { const current = active; if (!current || request.reviewId !== current.payload.reviewId) { - return { ok: false, message: 'This timing review is no longer active.' }; + return staleReviewResult(); } if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) { return { ok: false, message: 'The selected preview range is invalid.' }; @@ -451,7 +460,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep try { const previewSession = await previewFor(current, request); if (active !== current) { - return { ok: false, message: 'This timing review is no longer active.' }; + return staleReviewResult(); } await previewSession.play(request.startTime, request.endTime); return { ok: true }; @@ -468,7 +477,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep ): Promise { const current = active; if (!current || request.reviewId !== current.payload.reviewId) { - return { ok: false, message: 'This timing review is no longer active.' }; + return staleReviewResult(); } if ( !Number.isFinite(request.startTime) || @@ -484,7 +493,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep try { const window = await ensureWindow(current, request); if (active !== current) { - return { ok: false, message: 'This timing review is no longer active.' }; + return staleReviewResult(); } const peaks = await deps.generateWaveform({ mediaPath: window?.media ?? current.waveformMedia, @@ -506,7 +515,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep async function stopPreview(reviewId: string): Promise { const current = active; if (!current || reviewId !== current.payload.reviewId) { - return { ok: false, message: 'This timing review is no longer active.' }; + return staleReviewResult(); } try { const previewSession = current.preview ? await current.preview.session : null; @@ -523,7 +532,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult { const current = active; if (!current || request.reviewId !== current.payload.reviewId) { - return { ok: false, message: 'This timing review is no longer active.' }; + return staleReviewResult(); } if (request.decision.action === 'confirm') { const { startTime, endTime, text } = request.decision; diff --git a/src/renderer/modals/media-timing-review.ts b/src/renderer/modals/media-timing-review.ts index 189013e8..5d9ed85d 100644 --- a/src/renderer/modals/media-timing-review.ts +++ b/src/renderer/modals/media-timing-review.ts @@ -387,6 +387,10 @@ export function createMediaTimingReviewModal( ) { return; } + if (!result.ok && result.stale) { + closeResolvedReview(); + return; + } const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : ''; if (!path) { setWaveformState('unavailable'); @@ -707,11 +711,13 @@ export function createMediaTimingReviewModal( reviewId: payload.reviewId, decision, }); - if (!result.ok) { + if (!result.ok && !result.stale) { setStatus(result.message ?? 'The timing review could not be resolved.', true); showEditor(); return; } + // A stale review was already settled by main; keeping the modal up would leave + // controls that can never succeed over a live mpv window. closeResolvedReview(); } catch (error) { setStatus(error instanceof Error ? error.message : String(error), true); @@ -759,6 +765,10 @@ export function createMediaTimingReviewModal( } return; } + if (!result.ok && result.stale) { + closeResolvedReview(); + return; + } if (!result.ok) { setStatus(result.message ?? 'Audio preview is unavailable.', true); setPreviewPlaying(false); diff --git a/src/types/anki.ts b/src/types/anki.ts index 65c5f2a5..3329e305 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -79,6 +79,8 @@ export interface MediaTimingReviewResolveRequest { export interface MediaTimingReviewActionResult { ok: boolean; message?: string; + /** The review this request targeted has already ended; the renderer should close. */ + stale?: boolean; } export interface NotificationOptions {