feat(anki): add media timing review before card creation

- Add draggable timing previews with audio playback and exact media ranges
- Support cancellation choices, including deleting existing cards
This commit is contained in:
2026-08-30 01:56:16 -07:00
parent 2ad491e95c
commit 256eb5da1b
44 changed files with 2864 additions and 39 deletions
+6
View File
@@ -18,6 +18,7 @@ export function createKeyboardHandlers(
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handleMediaTimingReviewKeydown?: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
@@ -1078,6 +1079,11 @@ export function createKeyboardHandlers(
);
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (ctx.state.mediaTimingReviewModalOpen) {
options.handleMediaTimingReviewKeydown?.(e);
return;
}
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
e.preventDefault();
handleKeyboardModeToggleRequested();
+189
View File
@@ -196,6 +196,195 @@
</div>
</div>
</div>
<div
id="mediaTimingReviewModal"
class="modal media-timing-review-modal hidden"
aria-hidden="true"
>
<div
class="modal-content media-timing-review-content"
role="dialog"
aria-modal="true"
aria-labelledby="mediaTimingReviewTitle"
>
<div class="media-timing-review-header">
<div>
<div id="mediaTimingReviewKind" class="media-timing-review-eyebrow">
Sentence card
</div>
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
Review media timing
</div>
</div>
<button
id="mediaTimingReviewCancel"
class="media-timing-review-quiet-button"
type="button"
>
Cancel
</button>
</div>
<div id="mediaTimingReviewEditor" class="media-timing-review-editor">
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
<div class="media-timing-review-readout" aria-live="polite">
<div>
<span>Starts</span>
<strong id="mediaTimingReviewStartValue">00:00.000</strong>
</div>
<div class="media-timing-review-duration">
<span>Clip length</span>
<strong id="mediaTimingReviewDuration">0.00s</strong>
</div>
<div>
<span>Ends</span>
<strong id="mediaTimingReviewEndValue">00:00.000</strong>
</div>
</div>
<div class="media-timing-review-timeline-shell">
<div class="media-timing-review-timeline-labels">
<span id="mediaTimingReviewTimelineStart">00:00</span>
<span>available media</span>
<span id="mediaTimingReviewTimelineEnd">00:00</span>
</div>
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
<div class="media-timing-review-selected-range"></div>
<input
id="mediaTimingReviewStartRange"
class="media-timing-review-range media-timing-review-range-start"
type="range"
step="0.01"
aria-label="Clip start"
/>
<input
id="mediaTimingReviewEndRange"
class="media-timing-review-range media-timing-review-range-end"
type="range"
step="0.01"
aria-label="Clip end"
/>
</div>
<div class="media-timing-review-expand-row">
<button
id="mediaTimingReviewShowEarlier"
class="media-timing-review-expand-button"
type="button"
>
Show earlier
</button>
<span>Drag either edge to set the exact clip.</span>
<button
id="mediaTimingReviewShowLater"
class="media-timing-review-expand-button"
type="button"
>
Show later
</button>
</div>
</div>
<div class="media-timing-review-fine-grid">
<div class="media-timing-review-fine-control">
<span>Start</span>
<button
id="mediaTimingReviewStartBack"
type="button"
aria-label="Move start backward 100 milliseconds"
>
0.1s
</button>
<button
id="mediaTimingReviewStartForward"
type="button"
aria-label="Move start forward 100 milliseconds"
>
+ 0.1s
</button>
</div>
<div class="media-timing-review-fine-control">
<span>End</span>
<button
id="mediaTimingReviewEndBack"
type="button"
aria-label="Move end backward 100 milliseconds"
>
0.1s
</button>
<button
id="mediaTimingReviewEndForward"
type="button"
aria-label="Move end forward 100 milliseconds"
>
+ 0.1s
</button>
</div>
</div>
<div
id="mediaTimingReviewStatus"
class="media-timing-review-status"
role="status"
aria-live="polite"
></div>
<div class="media-timing-review-footer">
<div class="media-timing-review-preview-actions">
<button
id="mediaTimingReviewPlay"
class="media-timing-review-play-button"
type="button"
>
Play selection
</button>
<button
id="mediaTimingReviewReset"
class="media-timing-review-quiet-button"
type="button"
>
Reset timing
</button>
</div>
<button
id="mediaTimingReviewConfirm"
class="media-timing-review-confirm-button"
type="button"
>
Use this timing
</button>
</div>
</div>
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
<div class="media-timing-review-cancel-mark" aria-hidden="true">?</div>
<h2>Stop reviewing?</h2>
<p id="mediaTimingReviewCancelMessage">Choose what should happen to this card.</p>
<div class="media-timing-review-cancel-actions">
<button
id="mediaTimingReviewCancelBack"
class="media-timing-review-quiet-button"
type="button"
>
Keep editing
</button>
<button
id="mediaTimingReviewUseOriginal"
class="media-timing-review-original-button"
type="button"
>
Use original timing
</button>
<button
id="mediaTimingReviewDiscard"
class="media-timing-review-discard-button"
type="button"
>
Delete card
</button>
</div>
</div>
</div>
</div>
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
constrainMediaTimingSelection,
createMediaTimingPreviewRequestGuard,
formatMediaTimingTimestamp,
} from './media-timing-review';
test('formatMediaTimingTimestamp renders stable review readouts', () => {
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
});
test('selection constraints preserve the handle that did not move', () => {
assert.deepEqual(
constrainMediaTimingSelection({
nextStart: 3,
nextEnd: 2,
currentStart: 1,
timelineStart: 0,
timelineEnd: 10,
mediaEnd: 10,
maxMediaDuration: 30,
}),
{ start: 1.9, end: 2 },
);
assert.deepEqual(
constrainMediaTimingSelection({
nextStart: 1,
nextEnd: 0.5,
currentStart: 1,
timelineStart: 0,
timelineEnd: 10,
mediaEnd: 10,
maxMediaDuration: 30,
}),
{ start: 1, end: 1.1 },
);
});
test('preview request guard blocks overlap and invalidates stale responses', () => {
const guard = createMediaTimingPreviewRequestGuard();
const first = guard.begin();
assert.equal(typeof first, 'number');
assert.equal(guard.begin(), null);
assert.equal(guard.isCurrent(first!), true);
guard.invalidate();
assert.equal(guard.isCurrent(first!), false);
assert.equal(guard.isInFlight(), false);
const second = guard.begin();
assert.equal(typeof second, 'number');
guard.finish(first!);
assert.equal(guard.isCurrent(second!), true);
guard.finish(second!);
assert.equal(guard.isInFlight(), false);
});
+408
View File
@@ -0,0 +1,408 @@
import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload } from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
const MINIMUM_CLIP_SECONDS = 0.1;
const FINE_ADJUST_SECONDS = 0.1;
const TIMELINE_EXPANSION_SECONDS = 5;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds = true): string {
const unitsPerSecond = includeMilliseconds ? 1_000 : 1;
const totalUnits = Math.max(0, Math.round(seconds * unitsPerSecond));
const unitsPerMinute = 60 * unitsPerSecond;
const minutes = Math.floor(totalUnits / unitsPerMinute);
const remainingUnits = totalUnits % unitsPerMinute;
const remaining = includeMilliseconds
? (remainingUnits / unitsPerSecond).toFixed(3)
: String(remainingUnits);
return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
}
export function createMediaTimingPreviewRequestGuard() {
let sequence = 0;
let activeRequestId: number | null = null;
return {
begin(): number | null {
if (activeRequestId !== null) return null;
sequence += 1;
activeRequestId = sequence;
return activeRequestId;
},
invalidate(): void {
sequence += 1;
activeRequestId = null;
},
isCurrent(requestId: number): boolean {
return requestId === activeRequestId;
},
finish(requestId: number): void {
if (activeRequestId === requestId) activeRequestId = null;
},
isInFlight(): boolean {
return activeRequestId !== null;
},
};
}
export function constrainMediaTimingSelection(options: {
nextStart: number;
nextEnd: number;
currentStart: number;
timelineStart: number;
timelineEnd: number;
mediaEnd: number;
maxMediaDuration: number;
}): { start: number; end: number } {
let start = clamp(options.nextStart, options.timelineStart, options.timelineEnd);
let end = clamp(options.nextEnd, options.timelineStart, options.timelineEnd);
const startMoved = options.nextStart !== options.currentStart;
if (end - start < MINIMUM_CLIP_SECONDS) {
if (startMoved) start = end - MINIMUM_CLIP_SECONDS;
else end = start + MINIMUM_CLIP_SECONDS;
}
if (options.maxMediaDuration > 0 && end - start > options.maxMediaDuration) {
if (startMoved) start = end - options.maxMediaDuration;
else end = start + options.maxMediaDuration;
}
return {
start: Math.max(0, start),
end: Math.min(options.mediaEnd, end),
};
}
export function createMediaTimingReviewModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let payload: MediaTimingReviewOpenPayload | null = null;
let selectionStart = 0;
let selectionEnd = 0;
let timelineStart = 0;
let timelineEnd = 0;
let resolveInFlight = false;
let previewPlaying = false;
let previewTimer: ReturnType<typeof setTimeout> | null = null;
const previewRequest = createMediaTimingPreviewRequestGuard();
function setStatus(message: string, isError = false): void {
ctx.dom.mediaTimingReviewStatus.textContent = message;
ctx.dom.mediaTimingReviewStatus.classList.toggle('is-error', isError);
}
function clearPreviewTimer(): void {
if (previewTimer !== null) clearTimeout(previewTimer);
previewTimer = null;
}
function setPreviewPlaying(playing: boolean): void {
previewPlaying = playing;
ctx.dom.mediaTimingReviewPlay.textContent = playing ? 'Stop preview' : 'Play selection';
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
clearPreviewTimer();
if (playing) {
previewTimer = setTimeout(() => stopPreview(), (selectionEnd - selectionStart) * 1000);
}
}
function stopPreview(): void {
previewRequest.invalidate();
setPreviewPlaying(false);
if (payload) {
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
}
}
function renderSelection(): void {
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
const startPercent = ((selectionStart - timelineStart) / span) * 100;
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--selection-start',
`${clamp(startPercent, 0, 100)}%`,
);
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--selection-end',
`${clamp(endPercent, 0, 100)}%`,
);
ctx.dom.mediaTimingReviewStartRange.min = String(timelineStart);
ctx.dom.mediaTimingReviewStartRange.max = String(timelineEnd);
ctx.dom.mediaTimingReviewStartRange.value = String(selectionStart);
ctx.dom.mediaTimingReviewEndRange.min = String(timelineStart);
ctx.dom.mediaTimingReviewEndRange.max = String(timelineEnd);
ctx.dom.mediaTimingReviewEndRange.value = String(selectionEnd);
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
ctx.dom.mediaTimingReviewTimelineStart.textContent = formatMediaTimingTimestamp(
timelineStart,
false,
);
ctx.dom.mediaTimingReviewTimelineEnd.textContent = formatMediaTimingTimestamp(
timelineEnd,
false,
);
ctx.dom.mediaTimingReviewShowEarlier.disabled = timelineStart <= 0;
ctx.dom.mediaTimingReviewShowLater.disabled =
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
}
function updateSelection(nextStart: number, nextEnd: number): void {
if (!payload) return;
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
const nextSelection = constrainMediaTimingSelection({
nextStart,
nextEnd,
currentStart: selectionStart,
timelineStart,
timelineEnd,
mediaEnd,
maxMediaDuration: payload.maxMediaDuration,
});
selectionStart = nextSelection.start;
selectionEnd = nextSelection.end;
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
setStatus('');
renderSelection();
}
function showEditor(): void {
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
ctx.dom.mediaTimingReviewCancel.classList.remove('hidden');
}
function requestCancel(): void {
if (!ctx.state.mediaTimingReviewModalOpen || !payload || resolveInFlight) return;
stopPreview();
ctx.dom.mediaTimingReviewEditor.classList.add('hidden');
ctx.dom.mediaTimingReviewCancel.classList.add('hidden');
ctx.dom.mediaTimingReviewCancelStep.classList.remove('hidden');
ctx.dom.mediaTimingReviewCancelMessage.textContent =
payload.noteId !== undefined
? 'Keep editing, finish this card with its original timing, or delete the card.'
: 'Keep editing, finish this card with its original timing, or do not create it.';
ctx.dom.mediaTimingReviewDiscard.textContent =
payload.noteId !== undefined ? 'Delete card' : "Don't create card";
ctx.dom.mediaTimingReviewCancelBack.focus();
}
function closeResolvedReview(): void {
if (!ctx.state.mediaTimingReviewModalOpen) return;
clearPreviewTimer();
previewPlaying = false;
ctx.state.mediaTimingReviewModalOpen = false;
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('media-timing-review');
options.syncSettingsModalSubtitleSuppression();
payload = null;
if (!options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
}
async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> {
if (!payload || resolveInFlight) return;
resolveInFlight = true;
stopPreview();
const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button');
controls.forEach((button) => {
button.disabled = true;
});
try {
const result = await window.electronAPI.resolveMediaTimingReview({
reviewId: payload.reviewId,
decision,
});
if (!result.ok) {
setStatus(result.message ?? 'The timing review could not be resolved.', true);
showEditor();
return;
}
closeResolvedReview();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
showEditor();
} finally {
resolveInFlight = false;
controls.forEach((button) => {
button.disabled = false;
});
renderSelection();
}
}
async function togglePreview(): Promise<void> {
if (!payload || resolveInFlight) return;
if (previewPlaying) {
stopPreview();
return;
}
const requestId = previewRequest.begin();
if (requestId === null) return;
const requestedReviewId = payload.reviewId;
setStatus('Starting audio preview...');
try {
const result = await window.electronAPI.previewMediaTimingReview({
reviewId: requestedReviewId,
startTime: selectionStart,
endTime: selectionEnd,
});
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) {
if (result.ok && !previewRequest.isInFlight() && !previewPlaying) {
void window.electronAPI.stopMediaTimingReviewPreview(requestedReviewId).catch(() => {});
}
return;
}
if (!result.ok) {
setStatus(result.message ?? 'Audio preview is unavailable.', true);
setPreviewPlaying(false);
return;
}
setStatus('Previewing in the hidden audio player.');
setPreviewPlaying(true);
} catch (error) {
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) return;
setStatus(
`Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
true,
);
setPreviewPlaying(false);
} finally {
previewRequest.finish(requestId);
}
}
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
previewRequest.invalidate();
payload = nextPayload;
selectionStart = nextPayload.selectionStartTime;
selectionEnd = nextPayload.selectionEndTime;
timelineStart = nextPayload.timelineStartTime;
timelineEnd = nextPayload.timelineEndTime;
resolveInFlight = false;
setPreviewPlaying(false);
ctx.dom.mediaTimingReviewKind.textContent =
nextPayload.kind === 'word'
? 'Word card'
: nextPayload.kind === 'audio'
? 'Audio card'
: 'Sentence card';
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
ctx.dom.mediaTimingReviewDiscard.textContent =
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
setStatus('');
showEditor();
renderSelection();
ctx.state.mediaTimingReviewModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) window.electronAPI.setIgnoreMouseEvents(false);
ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
ctx.dom.mediaTimingReviewPlay.focus();
}
function expandTimeline(direction: 'earlier' | 'later'): void {
if (!payload) return;
if (direction === 'earlier') {
timelineStart = Math.max(0, timelineStart - TIMELINE_EXPANSION_SECONDS);
} else {
timelineEnd = Math.min(
payload.mediaDuration ?? Number.POSITIVE_INFINITY,
timelineEnd + TIMELINE_EXPANSION_SECONDS,
);
}
renderSelection();
}
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
if (!ctx.state.mediaTimingReviewModalOpen) return false;
if (event.key === 'Escape') {
event.preventDefault();
if (!ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden')) showEditor();
else requestCancel();
return true;
}
if (
event.code === 'Space' &&
event.target instanceof Element &&
!event.target.closest('button')
) {
event.preventDefault();
void togglePreview();
return true;
}
if (
event.key === 'Enter' &&
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
!(event.target instanceof Element && event.target.closest('button'))
) {
event.preventDefault();
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd });
return true;
}
return false;
}
function wireDomEvents(): void {
ctx.dom.mediaTimingReviewStartRange.addEventListener('input', () => {
updateSelection(Number(ctx.dom.mediaTimingReviewStartRange.value), selectionEnd);
});
ctx.dom.mediaTimingReviewEndRange.addEventListener('input', () => {
updateSelection(selectionStart, Number(ctx.dom.mediaTimingReviewEndRange.value));
});
ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
updateSelection(selectionStart - FINE_ADJUST_SECONDS, selectionEnd),
);
ctx.dom.mediaTimingReviewStartForward.addEventListener('click', () =>
updateSelection(selectionStart + FINE_ADJUST_SECONDS, selectionEnd),
);
ctx.dom.mediaTimingReviewEndBack.addEventListener('click', () =>
updateSelection(selectionStart, selectionEnd - FINE_ADJUST_SECONDS),
);
ctx.dom.mediaTimingReviewEndForward.addEventListener('click', () =>
updateSelection(selectionStart, selectionEnd + FINE_ADJUST_SECONDS),
);
ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
if (!payload) return;
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
});
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
'click',
() => void resolveReview({ action: 'use-original' }),
);
ctx.dom.mediaTimingReviewDiscard.addEventListener(
'click',
() => void resolveReview({ action: 'discard' }),
);
ctx.dom.mediaTimingReviewConfirm.addEventListener(
'click',
() =>
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd }),
);
}
return {
openMediaTimingReviewModal,
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
};
}
+18
View File
@@ -45,6 +45,7 @@ import { createCharacterDictionaryModal } from './modals/character-dictionary.js
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
@@ -114,6 +115,12 @@ const modalDescriptors = [
close: () => youtubePickerModal.closeYoutubePickerModal(),
suppressesSubtitles: true,
},
{
id: 'media-timing-review',
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
close: () => mediaTimingReviewModal.requestCancel(),
suppressesSubtitles: true,
},
{
id: 'playlist-browser',
isOpen: () => ctx.state.playlistBrowserModalOpen,
@@ -265,6 +272,10 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
@@ -273,6 +284,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handleMediaTimingReviewKeydown: mediaTimingReviewModal.handleMediaTimingReviewKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
@@ -574,6 +586,11 @@ function registerModalOpenHandlers(): void {
youtubePickerModal.openYoutubePickerModal(payload);
});
});
window.electronAPI.onOpenMediaTimingReview((payload) => {
runGuarded('media-timing-review:open', () => {
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
});
});
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
@@ -805,6 +822,7 @@ async function init(): Promise<void> {
jimakuModal.wireDomEvents();
tsukihimeModal.wireDomEvents();
youtubePickerModal.wireDomEvents();
mediaTimingReviewModal.wireDomEvents();
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
+4
View File
@@ -64,6 +64,8 @@ export type RendererState = {
youtubePickerSecondaryTrackId: string | null;
youtubePickerStatus: string;
mediaTimingReviewModalOpen: boolean;
kikuModalOpen: boolean;
kikuSelectedCard: 1 | 2;
kikuOriginalData: KikuDuplicateCardInfo | null;
@@ -194,6 +196,8 @@ export function createRendererState(): RendererState {
youtubePickerSecondaryTrackId: null,
youtubePickerStatus: '',
mediaTimingReviewModalOpen: false,
kikuModalOpen: false,
kikuSelectedCard: 1,
kikuOriginalData: null,
+430
View File
@@ -1323,6 +1323,436 @@ body:focus-visible,
}
}
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
.media-timing-review-modal {
background: rgba(24, 25, 38, 0.74);
backdrop-filter: blur(8px);
}
.media-timing-review-content {
width: min(760px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
overflow: auto;
padding: 0;
border: 1px solid var(--ctp-surface1);
border-radius: 18px;
background: var(--ctp-base);
color: var(--ctp-text);
box-shadow:
0 26px 80px rgba(24, 25, 38, 0.74),
0 0 0 1px rgba(183, 189, 248, 0.08) inset;
animation: media-timing-review-enter 180ms cubic-bezier(0.2, 0.9, 0.25, 1);
}
@keyframes media-timing-review-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.media-timing-review-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px 24px 18px;
border-bottom: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
}
.media-timing-review-eyebrow {
margin-bottom: 3px;
color: var(--ctp-teal);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.media-timing-review-title {
color: var(--ctp-text);
font-size: 22px;
font-weight: 750;
letter-spacing: -0.025em;
}
.media-timing-review-editor {
padding: 20px 24px 24px;
}
.media-timing-review-text {
max-height: 108px;
overflow: auto;
margin: 0 0 18px;
padding: 13px 16px;
border-left: 3px solid var(--ctp-mauve);
border-radius: 0 10px 10px 0;
background: var(--ctp-mantle);
color: var(--ctp-subtext1);
font-size: 16px;
font-weight: 560;
line-height: 1.55;
white-space: pre-wrap;
}
.media-timing-review-readout {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
overflow: hidden;
margin-bottom: 14px;
border: 1px solid var(--ctp-surface1);
border-radius: 12px;
background: var(--ctp-mantle);
}
.media-timing-review-readout > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
padding: 11px 14px;
}
.media-timing-review-readout > div + div {
border-left: 1px solid var(--ctp-surface0);
}
.media-timing-review-readout span {
color: var(--ctp-overlay2);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.media-timing-review-readout strong {
color: var(--ctp-lavender);
font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: 720;
}
.media-timing-review-readout .media-timing-review-duration {
align-items: center;
background: var(--ctp-surface0);
text-align: center;
}
.media-timing-review-timeline-shell {
padding: 14px 16px 12px;
border: 1px solid var(--ctp-surface1);
border-radius: 14px;
background: var(--ctp-mantle);
}
.media-timing-review-timeline-labels,
.media-timing-review-expand-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--ctp-overlay1);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.05em;
}
.media-timing-review-timeline-labels span:first-child,
.media-timing-review-timeline-labels span:last-child {
color: var(--ctp-subtext0);
font-variant-numeric: tabular-nums;
}
.media-timing-review-track {
--selection-start: 20%;
--selection-end: 80%;
position: relative;
height: 44px;
margin: 8px 0 9px;
border: 1px solid var(--ctp-surface2);
border-radius: 8px;
background:
repeating-linear-gradient(
90deg,
transparent 0,
transparent calc(10% - 1px),
var(--ctp-surface1) calc(10% - 1px),
var(--ctp-surface1) 10%
),
var(--ctp-surface0);
}
.media-timing-review-selected-range {
position: absolute;
top: 7px;
bottom: 7px;
left: var(--selection-start);
right: calc(100% - var(--selection-end));
border: 1px solid var(--ctp-teal);
border-radius: 6px;
background: var(--ctp-sapphire);
box-shadow: 0 0 18px rgba(125, 196, 228, 0.24);
pointer-events: none;
}
.media-timing-review-range {
position: absolute;
inset: 0;
width: 100%;
height: 44px;
margin: 0;
appearance: none;
background: transparent;
pointer-events: none;
}
.media-timing-review-range::-webkit-slider-runnable-track {
height: 100%;
background: transparent;
}
.media-timing-review-range::-webkit-slider-thumb {
width: 15px;
height: 34px;
margin-top: 4px;
appearance: none;
border: 2px solid var(--ctp-base);
border-radius: 5px;
background: var(--ctp-text);
box-shadow: 0 2px 7px var(--ctp-crust);
cursor: ew-resize;
pointer-events: auto;
}
.media-timing-review-range::-moz-range-track {
height: 100%;
background: transparent;
}
.media-timing-review-range::-moz-range-thumb {
width: 15px;
height: 34px;
border: 2px solid var(--ctp-base);
border-radius: 5px;
background: var(--ctp-text);
box-shadow: 0 2px 7px var(--ctp-crust);
cursor: ew-resize;
pointer-events: auto;
}
.media-timing-review-expand-row span {
text-align: center;
}
.media-timing-review-expand-button,
.media-timing-review-quiet-button,
.media-timing-review-original-button,
.media-timing-review-discard-button,
.media-timing-review-play-button,
.media-timing-review-confirm-button,
.media-timing-review-fine-control button {
border: 1px solid var(--ctp-surface2);
border-radius: 8px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font: inherit;
font-size: 12px;
font-weight: 720;
cursor: pointer;
transition:
background 130ms ease,
border-color 130ms ease,
transform 130ms ease;
}
.media-timing-review-expand-button {
padding: 5px 9px;
color: var(--ctp-sky);
}
.media-timing-review-expand-button:disabled,
.media-timing-review-content button:disabled {
cursor: default;
opacity: 0.42;
}
.media-timing-review-content button:not(:disabled):hover {
border-color: var(--ctp-lavender);
background: var(--ctp-surface1);
}
.media-timing-review-content button:not(:disabled):active {
transform: translateY(1px);
}
.media-timing-review-content button:focus-visible,
.media-timing-review-range:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--ctp-yellow);
outline-offset: 2px;
}
.media-timing-review-fine-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 12px;
}
.media-timing-review-fine-control {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 7px;
padding: 9px 10px;
border: 1px solid var(--ctp-surface0);
border-radius: 10px;
color: var(--ctp-subtext1);
}
.media-timing-review-fine-control span {
font-size: 12px;
font-weight: 750;
}
.media-timing-review-fine-control button {
min-width: 62px;
padding: 5px 8px;
font-variant-numeric: tabular-nums;
}
.media-timing-review-status {
min-height: 21px;
padding-top: 7px;
color: var(--ctp-teal);
font-size: 11px;
font-weight: 620;
}
.media-timing-review-status.is-error {
color: var(--ctp-red);
}
.media-timing-review-footer,
.media-timing-review-preview-actions {
display: flex;
align-items: center;
gap: 9px;
}
.media-timing-review-footer {
justify-content: space-between;
padding-top: 5px;
}
.media-timing-review-play-button,
.media-timing-review-confirm-button,
.media-timing-review-quiet-button,
.media-timing-review-original-button,
.media-timing-review-discard-button {
padding: 9px 14px;
}
.media-timing-review-play-button {
border-color: var(--ctp-teal);
background: var(--ctp-teal);
color: var(--ctp-crust);
}
.media-timing-review-play-button.is-playing {
border-color: var(--ctp-yellow);
background: var(--ctp-yellow);
}
.media-timing-review-confirm-button {
border-color: var(--ctp-blue);
background: var(--ctp-blue);
color: var(--ctp-crust);
}
.media-timing-review-original-button {
border-color: var(--ctp-yellow);
color: var(--ctp-yellow);
}
.media-timing-review-discard-button {
border-color: var(--ctp-red);
background: var(--ctp-red);
color: var(--ctp-crust);
}
.media-timing-review-cancel-step {
min-height: 330px;
padding: 48px 38px 38px;
text-align: center;
}
.media-timing-review-cancel-mark {
display: grid;
width: 54px;
height: 54px;
margin: 0 auto 16px;
place-items: center;
border: 2px solid var(--ctp-yellow);
border-radius: 50%;
color: var(--ctp-yellow);
font-size: 26px;
font-weight: 760;
}
.media-timing-review-cancel-step h2 {
color: var(--ctp-text);
font-size: 22px;
font-weight: 760;
}
.media-timing-review-cancel-step p {
max-width: 520px;
margin: 8px auto 26px;
color: var(--ctp-subtext0);
font-size: 13px;
line-height: 1.5;
}
.media-timing-review-cancel-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
@media (max-width: 640px) {
.media-timing-review-content {
width: calc(100vw - 18px);
max-height: calc(100vh - 18px);
}
.media-timing-review-header,
.media-timing-review-editor {
padding-right: 15px;
padding-left: 15px;
}
.media-timing-review-fine-grid {
grid-template-columns: 1fr;
}
.media-timing-review-footer {
align-items: stretch;
flex-direction: column;
}
.media-timing-review-preview-actions > *,
.media-timing-review-confirm-button {
flex: 1;
}
}
body.subtitle-sidebar-embedded-open #subtitleContainer {
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
+76
View File
@@ -44,6 +44,35 @@ export type RendererDom = {
youtubePickerStatus: HTMLDivElement;
youtubePickerTracks: HTMLUListElement;
mediaTimingReviewModal: HTMLDivElement;
mediaTimingReviewKind: HTMLDivElement;
mediaTimingReviewText: HTMLElement;
mediaTimingReviewStartValue: HTMLElement;
mediaTimingReviewEndValue: HTMLElement;
mediaTimingReviewDuration: HTMLElement;
mediaTimingReviewTimelineStart: HTMLElement;
mediaTimingReviewTimelineEnd: HTMLElement;
mediaTimingReviewSelectionTrack: HTMLDivElement;
mediaTimingReviewStartRange: HTMLInputElement;
mediaTimingReviewEndRange: HTMLInputElement;
mediaTimingReviewShowEarlier: HTMLButtonElement;
mediaTimingReviewShowLater: HTMLButtonElement;
mediaTimingReviewStartBack: HTMLButtonElement;
mediaTimingReviewStartForward: HTMLButtonElement;
mediaTimingReviewEndBack: HTMLButtonElement;
mediaTimingReviewEndForward: HTMLButtonElement;
mediaTimingReviewPlay: HTMLButtonElement;
mediaTimingReviewReset: HTMLButtonElement;
mediaTimingReviewCancel: HTMLButtonElement;
mediaTimingReviewConfirm: HTMLButtonElement;
mediaTimingReviewStatus: HTMLDivElement;
mediaTimingReviewEditor: HTMLDivElement;
mediaTimingReviewCancelStep: HTMLDivElement;
mediaTimingReviewCancelMessage: HTMLParagraphElement;
mediaTimingReviewCancelBack: HTMLButtonElement;
mediaTimingReviewUseOriginal: HTMLButtonElement;
mediaTimingReviewDiscard: HTMLButtonElement;
kikuModal: HTMLDivElement;
kikuCard1: HTMLDivElement;
kikuCard2: HTMLDivElement;
@@ -204,6 +233,53 @@ export function resolveRendererDom(): RendererDom {
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),
mediaTimingReviewTimelineStart: getRequiredElement<HTMLElement>(
'mediaTimingReviewTimelineStart',
),
mediaTimingReviewTimelineEnd: getRequiredElement<HTMLElement>('mediaTimingReviewTimelineEnd'),
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
'mediaTimingReviewSelectionTrack',
),
mediaTimingReviewStartRange: getRequiredElement<HTMLInputElement>(
'mediaTimingReviewStartRange',
),
mediaTimingReviewEndRange: getRequiredElement<HTMLInputElement>('mediaTimingReviewEndRange'),
mediaTimingReviewShowEarlier: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewShowEarlier',
),
mediaTimingReviewShowLater: getRequiredElement<HTMLButtonElement>('mediaTimingReviewShowLater'),
mediaTimingReviewStartBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewStartBack'),
mediaTimingReviewStartForward: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewStartForward',
),
mediaTimingReviewEndBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewEndBack'),
mediaTimingReviewEndForward: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewEndForward',
),
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
mediaTimingReviewStatus: getRequiredElement<HTMLDivElement>('mediaTimingReviewStatus'),
mediaTimingReviewEditor: getRequiredElement<HTMLDivElement>('mediaTimingReviewEditor'),
mediaTimingReviewCancelStep: getRequiredElement<HTMLDivElement>('mediaTimingReviewCancelStep'),
mediaTimingReviewCancelMessage: getRequiredElement<HTMLParagraphElement>(
'mediaTimingReviewCancelMessage',
),
mediaTimingReviewCancelBack: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewCancelBack',
),
mediaTimingReviewUseOriginal: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewUseOriginal',
),
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),