240 lines
8.8 KiB
TypeScript
240 lines
8.8 KiB
TypeScript
import { state, initState } from './state';
|
|
import { invalidateBounds, updateTimeline } from './timeline';
|
|
import { setupControls, showControls } from './controls';
|
|
import { setupKeyboard } from './keyboard';
|
|
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles';
|
|
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip';
|
|
import { setupQuality, updateQualityOptions } from './quality';
|
|
import { setupMode, updateModeButtons } from './mode';
|
|
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui';
|
|
import { goToNextEpisode } from './episodes/nav';
|
|
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
|
import { setupThumbnails } from './episodes/thumbnails';
|
|
import { markEpisodeTransition, setupProgress } from './progress';
|
|
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline';
|
|
import { formatTime } from './controls';
|
|
|
|
let initialized = false; // prevent double init on htmx swaps
|
|
|
|
const hidePreviewPopover = (): void => {
|
|
state.previewPopover?.classList.remove('block');
|
|
state.previewPopover?.classList.add('hidden');
|
|
state.previewPopover!.style.left = '0px';
|
|
};
|
|
|
|
const showPreviewPopover = (): void => {
|
|
state.previewPopover?.classList.remove('hidden');
|
|
state.previewPopover?.classList.add('block');
|
|
};
|
|
|
|
// updates time preview on progress bar hover
|
|
const updatePreviewUI = (ratio: number): void => {
|
|
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
|
if (!progressWrap || !state.previewPopover || !state.previewTime) {
|
|
hidePreviewPopover();
|
|
return;
|
|
}
|
|
const b = getBounds();
|
|
if (b.duration <= 0) {
|
|
hidePreviewPopover();
|
|
return;
|
|
}
|
|
|
|
// show time for hovered position
|
|
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)));
|
|
|
|
const barWidth = progressWrap.clientWidth;
|
|
if (barWidth <= 0) {
|
|
hidePreviewPopover();
|
|
return;
|
|
}
|
|
|
|
showPreviewPopover();
|
|
// clamp to stay within bar bounds
|
|
const popoverWidth = state.previewPopover.offsetWidth || 72;
|
|
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`;
|
|
};
|
|
|
|
const initPlayer = (): void => {
|
|
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
|
if (!container || initialized) return;
|
|
initialized = true;
|
|
|
|
initState(container);
|
|
|
|
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
|
|
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
|
|
|
// build video src from mode, token, and saved quality preference
|
|
// Only set if not already provided by the inline script during HTML parsing
|
|
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
|
|
const streamToken = state.modeSources[state.currentMode]?.token;
|
|
if (!state.video.src && streamToken) {
|
|
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
|
}
|
|
|
|
setupProgress();
|
|
setupControls();
|
|
setupKeyboard();
|
|
setupSkip();
|
|
setupSubtitles();
|
|
setupQuality();
|
|
setupMode();
|
|
|
|
updateSubtitleOptions();
|
|
updateQualityOptions();
|
|
updateModeButtons();
|
|
setupAutoplayButton();
|
|
updateAutoSkipButton();
|
|
showControls();
|
|
|
|
const onLoadedMetadata = (): void => {
|
|
loading && (loading.style.display = 'none');
|
|
invalidateBounds();
|
|
|
|
resolveActiveSegments();
|
|
renderSegments();
|
|
|
|
// resume from saved position
|
|
const startTime = Number(container.dataset.startTimeSeconds ?? '0');
|
|
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
|
|
state.video.currentTime = startTime;
|
|
}
|
|
// resume after mode switch
|
|
if (state.pendingSeekTime !== null) {
|
|
state.video.currentTime = state.pendingSeekTime;
|
|
state.pendingSeekTime = null;
|
|
}
|
|
// autoplay if not already playing (inline script may have already called play())
|
|
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
updateSkipButton(state.video.currentTime);
|
|
};
|
|
|
|
state.video.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
// inline script runs during HTML parsing before initPlayer; if metadata
|
|
// already loaded, fire the handler immediately
|
|
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
onLoadedMetadata();
|
|
}
|
|
|
|
state.video.addEventListener('waiting', () => {
|
|
loading && (loading.style.display = 'flex');
|
|
});
|
|
state.video.addEventListener('playing', () => {
|
|
loading && (loading.style.display = 'none');
|
|
});
|
|
// update progress bar during buffering
|
|
state.video.addEventListener('progress', () => {
|
|
updateTimeline(state.video.currentTime);
|
|
});
|
|
|
|
// main loop: update progress, subtitles, skip buttons
|
|
state.video.addEventListener('timeupdate', () => {
|
|
updateTimeline(state.video.currentTime);
|
|
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
|
updateSkipButton(state.video.currentTime);
|
|
});
|
|
|
|
state.video.addEventListener('ended', () => {
|
|
goToNextEpisode();
|
|
});
|
|
|
|
// click to seek
|
|
progressWrap?.addEventListener('mousedown', e => {
|
|
state.isScrubbing = true;
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
state.video.currentTime = absoluteTimeFromRatio(
|
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
);
|
|
updateTimeline(state.video.currentTime);
|
|
updateSkipButton(state.video.currentTime);
|
|
showControls();
|
|
});
|
|
|
|
// hover to preview time
|
|
progressWrap?.addEventListener('mousemove', e => {
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
|
});
|
|
|
|
progressWrap?.addEventListener('mouseleave', hidePreviewPopover);
|
|
|
|
// dragging outside progress bar while scrubbing
|
|
window.addEventListener('mousemove', e => {
|
|
if (!state.isScrubbing || !progressWrap) return;
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
state.video.currentTime = absoluteTimeFromRatio(
|
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
);
|
|
updateTimeline(state.video.currentTime);
|
|
updateSkipButton(state.video.currentTime);
|
|
});
|
|
|
|
// track episode transitions from external links
|
|
container.addEventListener('click', e => {
|
|
const anchor = (e.target as Node).parentElement?.closest('a[href]');
|
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
|
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean);
|
|
if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) {
|
|
markEpisodeTransition(Number.parseInt(parts[2], 10));
|
|
}
|
|
});
|
|
|
|
state.video.addEventListener('click', showControls);
|
|
|
|
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
|
|
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
|
let searchDebounce: number | undefined;
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(searchDebounce);
|
|
// debounce to avoid excessive range switches while typing
|
|
searchDebounce = window.setTimeout(() => {
|
|
const val = searchInput.value.replace(/\D/g, '');
|
|
if (!val) {
|
|
// clear: jump to current episode range
|
|
const cur = Number.parseInt(state.currentEpisode, 10);
|
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
|
updateEpisodeHighlight(cur);
|
|
return;
|
|
}
|
|
const ep = Number.parseInt(val, 10);
|
|
if (!ep || ep <= 0) return;
|
|
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
|
const clamped = Math.min(ep, maxEp);
|
|
searchInput.value = String(clamped);
|
|
if (state.episodeGrid) {
|
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
|
updateEpisodeHighlight(clamped);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
// range buttons (100s of episodes)
|
|
if (dropdown) {
|
|
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
|
switchEpisodeRange(idx);
|
|
});
|
|
});
|
|
}
|
|
|
|
// initial range for large episode lists
|
|
if (state.episodeGrid && state.totalEpisodes > 100) {
|
|
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100));
|
|
}
|
|
|
|
setupThumbnails();
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', initPlayer);
|
|
document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
|
if (target?.querySelector('[data-video-player]')) initPlayer();
|
|
});
|