mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user