feat: add skip segment editor UI
This commit is contained in:
@@ -9,6 +9,7 @@ import { setupMode, updateModeButtons } from './mode';
|
||||
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui';
|
||||
import { goToNextEpisode } from './episodes/nav';
|
||||
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
||||
import { setupSegmentEditor } from './skip/editor';
|
||||
import { setupThumbnails } from './episodes/thumbnails';
|
||||
import { markEpisodeTransition, setupProgress } from './progress';
|
||||
import {
|
||||
@@ -84,6 +85,7 @@ const initPlayer = (): void => {
|
||||
setupControls();
|
||||
setupKeyboard();
|
||||
setupSkip();
|
||||
setupSegmentEditor();
|
||||
setupSubtitles();
|
||||
setupQuality();
|
||||
setupMode();
|
||||
|
||||
169
static/player/skip/editor.ts
Normal file
169
static/player/skip/editor.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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 typeTrigger = qs<HTMLButtonElement>(panel, '[data-segment-type-trigger]');
|
||||
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[];
|
||||
|
||||
let startTime: number | null = null;
|
||||
let endTime: number | 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 => {
|
||||
panel.classList.remove('hidden');
|
||||
setError(null);
|
||||
showControls();
|
||||
};
|
||||
const close = (): void => {
|
||||
panel.classList.add('hidden');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (panel.classList.contains('hidden')) open();
|
||||
else close();
|
||||
});
|
||||
closeBtn?.addEventListener('click', close);
|
||||
|
||||
// close when clicking outside the segment capture UI
|
||||
document.addEventListener('pointerdown', e => {
|
||||
if (panel.classList.contains('hidden')) return;
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (root.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) {
|
||||
setError(res.status === 401 ? 'Login required.' : 'Failed to save segment.');
|
||||
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 });
|
||||
resolveActiveSegments();
|
||||
renderSegments();
|
||||
|
||||
close();
|
||||
} catch {
|
||||
setError('Failed to save segment.');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.classList.remove('opacity-70');
|
||||
}
|
||||
});
|
||||
|
||||
updateLabels();
|
||||
};
|
||||
@@ -53,6 +53,73 @@
|
||||
{{template "video_player" dict "WatchData" .WatchData "TotalEpisodes" $totalEpisodes}}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end" data-segment-editor-root>
|
||||
<div class="relative">
|
||||
<button type="button" data-segment-editor-toggle class="inline-flex items-center gap-2 px-4 py-2 bg-background-surface hover:bg-surface-hover text-sm text-foreground-muted transition-colors ring-1 ring-border">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||||
Add missing segment
|
||||
</button>
|
||||
|
||||
<div data-segment-editor class="hidden absolute right-0 bottom-full mb-2 z-50 w-[520px] max-w-[calc(100vw-2rem)] max-h-[min(420px,60vh)] overflow-auto bg-background-surface ring-1 ring-border shadow-soft">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-foreground">Segment capture</span>
|
||||
<span class="text-xs text-foreground-muted">Mark start (position 1), then end (position 2), then save.</span>
|
||||
</div>
|
||||
<button type="button" data-segment-editor-close class="text-sm text-foreground-muted hover:text-foreground transition-colors">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs text-foreground-muted w-12">Type</label>
|
||||
<input type="hidden" data-segment-type-value value="ed" />
|
||||
<ui-dropdown class="relative block flex-1" data-align="left" data-width="w-full">
|
||||
<div data-trigger>
|
||||
<button type="button" data-segment-type-trigger class="w-full flex items-center justify-between px-3 py-2 bg-background-button border border-border text-sm text-foreground hover:bg-surface-hover transition-colors">
|
||||
<span data-segment-type-label>Ending (ED)</span>
|
||||
<svg class="w-4 h-4 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div data-content class="hidden absolute z-50 top-full mt-1 left-0 w-full bg-background-button shadow-soft ring-1 ring-border">
|
||||
<div class="flex flex-col py-1">
|
||||
<button type="button" data-segment-type-option data-value="ed" class="flex w-full items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-surface-hover">
|
||||
<span class="text-sm text-foreground">Ending (ED)</span>
|
||||
</button>
|
||||
<button type="button" data-segment-type-option data-value="op" class="flex w-full items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-surface-hover">
|
||||
<span class="text-sm text-foreground">Opening (OP)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ui-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button type="button" data-segment-mark-start class="px-3 py-2 bg-accent/15 hover:bg-accent/20 text-sm text-accent transition-colors ring-1 ring-accent/30">Mark start</button>
|
||||
<button type="button" data-segment-mark-end class="px-3 py-2 bg-accent/15 hover:bg-accent/20 text-sm text-accent transition-colors ring-1 ring-accent/30">Mark end</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center justify-between px-3 py-2 bg-background-button ring-1 ring-border">
|
||||
<span class="text-xs text-foreground-muted">Start</span>
|
||||
<span data-segment-start class="text-sm tabular-nums text-foreground">—</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-3 py-2 bg-background-button ring-1 ring-border">
|
||||
<span class="text-xs text-foreground-muted">End</span>
|
||||
<span data-segment-end class="text-sm tabular-nums text-foreground">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="button" data-segment-reset class="text-sm text-foreground-muted hover:text-foreground transition-colors">Reset</button>
|
||||
<button type="button" data-segment-save class="px-4 py-2 bg-accent text-black text-sm font-semibold hover:opacity-90 transition-opacity">Save</button>
|
||||
</div>
|
||||
|
||||
<div data-segment-error class="hidden text-sm text-red-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-4">
|
||||
<div class="flex gap-2">
|
||||
{{$prevEp := sub (int $currentEpID) 1}}
|
||||
|
||||
Reference in New Issue
Block a user