|
|
|
@@ -166,88 +166,128 @@ const initPlayer = (): void => {
|
|
|
|
onLoadedMetadata();
|
|
|
|
onLoadedMetadata();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state.video.addEventListener('waiting', () => {
|
|
|
|
state.video.addEventListener(
|
|
|
|
if (loading) {
|
|
|
|
'waiting',
|
|
|
|
loading.style.display = 'flex';
|
|
|
|
() => {
|
|
|
|
}
|
|
|
|
if (loading) {
|
|
|
|
}, { signal });
|
|
|
|
loading.style.display = 'flex';
|
|
|
|
state.video.addEventListener('playing', () => {
|
|
|
|
}
|
|
|
|
if (loading) {
|
|
|
|
},
|
|
|
|
loading.style.display = 'none';
|
|
|
|
{ signal }
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}, { signal });
|
|
|
|
state.video.addEventListener(
|
|
|
|
|
|
|
|
'playing',
|
|
|
|
|
|
|
|
() => {
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
|
|
loading.style.display = 'none';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
// update progress bar during buffering
|
|
|
|
// update progress bar during buffering
|
|
|
|
state.video.addEventListener('progress', () => {
|
|
|
|
state.video.addEventListener(
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
'progress',
|
|
|
|
}, { signal });
|
|
|
|
() => {
|
|
|
|
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// main loop: update progress, subtitles, skip buttons
|
|
|
|
// main loop: update progress, subtitles, skip buttons
|
|
|
|
state.video.addEventListener('timeupdate', () => {
|
|
|
|
state.video.addEventListener(
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
'timeupdate',
|
|
|
|
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
|
|
|
() => {
|
|
|
|
updateSkipButton(state.video.currentTime);
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
}, { signal });
|
|
|
|
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
|
|
|
|
|
|
|
updateSkipButton(state.video.currentTime);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
state.video.addEventListener('ended', () => {
|
|
|
|
state.video.addEventListener(
|
|
|
|
goToNextEpisode();
|
|
|
|
'ended',
|
|
|
|
}, { signal });
|
|
|
|
() => {
|
|
|
|
|
|
|
|
goToNextEpisode();
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ 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('pointerdown', e => {
|
|
|
|
progressWrap?.addEventListener(
|
|
|
|
// ignore right/middle click
|
|
|
|
'pointerdown',
|
|
|
|
if ('button' in e && e.button !== 0) return;
|
|
|
|
e => {
|
|
|
|
state.isScrubbing = true;
|
|
|
|
// ignore right/middle click
|
|
|
|
try {
|
|
|
|
if ('button' in e && e.button !== 0) return;
|
|
|
|
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
|
|
|
state.isScrubbing = true;
|
|
|
|
} catch {}
|
|
|
|
try {
|
|
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
|
|
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
|
|
|
state.video.currentTime = absoluteTimeFromRatio(
|
|
|
|
} catch {}
|
|
|
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
|
|
);
|
|
|
|
state.video.currentTime = absoluteTimeFromRatio(
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
|
|
updateSkipButton(state.video.currentTime);
|
|
|
|
);
|
|
|
|
showControls();
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
}, { signal });
|
|
|
|
updateSkipButton(state.video.currentTime);
|
|
|
|
|
|
|
|
showControls();
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// hover to preview time
|
|
|
|
// hover to preview time
|
|
|
|
progressWrap?.addEventListener('pointermove', e => {
|
|
|
|
progressWrap?.addEventListener(
|
|
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
|
|
'pointermove',
|
|
|
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
|
|
|
e => {
|
|
|
|
}, { signal });
|
|
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
|
|
|
|
|
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal });
|
|
|
|
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal });
|
|
|
|
progressWrap?.addEventListener('pointerup', () => {
|
|
|
|
progressWrap?.addEventListener(
|
|
|
|
// ensure we finish the seek even if no window mousemove fired
|
|
|
|
'pointerup',
|
|
|
|
if (!progressWrap) return;
|
|
|
|
() => {
|
|
|
|
state.isScrubbing = false;
|
|
|
|
// ensure we finish the seek even if no window mousemove fired
|
|
|
|
}, { signal });
|
|
|
|
if (!progressWrap) return;
|
|
|
|
|
|
|
|
state.isScrubbing = false;
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// dragging outside progress bar while scrubbing
|
|
|
|
// dragging outside progress bar while scrubbing
|
|
|
|
window.addEventListener('pointermove', e => {
|
|
|
|
window.addEventListener(
|
|
|
|
if (!state.isScrubbing || !progressWrap) return;
|
|
|
|
'pointermove',
|
|
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
|
|
e => {
|
|
|
|
state.video.currentTime = absoluteTimeFromRatio(
|
|
|
|
if (!state.isScrubbing || !progressWrap) return;
|
|
|
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
|
|
);
|
|
|
|
state.video.currentTime = absoluteTimeFromRatio(
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
|
|
updateSkipButton(state.video.currentTime);
|
|
|
|
);
|
|
|
|
}, { signal });
|
|
|
|
updateTimeline(state.video.currentTime);
|
|
|
|
|
|
|
|
updateSkipButton(state.video.currentTime);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ 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('click', e => {
|
|
|
|
document.addEventListener(
|
|
|
|
const target = e.target;
|
|
|
|
'click',
|
|
|
|
if (!(target instanceof Element)) return;
|
|
|
|
e => {
|
|
|
|
const anchor = target.closest('a[href]');
|
|
|
|
const target = e.target;
|
|
|
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
|
|
|
if (!(target instanceof Element)) return;
|
|
|
|
const url = new URL(anchor.href, location.origin);
|
|
|
|
const anchor = target.closest('a[href]');
|
|
|
|
if (url.origin !== location.origin) return;
|
|
|
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
|
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
|
|
const url = new URL(anchor.href, location.origin);
|
|
|
|
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
|
|
|
|
if (url.origin !== location.origin) return;
|
|
|
|
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
|
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
|
|
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
|
|
|
|
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
|
|
|
|
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
|
|
|
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
|
|
|
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
|
|
|
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
|
|
|
|
}, { signal });
|
|
|
|
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
|
|
|
|
|
|
|
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
state.video.addEventListener('click', showControls, { signal });
|
|
|
|
state.video.addEventListener('click', showControls, { signal });
|
|
|
|
|
|
|
|
|
|
|
|
@@ -256,40 +296,48 @@ const initPlayer = (): void => {
|
|
|
|
let searchDebounce: number | undefined;
|
|
|
|
let searchDebounce: number | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
if (searchInput) {
|
|
|
|
if (searchInput) {
|
|
|
|
searchInput.addEventListener('input', () => {
|
|
|
|
searchInput.addEventListener(
|
|
|
|
clearTimeout(searchDebounce);
|
|
|
|
'input',
|
|
|
|
// debounce to avoid excessive range switches while typing
|
|
|
|
() => {
|
|
|
|
searchDebounce = window.setTimeout(() => {
|
|
|
|
clearTimeout(searchDebounce);
|
|
|
|
const val = searchInput.value.replace(/\D/g, '');
|
|
|
|
// debounce to avoid excessive range switches while typing
|
|
|
|
if (!val) {
|
|
|
|
searchDebounce = window.setTimeout(() => {
|
|
|
|
// clear: jump to current episode range
|
|
|
|
const val = searchInput.value.replace(/\D/g, '');
|
|
|
|
const cur = Number.parseInt(state.currentEpisode, 10);
|
|
|
|
if (!val) {
|
|
|
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
|
|
|
// clear: jump to current episode range
|
|
|
|
updateEpisodeHighlight(cur);
|
|
|
|
const cur = Number.parseInt(state.currentEpisode, 10);
|
|
|
|
return;
|
|
|
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
|
|
|
}
|
|
|
|
updateEpisodeHighlight(cur);
|
|
|
|
const ep = Number.parseInt(val, 10);
|
|
|
|
return;
|
|
|
|
if (!ep || ep <= 0) return;
|
|
|
|
}
|
|
|
|
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
|
|
|
const ep = Number.parseInt(val, 10);
|
|
|
|
const clamped = Math.min(ep, maxEp);
|
|
|
|
if (!ep || ep <= 0) return;
|
|
|
|
searchInput.value = String(clamped);
|
|
|
|
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
|
|
|
if (state.episodeGrid) {
|
|
|
|
const clamped = Math.min(ep, maxEp);
|
|
|
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
|
|
|
searchInput.value = String(clamped);
|
|
|
|
updateEpisodeHighlight(clamped);
|
|
|
|
if (state.episodeGrid) {
|
|
|
|
}
|
|
|
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
|
|
|
}, 300);
|
|
|
|
updateEpisodeHighlight(clamped);
|
|
|
|
}, { signal });
|
|
|
|
}
|
|
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ 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('click', () => {
|
|
|
|
btn.addEventListener(
|
|
|
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
|
|
|
'click',
|
|
|
|
switchEpisodeRange(idx);
|
|
|
|
() => {
|
|
|
|
const dd = btn.closest('ui-dropdown');
|
|
|
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
|
|
|
if (isClosableDropdown(dd)) dd.close();
|
|
|
|
switchEpisodeRange(idx);
|
|
|
|
}, { signal });
|
|
|
|
const dd = btn.closest('ui-dropdown');
|
|
|
|
|
|
|
|
if (isClosableDropdown(dd)) dd.close();
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ signal }
|
|
|
|
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|