feat: update subtitle sidebar overlay behavior

This commit is contained in:
2026-03-22 18:38:56 -07:00
parent 7d8d2ae7a7
commit 16f7b2507b
6 changed files with 200 additions and 30 deletions

View File

@@ -85,6 +85,36 @@
</div>
</div>
</div>
<div id="youtubePickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-content youtube-picker-content">
<div class="modal-header">
<div class="modal-title">YouTube Subtitle Tracks</div>
<button id="youtubePickerCloseButton" class="modal-close" type="button">
Continue without subtitles
</button>
</div>
<div class="modal-body youtube-picker-body">
<div id="youtubePickerTitle" class="youtube-picker-title"></div>
<div class="youtube-picker-grid">
<label class="youtube-picker-field">
<span>Primary subtitle</span>
<select id="youtubePickerPrimarySelect"></select>
</label>
<label class="youtube-picker-field">
<span>Secondary subtitle</span>
<select id="youtubePickerSecondarySelect"></select>
</label>
</div>
<div id="youtubePickerStatus" class="youtube-picker-status"></div>
<ul id="youtubePickerTracks" class="youtube-picker-tracks"></ul>
<div class="youtube-picker-footer">
<button id="youtubePickerContinueButton" class="kiku-confirm-button" type="button">
Use selected subtitles
</button>
</div>
</div>
</div>
</div>
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">

View File

@@ -84,6 +84,18 @@ test('findActiveSubtitleCueIndex prefers timing match before text fallback', ()
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: null }), 0);
});
test('findActiveSubtitleCueIndex prefers current subtitle timing over near-future clock lookahead', () => {
const cues = [
{ startTime: 231, endTime: 233.2, text: 'previous' },
{ startTime: 233.05, endTime: 236, text: 'next' },
];
assert.equal(
findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0),
0,
);
});
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;

View File

@@ -61,6 +61,18 @@ export function findActiveSubtitleCueIndex(
return -1;
}
const hasCurrentTiming =
typeof current?.startTime === 'number' && Number.isFinite(current.startTime);
if (hasCurrentTiming) {
const timingMatch = cues.findIndex(
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
);
if (timingMatch >= 0) {
return timingMatch;
}
}
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
const activeOrUpcomingCue = cues.findIndex(
(cue) =>
@@ -81,15 +93,6 @@ export function findActiveSubtitleCueIndex(
return -1;
}
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
const timingMatch = cues.findIndex(
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
);
if (timingMatch >= 0) {
return timingMatch;
}
}
const normalizedText = normalizeCueText(current.text);
if (!normalizedText) {
return -1;

View File

@@ -13,6 +13,7 @@ import type {
SubtitleSidebarConfig,
SubtitleCue,
SubsyncSourceTrack,
YoutubePickerOpenPayload,
} from '../types';
export type KikuModalStep = 'select' | 'preview';
@@ -40,6 +41,12 @@ export type RendererState = {
currentEpisodeFilter: number | null;
currentEntryId: number | null;
youtubePickerModalOpen: boolean;
youtubePickerPayload: YoutubePickerOpenPayload | null;
youtubePickerPrimaryTrackId: string | null;
youtubePickerSecondaryTrackId: string | null;
youtubePickerStatus: string;
kikuModalOpen: boolean;
kikuSelectedCard: 1 | 2;
kikuOriginalData: KikuDuplicateCardInfo | null;
@@ -131,6 +138,12 @@ export function createRendererState(): RendererState {
currentEpisodeFilter: null,
currentEntryId: null,
youtubePickerModalOpen: false,
youtubePickerPayload: null,
youtubePickerPrimaryTrackId: null,
youtubePickerSecondaryTrackId: null,
youtubePickerStatus: '',
kikuModalOpen: false,
kikuSelectedCard: 1,
kikuOriginalData: null,

View File

@@ -127,6 +127,10 @@ body {
z-index: 1100;
}
#youtubePickerModal {
z-index: 1110;
}
.modal.hidden {
display: none;
}
@@ -138,11 +142,11 @@ body {
.modal-content {
width: min(720px, 92%);
max-height: 80%;
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(36, 39, 58, 0.95);
border: 1px solid rgba(110, 115, 141, 0.18);
border-radius: 12px;
padding: 16px;
color: #fff;
color: #cad3f5;
display: flex;
flex-direction: column;
gap: 12px;
@@ -161,16 +165,17 @@ body {
}
.modal-close {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(73, 77, 100, 0.5);
color: #a5adcb;
border: 1px solid rgba(110, 115, 141, 0.2);
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.2);
background: rgba(91, 96, 120, 0.6);
color: #cad3f5;
}
.modal-body {
@@ -288,6 +293,91 @@ body {
display: none;
}
.youtube-picker-content {
width: min(820px, 92%);
background:
radial-gradient(circle at top right, rgba(198, 160, 246, 0.10), transparent 34%),
linear-gradient(180deg, rgba(36, 39, 58, 0.98), rgba(30, 32, 48, 0.98));
border-color: rgba(138, 173, 244, 0.25);
}
.youtube-picker-body {
gap: 14px;
}
.youtube-picker-title {
font-size: 13px;
color: #b8c0e0;
}
.youtube-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.youtube-picker-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #a5adcb;
}
.youtube-picker-field select {
min-height: 36px;
border-radius: 8px;
border: 1px solid rgba(110, 115, 141, 0.28);
background: rgba(24, 25, 38, 0.92);
color: #cad3f5;
padding: 6px 10px;
}
.youtube-picker-status {
min-height: 20px;
font-size: 13px;
color: #a5adcb;
}
.youtube-picker-tracks {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border-radius: 10px;
border: 1px solid rgba(110, 115, 141, 0.18);
}
.youtube-picker-tracks li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
color: #cad3f5;
}
.youtube-picker-tracks li:last-child {
border-bottom: none;
}
.youtube-picker-track-meta {
color: #6e738d;
font-size: 12px;
}
.youtube-picker-footer {
display: flex;
justify-content: flex-end;
}
@media (max-width: 700px) {
.youtube-picker-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.jimaku-form {
grid-template-columns: 1fr 1fr;
@@ -982,15 +1072,15 @@ iframe[id^='yomitan-popup'] {
.kiku-confirm-button {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid rgba(100, 180, 255, 0.4);
background: rgba(100, 180, 255, 0.15);
color: rgba(100, 180, 255, 0.95);
border: 1px solid rgba(138, 173, 244, 0.4);
background: rgba(138, 173, 244, 0.15);
color: #8aadf4;
font-weight: 600;
cursor: pointer;
}
.kiku-confirm-button:hover {
background: rgba(100, 180, 255, 0.25);
background: rgba(138, 173, 244, 0.25);
}
.subsync-modal-content {
@@ -1288,9 +1378,9 @@ iframe[id^='yomitan-popup'] {
.btn-learn {
padding: 5px 14px;
border-radius: 5px;
border: 1px solid rgba(100, 180, 255, 0.4);
background: rgba(100, 180, 255, 0.15);
color: rgba(100, 180, 255, 0.95);
border: 1px solid rgba(138, 173, 244, 0.4);
background: rgba(138, 173, 244, 0.15);
color: #8aadf4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -1298,28 +1388,28 @@ iframe[id^='yomitan-popup'] {
}
.btn-learn:hover {
background: rgba(100, 180, 255, 0.25);
background: rgba(138, 173, 244, 0.25);
}
.btn-learn.active {
border-color: rgba(100, 180, 255, 0.7);
background: rgba(100, 180, 255, 0.25);
border-color: rgba(138, 173, 244, 0.7);
background: rgba(138, 173, 244, 0.25);
}
.btn-secondary {
padding: 5px 12px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid rgba(110, 115, 141, 0.2);
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: #6e738d;
font-size: 12px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
background: rgba(73, 77, 100, 0.4);
color: #a5adcb;
}
.controller-debug-content {

View File

@@ -20,6 +20,15 @@ export type RendererDom = {
jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement;
youtubePickerModal: HTMLDivElement;
youtubePickerTitle: HTMLDivElement;
youtubePickerPrimarySelect: HTMLSelectElement;
youtubePickerSecondarySelect: HTMLSelectElement;
youtubePickerContinueButton: HTMLButtonElement;
youtubePickerCloseButton: HTMLButtonElement;
youtubePickerStatus: HTMLDivElement;
youtubePickerTracks: HTMLUListElement;
kikuModal: HTMLDivElement;
kikuCard1: HTMLDivElement;
kikuCard2: HTMLDivElement;
@@ -120,6 +129,19 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
youtubePickerPrimarySelect: getRequiredElement<HTMLSelectElement>('youtubePickerPrimarySelect'),
youtubePickerSecondarySelect: getRequiredElement<HTMLSelectElement>(
'youtubePickerSecondarySelect',
),
youtubePickerContinueButton: getRequiredElement<HTMLButtonElement>(
'youtubePickerContinueButton',
),
youtubePickerCloseButton: getRequiredElement<HTMLButtonElement>('youtubePickerCloseButton'),
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),