chore: format player main
This commit is contained in:
@@ -1,25 +1,25 @@
|
|||||||
import { state, initState } from './state';
|
import { state, initState } from "./state";
|
||||||
import { invalidateBounds, updateTimeline } from './timeline';
|
import { invalidateBounds, updateTimeline } from "./timeline";
|
||||||
import { setupControls, showControls } from './controls';
|
import { setupControls, showControls } from "./controls";
|
||||||
import { setupKeyboard } from './keyboard';
|
import { setupKeyboard } from "./keyboard";
|
||||||
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles';
|
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles";
|
||||||
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip';
|
import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip";
|
||||||
import { setupQuality, updateQualityOptions } from './quality';
|
import { setupQuality, updateQualityOptions } from "./quality";
|
||||||
import { setupMode, updateModeButtons } from './mode';
|
import { setupMode, updateModeButtons } from "./mode";
|
||||||
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui';
|
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui";
|
||||||
import { goToNextEpisode } from './episodes/nav';
|
import { goToNextEpisode } from "./episodes/nav";
|
||||||
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
import { resolveActiveSegments, renderSegments } from "./skip/segments";
|
||||||
import { setupSegmentEditor } from './skip/editor';
|
import { setupSegmentEditor } from "./skip/editor";
|
||||||
import { setupThumbnails } from './episodes/thumbnails';
|
import { setupThumbnails } from "./episodes/thumbnails";
|
||||||
import { markEpisodeTransition, setupProgress } from './progress';
|
import { markEpisodeTransition, setupProgress } from "./progress";
|
||||||
import { safeLocalStorage } from './storage';
|
import { safeLocalStorage } from "./storage";
|
||||||
import {
|
import {
|
||||||
absoluteTimeFromDisplay,
|
absoluteTimeFromDisplay,
|
||||||
absoluteTimeFromRatio,
|
absoluteTimeFromRatio,
|
||||||
getBounds,
|
getBounds,
|
||||||
displayTimeFromAbsolute,
|
displayTimeFromAbsolute,
|
||||||
} from './timeline';
|
} from "./timeline";
|
||||||
import { formatTime } from './controls';
|
import { formatTime } from "./controls";
|
||||||
|
|
||||||
let currentContainer: HTMLElement | null = null;
|
let currentContainer: HTMLElement | null = null;
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
@@ -29,22 +29,22 @@ const isClosableDropdown = (el: Element | null): el is ClosableDropdown => {
|
|||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
if (!(el instanceof HTMLElement)) return false;
|
if (!(el instanceof HTMLElement)) return false;
|
||||||
const maybe = el as Partial<{ close: unknown }>;
|
const maybe = el as Partial<{ close: unknown }>;
|
||||||
return typeof maybe.close === 'function';
|
return typeof maybe.close === "function";
|
||||||
};
|
};
|
||||||
|
|
||||||
const hidePreviewPopover = (): void => {
|
const hidePreviewPopover = (): void => {
|
||||||
if (!state.previewPopover) return;
|
if (!state.previewPopover) return;
|
||||||
state.previewPopover.classList.add('hidden');
|
state.previewPopover.classList.add("hidden");
|
||||||
state.previewPopover.classList.add('opacity-0');
|
state.previewPopover.classList.add("opacity-0");
|
||||||
state.previewPopover.classList.remove('opacity-100');
|
state.previewPopover.classList.remove("opacity-100");
|
||||||
state.previewPopover.style.left = '';
|
state.previewPopover.style.left = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const showPreviewPopover = (): void => {
|
const showPreviewPopover = (): void => {
|
||||||
if (!state.previewPopover) return;
|
if (!state.previewPopover) return;
|
||||||
state.previewPopover.classList.remove('hidden');
|
state.previewPopover.classList.remove("hidden");
|
||||||
state.previewPopover.classList.remove('opacity-0');
|
state.previewPopover.classList.remove("opacity-0");
|
||||||
state.previewPopover.classList.add('opacity-100');
|
state.previewPopover.classList.add("opacity-100");
|
||||||
};
|
};
|
||||||
|
|
||||||
const teardownPlayer = (): void => {
|
const teardownPlayer = (): void => {
|
||||||
@@ -55,7 +55,7 @@ const teardownPlayer = (): void => {
|
|||||||
|
|
||||||
// updates time preview on progress bar hover
|
// updates time preview on progress bar hover
|
||||||
const updatePreviewUI = (ratio: number): void => {
|
const updatePreviewUI = (ratio: number): void => {
|
||||||
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
const progressWrap = state.container.querySelector("[data-progress-wrap]") as HTMLElement | null;
|
||||||
if (!progressWrap || !state.previewPopover || !state.previewTime) {
|
if (!progressWrap || !state.previewPopover || !state.previewTime) {
|
||||||
hidePreviewPopover();
|
hidePreviewPopover();
|
||||||
return;
|
return;
|
||||||
@@ -82,13 +82,13 @@ const updatePreviewUI = (ratio: number): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initPlayer = (): void => {
|
const initPlayer = (): void => {
|
||||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
const container = document.querySelector("[data-video-player]") as HTMLElement | null;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (container === currentContainer) return;
|
if (container === currentContainer) return;
|
||||||
teardownPlayer();
|
teardownPlayer();
|
||||||
|
|
||||||
if (!initState(container)) {
|
if (!initState(container)) {
|
||||||
console.error('Video player markup is missing required controls.');
|
console.error("Video player markup is missing required controls.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentContainer = container;
|
currentContainer = container;
|
||||||
@@ -96,15 +96,15 @@ const initPlayer = (): void => {
|
|||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
cleanup = () => abortController.abort();
|
cleanup = () => abortController.abort();
|
||||||
|
|
||||||
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
|
const loading = container.querySelector("[data-loading]") as HTMLElement | null;
|
||||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
const progressWrap = container.querySelector("[data-progress-wrap]") as HTMLElement | null;
|
||||||
|
|
||||||
// build video src from mode, token, and saved quality preference
|
// build video src from mode, token, and saved quality preference
|
||||||
// Only set if not already provided by the inline script during HTML parsing
|
// Only set if not already provided by the inline script during HTML parsing
|
||||||
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
|
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
||||||
const streamToken = state.modeSources[state.currentMode]?.token;
|
const streamToken = state.modeSources[state.currentMode]?.token;
|
||||||
if (!state.video.src && streamToken) {
|
if (!state.video.src && streamToken) {
|
||||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupProgress();
|
setupProgress();
|
||||||
@@ -122,7 +122,7 @@ const initPlayer = (): void => {
|
|||||||
setupAutoplayButton();
|
setupAutoplayButton();
|
||||||
updateAutoSkipButton();
|
updateAutoSkipButton();
|
||||||
showControls();
|
showControls();
|
||||||
if (state.modeSwitchedFrom === 'dub' && state.currentMode === 'sub') {
|
if (state.modeSwitchedFrom === "dub" && state.currentMode === "sub") {
|
||||||
window.showToast?.({
|
window.showToast?.({
|
||||||
message: `Episode ${state.currentEpisode} is only available in sub, switched from dub.`,
|
message: `Episode ${state.currentEpisode} is only available in sub, switched from dub.`,
|
||||||
});
|
});
|
||||||
@@ -130,7 +130,7 @@ const initPlayer = (): void => {
|
|||||||
|
|
||||||
const onLoadedMetadata = (): void => {
|
const onLoadedMetadata = (): void => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.style.display = 'none';
|
loading.style.display = "none";
|
||||||
}
|
}
|
||||||
invalidateBounds();
|
invalidateBounds();
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ const initPlayer = (): void => {
|
|||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.video.currentTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
state.video.addEventListener('loadedmetadata', onLoadedMetadata, { signal });
|
state.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
|
||||||
// inline script runs during HTML parsing before initPlayer; if metadata
|
// inline script runs during HTML parsing before initPlayer; if metadata
|
||||||
// already loaded, fire the handler immediately
|
// already loaded, fire the handler immediately
|
||||||
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
@@ -167,142 +167,142 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.video.addEventListener(
|
state.video.addEventListener(
|
||||||
'waiting',
|
"waiting",
|
||||||
() => {
|
() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.style.display = 'flex';
|
loading.style.display = "flex";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
state.video.addEventListener(
|
state.video.addEventListener(
|
||||||
'playing',
|
"playing",
|
||||||
() => {
|
() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.style.display = 'none';
|
loading.style.display = "none";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
// update progress bar during buffering
|
// update progress bar during buffering
|
||||||
state.video.addEventListener(
|
state.video.addEventListener(
|
||||||
'progress',
|
"progress",
|
||||||
() => {
|
() => {
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.video.currentTime);
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// main loop: update progress, subtitles, skip buttons
|
// main loop: update progress, subtitles, skip buttons
|
||||||
state.video.addEventListener(
|
state.video.addEventListener(
|
||||||
'timeupdate',
|
"timeupdate",
|
||||||
() => {
|
() => {
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.video.currentTime);
|
||||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.video.currentTime);
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
state.video.addEventListener(
|
state.video.addEventListener(
|
||||||
'ended',
|
"ended",
|
||||||
() => {
|
() => {
|
||||||
goToNextEpisode();
|
goToNextEpisode();
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
|
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
|
||||||
progressWrap?.addEventListener(
|
progressWrap?.addEventListener(
|
||||||
'pointerdown',
|
"pointerdown",
|
||||||
e => {
|
(e) => {
|
||||||
// ignore right/middle click
|
// ignore right/middle click
|
||||||
if ('button' in e && e.button !== 0) return;
|
if ("button" in e && e.button !== 0) return;
|
||||||
state.isScrubbing = true;
|
state.isScrubbing = true;
|
||||||
try {
|
try {
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
||||||
} catch {}
|
} catch {}
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
state.video.currentTime = absoluteTimeFromRatio(
|
state.video.currentTime = absoluteTimeFromRatio(
|
||||||
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||||
);
|
);
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.video.currentTime);
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.video.currentTime);
|
||||||
showControls();
|
showControls();
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// hover to preview time
|
// hover to preview time
|
||||||
progressWrap?.addEventListener(
|
progressWrap?.addEventListener(
|
||||||
'pointermove',
|
"pointermove",
|
||||||
e => {
|
(e) => {
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal });
|
progressWrap?.addEventListener("pointerleave", hidePreviewPopover, { signal });
|
||||||
progressWrap?.addEventListener(
|
progressWrap?.addEventListener(
|
||||||
'pointerup',
|
"pointerup",
|
||||||
() => {
|
() => {
|
||||||
// ensure we finish the seek even if no window mousemove fired
|
// ensure we finish the seek even if no window mousemove fired
|
||||||
if (!progressWrap) return;
|
if (!progressWrap) return;
|
||||||
state.isScrubbing = false;
|
state.isScrubbing = false;
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// dragging outside progress bar while scrubbing
|
// dragging outside progress bar while scrubbing
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
'pointermove',
|
"pointermove",
|
||||||
e => {
|
(e) => {
|
||||||
if (!state.isScrubbing || !progressWrap) return;
|
if (!state.isScrubbing || !progressWrap) return;
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
state.video.currentTime = absoluteTimeFromRatio(
|
state.video.currentTime = absoluteTimeFromRatio(
|
||||||
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||||
);
|
);
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.video.currentTime);
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.video.currentTime);
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// track next-episode links outside the player so they start fresh after finishing an episode
|
// track next-episode links outside the player so they start fresh after finishing an episode
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'click',
|
"click",
|
||||||
e => {
|
(e) => {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (!(target instanceof Element)) return;
|
if (!(target instanceof Element)) return;
|
||||||
const anchor = target.closest('a[href]');
|
const anchor = target.closest("a[href]");
|
||||||
if (!(anchor instanceof HTMLAnchorElement)) return;
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||||
const url = new URL(anchor.href, location.origin);
|
const url = new URL(anchor.href, location.origin);
|
||||||
if (url.origin !== location.origin) return;
|
if (url.origin !== location.origin) return;
|
||||||
const parts = url.pathname.split('/').filter(Boolean);
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
|
if (parts[0] !== "anime" || parts[2] !== "watch") return;
|
||||||
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
||||||
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
|
const nextEpisode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10);
|
||||||
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
||||||
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
state.video.addEventListener('click', showControls, { signal });
|
state.video.addEventListener("click", showControls, { signal });
|
||||||
|
|
||||||
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
|
const searchInput = document.querySelector("[data-episode-search]") as HTMLInputElement | null;
|
||||||
const dropdown = document.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
const dropdown = document.querySelector("[data-episode-dropdown]") as HTMLElement | null;
|
||||||
let searchDebounce: number | undefined;
|
let searchDebounce: number | undefined;
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener(
|
searchInput.addEventListener(
|
||||||
'input',
|
"input",
|
||||||
() => {
|
() => {
|
||||||
clearTimeout(searchDebounce);
|
clearTimeout(searchDebounce);
|
||||||
// debounce to avoid excessive range switches while typing
|
// debounce to avoid excessive range switches while typing
|
||||||
searchDebounce = window.setTimeout(() => {
|
searchDebounce = window.setTimeout(() => {
|
||||||
const val = searchInput.value.replace(/\D/g, '');
|
const val = searchInput.value.replace(/\D/g, "");
|
||||||
if (!val) {
|
if (!val) {
|
||||||
// clear: jump to current episode range
|
// clear: jump to current episode range
|
||||||
const cur = Number.parseInt(state.currentEpisode, 10);
|
const cur = Number.parseInt(state.currentEpisode, 10);
|
||||||
@@ -321,22 +321,22 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// range buttons (100s of episodes)
|
// range buttons (100s of episodes)
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
dropdown.querySelectorAll(".episode-range-btn").forEach((btn) => {
|
||||||
btn.addEventListener(
|
btn.addEventListener(
|
||||||
'click',
|
"click",
|
||||||
() => {
|
() => {
|
||||||
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? "0", 10);
|
||||||
switchEpisodeRange(idx);
|
switchEpisodeRange(idx);
|
||||||
const dd = btn.closest('ui-dropdown');
|
const dd = btn.closest("ui-dropdown");
|
||||||
if (isClosableDropdown(dd)) dd.close();
|
if (isClosableDropdown(dd)) dd.close();
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,13 +349,13 @@ const initPlayer = (): void => {
|
|||||||
setupThumbnails();
|
setupThumbnails();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initPlayer);
|
document.addEventListener("DOMContentLoaded", initPlayer);
|
||||||
document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
document.body.addEventListener("htmx:afterSwap", (e: Event) => {
|
||||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
||||||
if (target?.querySelector('[data-video-player]')) initPlayer();
|
if (target?.querySelector("[data-video-player]")) initPlayer();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:beforeSwap', (e: Event) => {
|
document.body.addEventListener("htmx:beforeSwap", (e: Event) => {
|
||||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
||||||
if (target && currentContainer && target.contains(currentContainer)) {
|
if (target && currentContainer && target.contains(currentContainer)) {
|
||||||
teardownPlayer();
|
teardownPlayer();
|
||||||
|
|||||||
Reference in New Issue
Block a user