From 5d21f6f4de5cabfa134b85e48f07aa181e8c7f67 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 19 May 2026 11:02:59 +0200 Subject: [PATCH] feat: add skip segment editor UI --- static/player/main.ts | 2 + static/player/skip/editor.ts | 169 +++++++++++++++++++++++++++++++++++ templates/watch.gohtml | 67 ++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 static/player/skip/editor.ts diff --git a/static/player/main.ts b/static/player/main.ts index 7389454..927df95 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -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(); diff --git a/static/player/skip/editor.ts b/static/player/skip/editor.ts new file mode 100644 index 0000000..3fac222 --- /dev/null +++ b/static/player/skip/editor.ts @@ -0,0 +1,169 @@ +import { state } from '../state'; +import { formatTime, showControls } from '../controls'; +import { resolveActiveSegments, renderSegments } from './segments'; + +type SkipType = 'op' | 'ed'; + +const qs = (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(root, '[data-segment-editor-toggle]'); + const panel = qs(root, '[data-segment-editor]'); + if (!toggleBtn || !panel) return; + + const closeBtn = qs(panel, '[data-segment-editor-close]'); + const typeValue = qs(panel, '[data-segment-type-value]'); + const typeLabel = qs(panel, '[data-segment-type-label]'); + const typeTrigger = qs(panel, '[data-segment-type-trigger]'); + const markStartBtn = qs(panel, '[data-segment-mark-start]'); + const markEndBtn = qs(panel, '[data-segment-mark-end]'); + const startLabel = qs(panel, '[data-segment-start]'); + const endLabel = qs(panel, '[data-segment-end]'); + const resetBtn = qs(panel, '[data-segment-reset]'); + const saveBtn = qs(panel, '[data-segment-save]'); + const errorBox = qs(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(); +}; diff --git a/templates/watch.gohtml b/templates/watch.gohtml index 367c9b2..d6e18a4 100644 --- a/templates/watch.gohtml +++ b/templates/watch.gohtml @@ -53,6 +53,73 @@ {{template "video_player" dict "WatchData" .WatchData "TotalEpisodes" $totalEpisodes}} +
+
+ + + +
+
+
{{$prevEp := sub (int $currentEpID) 1}}