288 lines
8.5 KiB
TypeScript
288 lines
8.5 KiB
TypeScript
import { formatTime, showControls } from "../controls";
|
|
import { state } from "../state";
|
|
import { resolveActiveSegments, renderSegments } from "./segments";
|
|
|
|
type SkipType = "op" | "ed";
|
|
type ClosableDropdown = HTMLElement & { close: (options?: { restoreFocus?: boolean }) => void };
|
|
|
|
const qs = <T extends Element>(root: ParentNode, sel: string): T | null =>
|
|
root.querySelector(sel) as T | null;
|
|
|
|
const isClosableDropdown = (element: Element | null): element is ClosableDropdown =>
|
|
element instanceof HTMLElement && "close" in element && typeof element.close === "function";
|
|
|
|
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 = [
|
|
...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")) {
|
|
close();
|
|
return;
|
|
}
|
|
|
|
const dropdown = toggleBtn.closest("ui-dropdown");
|
|
if (isClosableDropdown(dropdown)) {
|
|
dropdown.close({ restoreFocus: false });
|
|
}
|
|
open();
|
|
});
|
|
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 = [...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.dataset.value || "ed") as SkipType;
|
|
if (typeValue) {
|
|
typeValue.value = v;
|
|
}
|
|
if (typeLabel) {
|
|
typeLabel.textContent = v === "op" ? "Opening (OP)" : "Ending (ED)";
|
|
}
|
|
const dropdown = btn.closest("ui-dropdown");
|
|
if (isClosableDropdown(dropdown)) {
|
|
dropdown.close({ restoreFocus: false });
|
|
}
|
|
showControls();
|
|
});
|
|
});
|
|
|
|
const reset = (): void => {
|
|
startTime = null;
|
|
endTime = null;
|
|
setError(null);
|
|
updateLabels();
|
|
};
|
|
resetBtn?.addEventListener("click", reset);
|
|
|
|
markStartBtn?.addEventListener("click", () => {
|
|
startTime = Math.max(0, state.elements.video.currentTime);
|
|
if (endTime != null && startTime >= endTime) {
|
|
endTime = null;
|
|
}
|
|
setError(null);
|
|
updateLabels();
|
|
showControls();
|
|
});
|
|
markEndBtn?.addEventListener("click", () => {
|
|
endTime = Math.max(0, state.elements.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.episode.malID,
|
|
episode: Number.parseInt(state.episode.current, 10),
|
|
skip_type: skipType,
|
|
start_time: startTime,
|
|
end_time: endTime,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
let message = res.status === 401 ? "Login required." : "Failed to save segment.";
|
|
let payload: { error?: string } | null = null;
|
|
try {
|
|
payload = await res.json();
|
|
} catch (error) {
|
|
console.error("failed to parse response json:", error);
|
|
}
|
|
if (payload?.error) {
|
|
message = payload.error;
|
|
}
|
|
setError(message);
|
|
return;
|
|
}
|
|
|
|
// Update local segments immediately so UI reflects the saved data.
|
|
const normalizedType = skipType === "ed" ? "ending" : "opening";
|
|
state.skip.parsedSegments = state.skip.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.skip.parsedSegments.push({
|
|
type: normalizedType,
|
|
start: startTime,
|
|
end: endTime,
|
|
source: "override",
|
|
});
|
|
resolveActiveSegments();
|
|
renderSegments();
|
|
|
|
window.showToast?.({ message: "Segment saved." });
|
|
close();
|
|
} catch (error) {
|
|
setError("Failed To Save Segment.");
|
|
console.error("failed to save segment:", error);
|
|
throw error;
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.classList.remove("opacity-70");
|
|
}
|
|
});
|
|
|
|
updateLabels();
|
|
};
|