Files
mal/static/player/skip/editor.ts
2026-05-25 01:55:23 +02:00

234 lines
7.6 KiB
TypeScript

import { state } from '../state';
import { formatTime, showControls } from '../controls';
import { resolveActiveSegments, renderSegments } from './segments';
type SkipType = 'op' | 'ed';
const qs = <T extends Element>(root: ParentNode, sel: string): T | null =>
root.querySelector(sel) as T | null;
export const setupSegmentEditor = (): void => {
const root = document.querySelector('[data-segment-editor-root]') as HTMLElement | null;
if (!root) return;
const toggleBtn = qs<HTMLButtonElement>(root, '[data-segment-editor-toggle]');
const panel = qs<HTMLElement>(root, '[data-segment-editor]');
if (!toggleBtn || !panel) return;
const closeBtn = qs<HTMLButtonElement>(panel, '[data-segment-editor-close]');
const typeValue = qs<HTMLInputElement>(panel, '[data-segment-type-value]');
const typeLabel = qs<HTMLElement>(panel, '[data-segment-type-label]');
const markStartBtn = qs<HTMLButtonElement>(panel, '[data-segment-mark-start]');
const markEndBtn = qs<HTMLButtonElement>(panel, '[data-segment-mark-end]');
const startLabel = qs<HTMLElement>(panel, '[data-segment-start]');
const endLabel = qs<HTMLElement>(panel, '[data-segment-end]');
const resetBtn = qs<HTMLButtonElement>(panel, '[data-segment-reset]');
const saveBtn = qs<HTMLButtonElement>(panel, '[data-segment-save]');
const errorBox = qs<HTMLElement>(panel, '[data-segment-error]');
const typeOptions = Array.from(
panel.querySelectorAll('[data-segment-type-option]')
) as HTMLButtonElement[];
const focusableSelector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
let startTime: number | null = null;
let endTime: number | null = null;
let lastActiveElement: HTMLElement | null = null;
const setError = (msg: string | null): void => {
if (!errorBox) return;
if (!msg) {
errorBox.classList.add('hidden');
errorBox.textContent = '';
return;
}
errorBox.textContent = msg;
errorBox.classList.remove('hidden');
};
const updateLabels = (): void => {
if (startLabel) startLabel.textContent = startTime == null ? '—' : formatTime(startTime);
if (endLabel) endLabel.textContent = endTime == null ? '—' : formatTime(endTime);
};
const open = (): void => {
lastActiveElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
panel.classList.remove('hidden');
panel.classList.add('flex');
panel.setAttribute('aria-hidden', 'false');
setError(null);
showControls();
const firstFocusable = panel.querySelector(focusableSelector) as HTMLElement | null;
firstFocusable?.focus();
};
const close = (): void => {
panel.classList.add('hidden');
panel.classList.remove('flex');
panel.setAttribute('aria-hidden', 'true');
setError(null);
lastActiveElement?.focus();
};
toggleBtn.addEventListener('click', () => {
if (panel.classList.contains('hidden')) open();
else close();
});
closeBtn?.addEventListener('click', close);
document.addEventListener('keydown', e => {
if (panel.classList.contains('hidden')) return;
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab') return;
const focusables = Array.from(panel.querySelectorAll(focusableSelector)).filter(
el =>
el instanceof HTMLElement && !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
return;
}
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
});
// close when clicking the backdrop outside the modal content
document.addEventListener('pointerdown', e => {
if (panel.classList.contains('hidden')) return;
const target = e.target as Node | null;
if (!target) return;
if (
(e.target as HTMLElement | null)?.closest('[data-segment-editor] [data-segment-editor-close]')
)
return;
const content = panel.firstElementChild;
if (content && content.contains(target)) return;
close();
});
// dropdown type selector (uses ui-dropdown for visuals; we manage the value)
typeOptions.forEach(btn => {
btn.addEventListener('click', () => {
const v = (btn.getAttribute('data-value') || 'ed') as SkipType;
if (typeValue) typeValue.value = v;
if (typeLabel) typeLabel.textContent = v === 'op' ? 'Opening (OP)' : 'Ending (ED)';
// close dropdown popover if it exists
const dropdown = btn.closest('ui-dropdown');
const content = dropdown?.querySelector('[data-content]') as HTMLElement | null;
content?.classList.add('hidden');
showControls();
});
});
const reset = (): void => {
startTime = null;
endTime = null;
setError(null);
updateLabels();
};
resetBtn?.addEventListener('click', reset);
markStartBtn?.addEventListener('click', () => {
startTime = Math.max(0, state.video.currentTime);
if (endTime != null && startTime >= endTime) endTime = null;
setError(null);
updateLabels();
showControls();
});
markEndBtn?.addEventListener('click', () => {
endTime = Math.max(0, state.video.currentTime);
if (startTime != null && endTime <= startTime) {
setError('End must be after start.');
return;
}
setError(null);
updateLabels();
showControls();
});
saveBtn?.addEventListener('click', async () => {
const skipType = ((typeValue?.value || 'ed') as SkipType) ?? 'ed';
if (startTime == null || endTime == null) {
setError('Mark start and end first.');
return;
}
if (endTime <= startTime) {
setError('End must be after start.');
return;
}
saveBtn.disabled = true;
saveBtn.classList.add('opacity-70');
setError(null);
try {
const res = await fetch('/api/watch/segments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mal_id: state.malID,
episode: Number.parseInt(state.currentEpisode, 10),
skip_type: skipType,
start_time: startTime,
end_time: endTime,
}),
});
if (!res.ok) {
let message = res.status === 401 ? 'Login required.' : 'Failed to save segment.';
try {
const payload = (await res.json()) as { error?: string };
if (payload?.error) message = payload.error;
} catch {}
setError(message);
return;
}
// Update local segments immediately so UI reflects the saved data.
const normalizedType = skipType === 'ed' ? 'ending' : 'opening';
state.parsedSegments = (state.parsedSegments || []).filter(s => {
const t = (s.type || '').toLowerCase();
if (normalizedType === 'ending') return t !== 'ed' && t !== 'ending' && t !== 'outro';
return t !== 'op' && t !== 'opening' && t !== 'intro';
});
state.parsedSegments.push({
type: normalizedType,
start: startTime,
end: endTime,
source: 'override',
});
resolveActiveSegments();
renderSegments();
window.showToast?.({ message: 'Segment saved.' });
close();
} catch {
setError('Failed to save segment.');
} finally {
saveBtn.disabled = false;
saveBtn.classList.remove('opacity-70');
}
});
updateLabels();
};