mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
feat(anki): add waveform-guided timing controls
- Add speech-weighted waveform analysis and playback playhead - Support dragging, sliding, and keyboard nudging for clip timing
This commit is contained in:
+60
-26
@@ -208,13 +208,11 @@
|
||||
aria-labelledby="mediaTimingReviewTitle"
|
||||
>
|
||||
<div class="media-timing-review-header">
|
||||
<div>
|
||||
<div id="mediaTimingReviewKind" class="media-timing-review-eyebrow">
|
||||
Sentence card
|
||||
</div>
|
||||
<div class="media-timing-review-heading">
|
||||
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
|
||||
Review media timing
|
||||
</div>
|
||||
<div id="mediaTimingReviewKind" class="media-timing-review-kind">Sentence card</div>
|
||||
</div>
|
||||
<button
|
||||
id="mediaTimingReviewCancel"
|
||||
@@ -246,41 +244,70 @@
|
||||
<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="mediaTimingReviewWaveformLabel">speech-weighted waveform</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"
|
||||
<svg
|
||||
class="media-timing-review-waveform"
|
||||
viewBox="0 0 1000 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="mediaTimingReviewWaveformFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="media-timing-review-waveform-edge-stop" offset="0%" />
|
||||
<stop class="media-timing-review-waveform-core-stop" offset="50%" />
|
||||
<stop class="media-timing-review-waveform-edge-stop" offset="100%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path id="mediaTimingReviewWaveformPath"></path>
|
||||
</svg>
|
||||
<div
|
||||
class="media-timing-review-original-range"
|
||||
title="Original subtitle timing"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div
|
||||
id="mediaTimingReviewSelectedRange"
|
||||
class="media-timing-review-selected-range"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="media-timing-review-playhead" aria-hidden="true"></div>
|
||||
<div
|
||||
id="mediaTimingReviewStartHandle"
|
||||
class="media-timing-review-handle media-timing-review-handle-start"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="Clip start"
|
||||
/>
|
||||
<input
|
||||
id="mediaTimingReviewEndRange"
|
||||
class="media-timing-review-range media-timing-review-range-end"
|
||||
type="range"
|
||||
step="0.01"
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
<div
|
||||
id="mediaTimingReviewEndHandle"
|
||||
class="media-timing-review-handle media-timing-review-handle-end"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="Clip end"
|
||||
/>
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
</div>
|
||||
<div class="media-timing-review-expand-row">
|
||||
<button
|
||||
id="mediaTimingReviewShowEarlier"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
aria-label="Reveal five more seconds before the timeline"
|
||||
>
|
||||
Show earlier
|
||||
−5s
|
||||
</button>
|
||||
<span>Drag either edge to set the exact clip.</span>
|
||||
<span>Drag an edge to trim, or drag the middle to slide the clip.</span>
|
||||
<button
|
||||
id="mediaTimingReviewShowLater"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
aria-label="Reveal five more seconds after the timeline"
|
||||
>
|
||||
Show later
|
||||
+5s
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,14 +320,14 @@
|
||||
type="button"
|
||||
aria-label="Move start backward 100 milliseconds"
|
||||
>
|
||||
− 0.1s
|
||||
−0.1s
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewStartForward"
|
||||
type="button"
|
||||
aria-label="Move start forward 100 milliseconds"
|
||||
>
|
||||
+ 0.1s
|
||||
+0.1s
|
||||
</button>
|
||||
</div>
|
||||
<div class="media-timing-review-fine-control">
|
||||
@@ -310,14 +337,14 @@
|
||||
type="button"
|
||||
aria-label="Move end backward 100 milliseconds"
|
||||
>
|
||||
− 0.1s
|
||||
−0.1s
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewEndForward"
|
||||
type="button"
|
||||
aria-label="Move end forward 100 milliseconds"
|
||||
>
|
||||
+ 0.1s
|
||||
+0.1s
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,7 +362,8 @@
|
||||
class="media-timing-review-play-button"
|
||||
type="button"
|
||||
>
|
||||
Play selection
|
||||
<span class="media-timing-review-play-glyph" aria-hidden="true"></span>
|
||||
<span id="mediaTimingReviewPlayLabel">Play selection</span>
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewReset"
|
||||
@@ -353,6 +381,12 @@
|
||||
Use this timing
|
||||
</button>
|
||||
</div>
|
||||
<div class="media-timing-review-hints" aria-hidden="true">
|
||||
<span><kbd>Space</kbd> preview</span>
|
||||
<span><kbd>←</kbd><kbd>→</kbd> nudge focused edge</span>
|
||||
<span><kbd>Enter</kbd> confirm</span>
|
||||
<span><kbd>Esc</kbd> cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildMediaTimingWaveformPath,
|
||||
constrainMediaTimingSelection,
|
||||
createMediaTimingPreviewRequestGuard,
|
||||
formatMediaTimingTimestamp,
|
||||
mediaTimingTimeFromPointer,
|
||||
slideMediaTimingSelection,
|
||||
} from './media-timing-review';
|
||||
|
||||
test('waveform path mirrors normalized peaks around its center line', () => {
|
||||
const path = buildMediaTimingWaveformPath([0, 0.5, 1]);
|
||||
|
||||
assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
|
||||
assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
|
||||
assert.equal(buildMediaTimingWaveformPath([1]), '');
|
||||
});
|
||||
|
||||
test('formatMediaTimingTimestamp renders stable review readouts', () => {
|
||||
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
||||
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
|
||||
@@ -41,6 +52,27 @@ test('selection constraints preserve the handle that did not move', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('pointer positions map onto the visible timeline and clamp past its edges', () => {
|
||||
const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };
|
||||
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
|
||||
assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
|
||||
});
|
||||
|
||||
test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
|
||||
const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };
|
||||
|
||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
|
||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
|
||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
|
||||
start: 8,
|
||||
end: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
||||
const guard = createMediaTimingPreviewRequestGuard();
|
||||
const first = guard.begin();
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
const MINIMUM_CLIP_SECONDS = 0.1;
|
||||
const FINE_ADJUST_SECONDS = 0.1;
|
||||
const COARSE_ADJUST_SECONDS = 0.5;
|
||||
const TIMELINE_EXPANSION_SECONDS = 5;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
@@ -48,6 +49,19 @@ export function createMediaTimingPreviewRequestGuard() {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMediaTimingWaveformPath(peaks: number[]): string {
|
||||
if (peaks.length < 2) return '';
|
||||
const points = peaks.map((peak, index) => ({
|
||||
x: (index / (peaks.length - 1)) * 1_000,
|
||||
amplitude: clamp(Number.isFinite(peak) ? peak : 0, 0, 1) * 44,
|
||||
}));
|
||||
const upper = points.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 - amplitude).toFixed(2)}`);
|
||||
const lower = [...points]
|
||||
.reverse()
|
||||
.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 + amplitude).toFixed(2)}`);
|
||||
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
|
||||
}
|
||||
|
||||
export function constrainMediaTimingSelection(options: {
|
||||
nextStart: number;
|
||||
nextEnd: number;
|
||||
@@ -74,6 +88,33 @@ export function constrainMediaTimingSelection(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps a pointer position over the timeline track back onto a media timestamp. */
|
||||
export function mediaTimingTimeFromPointer(options: {
|
||||
clientX: number;
|
||||
trackLeft: number;
|
||||
trackWidth: number;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
}): number {
|
||||
if (options.trackWidth <= 0) return options.timelineStart;
|
||||
const ratio = clamp((options.clientX - options.trackLeft) / options.trackWidth, 0, 1);
|
||||
return options.timelineStart + ratio * (options.timelineEnd - options.timelineStart);
|
||||
}
|
||||
|
||||
/** Slides the selection without resizing it, keeping the whole clip inside the visible timeline. */
|
||||
export function slideMediaTimingSelection(options: {
|
||||
nextStart: number;
|
||||
span: number;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
mediaEnd: number;
|
||||
}): { start: number; end: number } {
|
||||
const latestEnd = Math.min(options.timelineEnd, options.mediaEnd);
|
||||
const maxStart = Math.max(options.timelineStart, latestEnd - options.span);
|
||||
const start = clamp(options.nextStart, options.timelineStart, maxStart);
|
||||
return { start, end: start + options.span };
|
||||
}
|
||||
|
||||
export function createMediaTimingReviewModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -89,6 +130,15 @@ export function createMediaTimingReviewModal(
|
||||
let resolveInFlight = false;
|
||||
let previewPlaying = false;
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waveformTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waveformSequence = 0;
|
||||
let drag: {
|
||||
edge: 'start' | 'end' | 'both';
|
||||
pointerId: number;
|
||||
trackLeft: number;
|
||||
trackWidth: number;
|
||||
grabOffset: number;
|
||||
} | null = null;
|
||||
const previewRequest = createMediaTimingPreviewRequestGuard();
|
||||
|
||||
function setStatus(message: string, isError = false): void {
|
||||
@@ -101,28 +151,49 @@ export function createMediaTimingReviewModal(
|
||||
previewTimer = null;
|
||||
}
|
||||
|
||||
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
|
||||
function setPreviewPlaying(playing: boolean): void {
|
||||
previewPlaying = playing;
|
||||
ctx.dom.mediaTimingReviewPlay.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
||||
clearPreviewTimer();
|
||||
if (playing) {
|
||||
previewTimer = setTimeout(() => stopPreview(), (selectionEnd - selectionStart) * 1000);
|
||||
}
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
track.classList.remove('is-previewing');
|
||||
if (!playing) return;
|
||||
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
|
||||
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
|
||||
void track.offsetWidth;
|
||||
track.classList.add('is-previewing');
|
||||
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
|
||||
}
|
||||
|
||||
/** Callers that need to report a failure set their own status after stopping the preview. */
|
||||
function stopPreview(): void {
|
||||
previewRequest.invalidate();
|
||||
setPreviewPlaying(false);
|
||||
setStatus('');
|
||||
if (payload) {
|
||||
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderHandle(handle: HTMLDivElement, value: number): void {
|
||||
handle.setAttribute('aria-valuemin', timelineStart.toFixed(3));
|
||||
handle.setAttribute('aria-valuemax', timelineEnd.toFixed(3));
|
||||
handle.setAttribute('aria-valuenow', value.toFixed(3));
|
||||
handle.setAttribute('aria-valuetext', formatMediaTimingTimestamp(value));
|
||||
}
|
||||
|
||||
function renderSelection(): void {
|
||||
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
||||
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
||||
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
|
||||
const originalStartPercent = payload
|
||||
? ((payload.originalStartTime - timelineStart) / span) * 100
|
||||
: 0;
|
||||
const originalEndPercent = payload
|
||||
? ((payload.originalEndTime - timelineStart) / span) * 100
|
||||
: 0;
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--selection-start',
|
||||
`${clamp(startPercent, 0, 100)}%`,
|
||||
@@ -131,12 +202,16 @@ export function createMediaTimingReviewModal(
|
||||
'--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.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--original-start',
|
||||
`${clamp(originalStartPercent, 0, 100)}%`,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--original-end',
|
||||
`${clamp(originalEndPercent, 0, 100)}%`,
|
||||
);
|
||||
renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
|
||||
renderHandle(ctx.dom.mediaTimingReviewEndHandle, selectionEnd);
|
||||
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
|
||||
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
|
||||
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
|
||||
@@ -153,6 +228,70 @@ export function createMediaTimingReviewModal(
|
||||
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
|
||||
}
|
||||
|
||||
function setWaveformState(state: 'loading' | 'ready' | 'unavailable'): void {
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle('is-loading', state === 'loading');
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle(
|
||||
'is-waveform-unavailable',
|
||||
state === 'unavailable',
|
||||
);
|
||||
ctx.dom.mediaTimingReviewWaveformLabel.textContent =
|
||||
state === 'loading'
|
||||
? 'isolating dialogue...'
|
||||
: state === 'ready'
|
||||
? 'speech-weighted waveform'
|
||||
: 'waveform unavailable';
|
||||
}
|
||||
|
||||
async function loadWaveform(): Promise<void> {
|
||||
if (!payload || !ctx.state.mediaTimingReviewModalOpen) return;
|
||||
waveformSequence += 1;
|
||||
const sequence = waveformSequence;
|
||||
const reviewId = payload.reviewId;
|
||||
const startTime = timelineStart;
|
||||
const endTime = timelineEnd;
|
||||
setWaveformState('loading');
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
||||
try {
|
||||
const result = await window.electronAPI.getMediaTimingReviewWaveform({
|
||||
reviewId,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
if (
|
||||
sequence !== waveformSequence ||
|
||||
payload?.reviewId !== reviewId ||
|
||||
timelineStart !== startTime ||
|
||||
timelineEnd !== endTime
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
|
||||
if (!path) {
|
||||
setWaveformState('unavailable');
|
||||
return;
|
||||
}
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
|
||||
setWaveformState('ready');
|
||||
} catch {
|
||||
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
|
||||
setWaveformState('unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueWaveformLoad(delayMs = 0): void {
|
||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||
waveformSequence += 1;
|
||||
if (payload && ctx.state.mediaTimingReviewModalOpen) {
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
||||
setWaveformState('loading');
|
||||
}
|
||||
waveformTimer = setTimeout(() => {
|
||||
waveformTimer = null;
|
||||
void loadWaveform();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function updateSelection(nextStart: number, nextEnd: number): void {
|
||||
if (!payload) return;
|
||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||
@@ -172,6 +311,116 @@ export function createMediaTimingReviewModal(
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
/** Shifts the whole clip without changing its length. */
|
||||
function moveSelection(nextStart: number): void {
|
||||
if (!payload) return;
|
||||
const slid = slideMediaTimingSelection({
|
||||
nextStart,
|
||||
span: selectionEnd - selectionStart,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
mediaEnd: payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
||||
});
|
||||
updateSelection(slid.start, slid.end);
|
||||
}
|
||||
|
||||
function setEdge(edge: 'start' | 'end', value: number): void {
|
||||
if (edge === 'start') updateSelection(value, selectionEnd);
|
||||
else updateSelection(selectionStart, value);
|
||||
}
|
||||
|
||||
function applyDrag(clientX: number): void {
|
||||
if (!drag) return;
|
||||
const pointerTime = mediaTimingTimeFromPointer({
|
||||
clientX,
|
||||
trackLeft: drag.trackLeft,
|
||||
trackWidth: drag.trackWidth,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
});
|
||||
const time = pointerTime - drag.grabOffset;
|
||||
if (drag.edge === 'both') moveSelection(time);
|
||||
else setEdge(drag.edge, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabbing a handle drags that edge, grabbing the highlighted clip slides the whole selection,
|
||||
* and pressing anywhere else snaps the nearest edge to that point and keeps dragging it.
|
||||
*/
|
||||
function beginDrag(event: PointerEvent): void {
|
||||
if (!payload || resolveInFlight || drag !== null || event.button !== 0) return;
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pointerTime = mediaTimingTimeFromPointer({
|
||||
clientX: event.clientX,
|
||||
trackLeft: rect.left,
|
||||
trackWidth: rect.width,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
});
|
||||
const target = event.target;
|
||||
let edge: 'start' | 'end' | 'both';
|
||||
let grabOffset: number;
|
||||
if (target === ctx.dom.mediaTimingReviewStartHandle) {
|
||||
edge = 'start';
|
||||
grabOffset = pointerTime - selectionStart;
|
||||
} else if (target === ctx.dom.mediaTimingReviewEndHandle) {
|
||||
edge = 'end';
|
||||
grabOffset = pointerTime - selectionEnd;
|
||||
} else if (target === ctx.dom.mediaTimingReviewSelectedRange) {
|
||||
edge = 'both';
|
||||
grabOffset = pointerTime - selectionStart;
|
||||
} else {
|
||||
edge =
|
||||
Math.abs(pointerTime - selectionStart) <= Math.abs(pointerTime - selectionEnd)
|
||||
? 'start'
|
||||
: 'end';
|
||||
grabOffset = 0;
|
||||
}
|
||||
event.preventDefault();
|
||||
drag = {
|
||||
edge,
|
||||
pointerId: event.pointerId,
|
||||
trackLeft: rect.left,
|
||||
trackWidth: rect.width,
|
||||
grabOffset,
|
||||
};
|
||||
track.setPointerCapture(event.pointerId);
|
||||
ctx.dom.mediaTimingReviewModal.classList.add(edge === 'both' ? 'is-sliding' : 'is-scrubbing');
|
||||
if (edge !== 'both') {
|
||||
const handle =
|
||||
edge === 'start'
|
||||
? ctx.dom.mediaTimingReviewStartHandle
|
||||
: ctx.dom.mediaTimingReviewEndHandle;
|
||||
handle.focus();
|
||||
if (grabOffset === 0) applyDrag(event.clientX);
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent): void {
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
|
||||
drag = null;
|
||||
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
drag = null;
|
||||
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
||||
}
|
||||
|
||||
function handleEdgeKeydown(event: KeyboardEvent, edge: 'start' | 'end'): void {
|
||||
const step = event.shiftKey ? COARSE_ADJUST_SECONDS : FINE_ADJUST_SECONDS;
|
||||
const current = edge === 'start' ? selectionStart : selectionEnd;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') setEdge(edge, current - step);
|
||||
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') setEdge(edge, current + step);
|
||||
else if (event.key === 'Home') setEdge(edge, timelineStart);
|
||||
else if (event.key === 'End') setEdge(edge, timelineEnd);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function showEditor(): void {
|
||||
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
|
||||
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
|
||||
@@ -195,7 +444,11 @@ export function createMediaTimingReviewModal(
|
||||
|
||||
function closeResolvedReview(): void {
|
||||
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
||||
cancelDrag();
|
||||
clearPreviewTimer();
|
||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||
waveformTimer = null;
|
||||
waveformSequence += 1;
|
||||
previewPlaying = false;
|
||||
ctx.state.mediaTimingReviewModalOpen = false;
|
||||
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
||||
@@ -285,6 +538,7 @@ export function createMediaTimingReviewModal(
|
||||
|
||||
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
||||
previewRequest.invalidate();
|
||||
cancelDrag();
|
||||
payload = nextPayload;
|
||||
selectionStart = nextPayload.selectionStartTime;
|
||||
selectionEnd = nextPayload.selectionEndTime;
|
||||
@@ -298,6 +552,7 @@ export function createMediaTimingReviewModal(
|
||||
: nextPayload.kind === 'audio'
|
||||
? 'Audio card'
|
||||
: 'Sentence card';
|
||||
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
||||
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||
@@ -311,7 +566,8 @@ export function createMediaTimingReviewModal(
|
||||
ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
|
||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
|
||||
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
|
||||
ctx.dom.mediaTimingReviewPlay.focus();
|
||||
ctx.dom.mediaTimingReviewStartHandle.focus();
|
||||
queueWaveformLoad();
|
||||
}
|
||||
|
||||
function expandTimeline(direction: 'earlier' | 'later'): void {
|
||||
@@ -325,6 +581,7 @@ export function createMediaTimingReviewModal(
|
||||
);
|
||||
}
|
||||
renderSelection();
|
||||
queueWaveformLoad(120);
|
||||
}
|
||||
|
||||
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
|
||||
@@ -357,12 +614,19 @@ export function createMediaTimingReviewModal(
|
||||
}
|
||||
|
||||
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));
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
track.addEventListener('pointerdown', beginDrag);
|
||||
track.addEventListener('pointermove', (event) => {
|
||||
if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
|
||||
});
|
||||
track.addEventListener('pointerup', endDrag);
|
||||
track.addEventListener('pointercancel', endDrag);
|
||||
ctx.dom.mediaTimingReviewStartHandle.addEventListener('keydown', (event) =>
|
||||
handleEdgeKeydown(event, 'start'),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewEndHandle.addEventListener('keydown', (event) =>
|
||||
handleEdgeKeydown(event, 'end'),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
|
||||
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
|
||||
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
|
||||
|
||||
+330
-90
@@ -1325,22 +1325,34 @@ 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);
|
||||
background: rgba(24, 25, 38, 0.76);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Pointer capture keeps events on the track, so the cursor is forced from the modal root. */
|
||||
.media-timing-review-modal.is-scrubbing,
|
||||
.media-timing-review-modal.is-scrubbing * {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.media-timing-review-modal.is-sliding,
|
||||
.media-timing-review-modal.is-sliding * {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.media-timing-review-content {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
max-height: min(780px, calc(100vh - 32px));
|
||||
overflow: auto;
|
||||
gap: 0;
|
||||
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;
|
||||
0 30px 90px rgba(24, 25, 38, 0.8),
|
||||
0 0 0 1px rgba(183, 189, 248, 0.07) inset;
|
||||
animation: media-timing-review-enter 180ms cubic-bezier(0.2, 0.9, 0.25, 1);
|
||||
}
|
||||
|
||||
@@ -1356,40 +1368,66 @@ body:focus-visible,
|
||||
}
|
||||
|
||||
.media-timing-review-header {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px 24px 18px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
background:
|
||||
linear-gradient(90deg, var(--ctp-teal), var(--ctp-blue) 45%, var(--ctp-mauve)) top / 100% 2px
|
||||
no-repeat,
|
||||
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-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.media-timing-review-title {
|
||||
color: var(--ctp-text);
|
||||
font-size: 22px;
|
||||
font-size: 21px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.media-timing-review-kind {
|
||||
--kind-accent: var(--ctp-teal);
|
||||
|
||||
padding: 3px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--kind-accent) 42%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--kind-accent) 13%, transparent);
|
||||
color: var(--kind-accent);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-kind[data-kind='word'] {
|
||||
--kind-accent: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.media-timing-review-kind[data-kind='audio'] {
|
||||
--kind-accent: var(--ctp-sky);
|
||||
}
|
||||
|
||||
.media-timing-review-editor {
|
||||
padding: 20px 24px 24px;
|
||||
padding: 18px 24px 22px;
|
||||
}
|
||||
|
||||
.media-timing-review-text {
|
||||
max-height: 108px;
|
||||
overflow: auto;
|
||||
margin: 0 0 18px;
|
||||
padding: 13px 16px;
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid var(--ctp-mauve);
|
||||
border-radius: 0 10px 10px 0;
|
||||
background: var(--ctp-mantle);
|
||||
@@ -1402,9 +1440,9 @@ body:focus-visible,
|
||||
|
||||
.media-timing-review-readout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 12px;
|
||||
background: var(--ctp-mantle);
|
||||
@@ -1414,8 +1452,8 @@ body:focus-visible,
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 11px 14px;
|
||||
gap: 3px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.media-timing-review-readout > div + div {
|
||||
@@ -1423,10 +1461,10 @@ body:focus-visible,
|
||||
}
|
||||
|
||||
.media-timing-review-readout span {
|
||||
color: var(--ctp-overlay2);
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1439,12 +1477,22 @@ body:focus-visible,
|
||||
|
||||
.media-timing-review-readout .media-timing-review-duration {
|
||||
align-items: center;
|
||||
min-width: 128px;
|
||||
background: var(--ctp-surface0);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-timing-review-readout > div:last-child {
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.media-timing-review-readout .media-timing-review-duration strong {
|
||||
color: var(--ctp-teal);
|
||||
}
|
||||
|
||||
.media-timing-review-timeline-shell {
|
||||
padding: 14px 16px 12px;
|
||||
padding: 12px 14px 10px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 14px;
|
||||
background: var(--ctp-mantle);
|
||||
@@ -1471,79 +1519,202 @@ body:focus-visible,
|
||||
.media-timing-review-track {
|
||||
--selection-start: 20%;
|
||||
--selection-end: 80%;
|
||||
--original-start: 25%;
|
||||
--original-end: 75%;
|
||||
--playhead-duration: 1s;
|
||||
|
||||
position: relative;
|
||||
height: 44px;
|
||||
height: 96px;
|
||||
margin: 8px 0 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(var(--ctp-surface1), var(--ctp-surface1)) center / 100% 1px no-repeat,
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
transparent calc(10% - 1px),
|
||||
var(--ctp-surface1) calc(10% - 1px),
|
||||
var(--ctp-surface1) 10%
|
||||
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) calc(10% - 1px),
|
||||
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) 10%
|
||||
),
|
||||
var(--ctp-surface0);
|
||||
linear-gradient(180deg, var(--ctp-crust), var(--ctp-mantle));
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.media-timing-review-waveform {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 6px 0;
|
||||
width: 100%;
|
||||
height: calc(100% - 12px);
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
#mediaTimingReviewWaveformPath {
|
||||
fill: url('#mediaTimingReviewWaveformFill');
|
||||
}
|
||||
|
||||
.media-timing-review-waveform-edge-stop {
|
||||
stop-color: var(--ctp-sky);
|
||||
}
|
||||
|
||||
.media-timing-review-waveform-core-stop {
|
||||
stop-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
/* Sits above the dimming scrim so the subtitle bounds stay readable outside the selection. */
|
||||
.media-timing-review-original-range {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
left: var(--original-start);
|
||||
right: calc(100% - var(--original-end));
|
||||
min-width: 1px;
|
||||
border-inline: 1px dashed var(--ctp-peach);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-timing-review-original-range::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 0 0 5px;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 72%, transparent);
|
||||
content: 'subtitle';
|
||||
color: var(--ctp-peach);
|
||||
font-size: 8px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-track.is-loading::after {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 20%,
|
||||
rgba(138, 173, 244, 0.16) 44%,
|
||||
rgba(183, 189, 248, 0.22) 50%,
|
||||
transparent 76%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: media-timing-waveform-loading 1.1s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-timing-review-track.is-waveform-unavailable .media-timing-review-waveform {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes media-timing-waveform-loading {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* The oversized shadow spread dims everything outside the clip; the track clips it. */
|
||||
.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;
|
||||
z-index: 3;
|
||||
inset: 0 calc(100% - var(--selection-end)) 0 var(--selection-start);
|
||||
border-inline: 1px solid color-mix(in srgb, var(--ctp-teal) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--ctp-teal) 9%, transparent);
|
||||
box-shadow: 0 0 0 2000px color-mix(in srgb, var(--ctp-crust) 62%, transparent);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.media-timing-review-range {
|
||||
.media-timing-review-playhead {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--selection-start);
|
||||
width: 2px;
|
||||
background: var(--ctp-yellow);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-timing-review-range::-webkit-slider-runnable-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||
opacity: 1;
|
||||
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
|
||||
}
|
||||
|
||||
.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);
|
||||
@keyframes media-timing-review-playhead {
|
||||
from {
|
||||
left: var(--selection-start);
|
||||
}
|
||||
to {
|
||||
left: var(--selection-end);
|
||||
}
|
||||
}
|
||||
|
||||
.media-timing-review-handle {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
border: 1px solid var(--ctp-teal);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--ctp-teal),
|
||||
color-mix(in srgb, var(--ctp-teal) 74%, var(--ctp-crust))
|
||||
);
|
||||
cursor: ew-resize;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
transition: box-shadow 130ms ease;
|
||||
}
|
||||
|
||||
.media-timing-review-range::-moz-range-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
/* Widens the grab area past the visible bracket without moving the clip edge. */
|
||||
.media-timing-review-handle::before {
|
||||
position: absolute;
|
||||
inset: 0 -7px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.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-handle::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 18px;
|
||||
content: '';
|
||||
border-inline: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.media-timing-review-handle-start {
|
||||
left: var(--selection-start);
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.media-timing-review-handle-end {
|
||||
left: var(--selection-end);
|
||||
border-radius: 0 5px 5px 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.media-timing-review-handle:hover {
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--ctp-teal) 60%, transparent);
|
||||
}
|
||||
|
||||
.media-timing-review-handle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--ctp-yellow),
|
||||
0 0 16px color-mix(in srgb, var(--ctp-yellow) 55%, transparent);
|
||||
}
|
||||
|
||||
.media-timing-review-expand-row span {
|
||||
@@ -1558,7 +1729,7 @@ body:focus-visible,
|
||||
.media-timing-review-confirm-button,
|
||||
.media-timing-review-fine-control button {
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 8px;
|
||||
border-radius: 9px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
@@ -1572,8 +1743,9 @@ body:focus-visible,
|
||||
}
|
||||
|
||||
.media-timing-review-expand-button {
|
||||
padding: 5px 9px;
|
||||
padding: 5px 10px;
|
||||
color: var(--ctp-sky);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-timing-review-expand-button:disabled,
|
||||
@@ -1591,8 +1763,7 @@ body:focus-visible,
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.media-timing-review-content button:focus-visible,
|
||||
.media-timing-review-range:focus-visible::-webkit-slider-thumb {
|
||||
.media-timing-review-content button:focus-visible {
|
||||
outline: 2px solid var(--ctp-yellow);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -1601,34 +1772,37 @@ body:focus-visible,
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-control {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 10px;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 10px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.media-timing-review-fine-control span {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-control button {
|
||||
min-width: 62px;
|
||||
min-width: 58px;
|
||||
padding: 5px 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-timing-review-status {
|
||||
min-height: 21px;
|
||||
padding-top: 7px;
|
||||
padding-top: 8px;
|
||||
color: var(--ctp-teal);
|
||||
font-size: 11px;
|
||||
font-weight: 620;
|
||||
@@ -1647,7 +1821,7 @@ body:focus-visible,
|
||||
|
||||
.media-timing-review-footer {
|
||||
justify-content: space-between;
|
||||
padding-top: 5px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.media-timing-review-play-button,
|
||||
@@ -1655,20 +1829,38 @@ body:focus-visible,
|
||||
.media-timing-review-quiet-button,
|
||||
.media-timing-review-original-button,
|
||||
.media-timing-review-discard-button {
|
||||
padding: 9px 14px;
|
||||
padding: 9px 15px;
|
||||
}
|
||||
|
||||
.media-timing-review-play-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-color: var(--ctp-teal);
|
||||
background: var(--ctp-teal);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.media-timing-review-play-glyph {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-block: 5px solid transparent;
|
||||
border-left: 9px solid currentcolor;
|
||||
}
|
||||
|
||||
.media-timing-review-play-button.is-playing {
|
||||
border-color: var(--ctp-yellow);
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.media-timing-review-play-button.is-playing .media-timing-review-play-glyph {
|
||||
width: 9px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
.media-timing-review-confirm-button {
|
||||
border-color: var(--ctp-blue);
|
||||
background: var(--ctp-blue);
|
||||
@@ -1686,20 +1878,48 @@ body:focus-visible,
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.media-timing-review-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding-top: 14px;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.media-timing-review-hints kbd {
|
||||
display: inline-block;
|
||||
min-width: 15px;
|
||||
margin-right: 3px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 5px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-step {
|
||||
min-height: 330px;
|
||||
padding: 48px 38px 38px;
|
||||
min-height: 300px;
|
||||
padding: 44px 38px 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-mark {
|
||||
display: grid;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 16px;
|
||||
place-items: center;
|
||||
border: 2px solid var(--ctp-yellow);
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 26px;
|
||||
font-weight: 760;
|
||||
@@ -1707,13 +1927,13 @@ body:focus-visible,
|
||||
|
||||
.media-timing-review-cancel-step h2 {
|
||||
color: var(--ctp-text);
|
||||
font-size: 22px;
|
||||
font-size: 21px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-step p {
|
||||
max-width: 520px;
|
||||
margin: 8px auto 26px;
|
||||
margin: 8px auto 24px;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
@@ -1726,6 +1946,17 @@ body:focus-visible,
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-timing-review-content {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.media-timing-review-track.is-loading::after,
|
||||
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.media-timing-review-content {
|
||||
width: calc(100vw - 18px);
|
||||
@@ -1738,6 +1969,15 @@ body:focus-visible,
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.media-timing-review-readout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.media-timing-review-readout > div + div {
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,11 @@ export type RendererDom = {
|
||||
mediaTimingReviewTimelineStart: HTMLElement;
|
||||
mediaTimingReviewTimelineEnd: HTMLElement;
|
||||
mediaTimingReviewSelectionTrack: HTMLDivElement;
|
||||
mediaTimingReviewStartRange: HTMLInputElement;
|
||||
mediaTimingReviewEndRange: HTMLInputElement;
|
||||
mediaTimingReviewWaveformLabel: HTMLElement;
|
||||
mediaTimingReviewWaveformPath: SVGPathElement;
|
||||
mediaTimingReviewSelectedRange: HTMLDivElement;
|
||||
mediaTimingReviewStartHandle: HTMLDivElement;
|
||||
mediaTimingReviewEndHandle: HTMLDivElement;
|
||||
mediaTimingReviewShowEarlier: HTMLButtonElement;
|
||||
mediaTimingReviewShowLater: HTMLButtonElement;
|
||||
mediaTimingReviewStartBack: HTMLButtonElement;
|
||||
@@ -62,6 +65,7 @@ export type RendererDom = {
|
||||
mediaTimingReviewEndBack: HTMLButtonElement;
|
||||
mediaTimingReviewEndForward: HTMLButtonElement;
|
||||
mediaTimingReviewPlay: HTMLButtonElement;
|
||||
mediaTimingReviewPlayLabel: HTMLElement;
|
||||
mediaTimingReviewReset: HTMLButtonElement;
|
||||
mediaTimingReviewCancel: HTMLButtonElement;
|
||||
mediaTimingReviewConfirm: HTMLButtonElement;
|
||||
@@ -174,8 +178,8 @@ export type RendererDom = {
|
||||
playlistBrowserClose: HTMLButtonElement;
|
||||
};
|
||||
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
function getRequiredElement<T extends Element>(id: string): T {
|
||||
const element = document.querySelector(`#${id}`);
|
||||
if (!element) {
|
||||
throw new Error(`Missing required DOM element #${id}`);
|
||||
}
|
||||
@@ -246,10 +250,19 @@ export function resolveRendererDom(): RendererDom {
|
||||
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
|
||||
'mediaTimingReviewSelectionTrack',
|
||||
),
|
||||
mediaTimingReviewStartRange: getRequiredElement<HTMLInputElement>(
|
||||
'mediaTimingReviewStartRange',
|
||||
mediaTimingReviewWaveformLabel: getRequiredElement<HTMLElement>(
|
||||
'mediaTimingReviewWaveformLabel',
|
||||
),
|
||||
mediaTimingReviewEndRange: getRequiredElement<HTMLInputElement>('mediaTimingReviewEndRange'),
|
||||
mediaTimingReviewWaveformPath: getRequiredElement<SVGPathElement>(
|
||||
'mediaTimingReviewWaveformPath',
|
||||
),
|
||||
mediaTimingReviewSelectedRange: getRequiredElement<HTMLDivElement>(
|
||||
'mediaTimingReviewSelectedRange',
|
||||
),
|
||||
mediaTimingReviewStartHandle: getRequiredElement<HTMLDivElement>(
|
||||
'mediaTimingReviewStartHandle',
|
||||
),
|
||||
mediaTimingReviewEndHandle: getRequiredElement<HTMLDivElement>('mediaTimingReviewEndHandle'),
|
||||
mediaTimingReviewShowEarlier: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewShowEarlier',
|
||||
),
|
||||
@@ -263,6 +276,7 @@ export function resolveRendererDom(): RendererDom {
|
||||
'mediaTimingReviewEndForward',
|
||||
),
|
||||
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
|
||||
mediaTimingReviewPlayLabel: getRequiredElement<HTMLElement>('mediaTimingReviewPlayLabel'),
|
||||
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
|
||||
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
|
||||
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
|
||||
|
||||
Reference in New Issue
Block a user