feat: add prettier and eslint with pre-commit hook

This commit is contained in:
2026-05-10 19:23:53 +02:00
parent be9fbe0f64
commit 3703bbfcfe
33 changed files with 1643 additions and 1245 deletions

View File

@@ -1,95 +1,98 @@
import { state } from './state'
import { state } from './state';
export const formatTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
export const showControls = (): void => {
state.container.classList.add('show-controls')
window.clearTimeout(state.playerControlsTimeout)
state.container.classList.add('show-controls');
window.clearTimeout(state.playerControlsTimeout);
state.playerControlsTimeout = window.setTimeout(() => {
if (!state.isScrubbing && !state.video.paused) {
state.container.classList.remove('show-controls')
state.container.classList.remove('show-controls');
}
}, 2000)
}
}, 2000);
};
export const seekBy = (delta: number): void => {
if (state.video.duration <= 0) return
state.video.currentTime = Math.max(0, Math.min(state.video.duration, state.video.currentTime + delta))
showControls()
}
if (state.video.duration <= 0) return;
state.video.currentTime = Math.max(
0,
Math.min(state.video.duration, state.video.currentTime + delta)
);
showControls();
};
export const togglePlayPause = (): void => {
if (state.video.paused) {
state.video.play()
state.video.play();
} else {
state.video.pause()
state.video.pause();
}
}
};
export const toggleMute = (): void => {
if (state.video.muted || state.video.volume === 0) {
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1
state.video.muted = false
state.video.volume = restored
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1;
state.video.muted = false;
state.video.volume = restored;
} else {
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume
state.video.muted = true
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume;
state.video.muted = true;
}
}
};
export const setVolume = (value: number): void => {
state.video.volume = Math.max(0, Math.min(1, value))
state.video.muted = value === 0
if (value > 0) state.lastKnownVolume = value
}
state.video.volume = Math.max(0, Math.min(1, value));
state.video.muted = value === 0;
if (value > 0) state.lastKnownVolume = value;
};
export const toggleFullscreen = (): void => {
if (document.fullscreenElement) {
document.exitFullscreen()
return
document.exitFullscreen();
return;
}
state.container.requestFullscreen?.()
}
state.container.requestFullscreen?.();
};
export const syncVolumeUI = (): void => {
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls()
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100)
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls();
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100);
if (volumeRange) {
volumeRange.value = String(value)
volumeRange.style.setProperty('--volume-percent', `${value}%`)
volumeRange.value = String(value);
volumeRange.style.setProperty('--volume-percent', `${value}%`);
}
if (volumeUnderline) volumeUnderline.style.height = `${value}%`
updateMuteIcons(state.video.muted || state.video.volume === 0)
}
if (volumeUnderline) volumeUnderline.style.height = `${value}%`;
updateMuteIcons(state.video.muted || state.video.volume === 0);
};
interface Controls {
playPause: HTMLButtonElement | null
muteBtn: HTMLButtonElement | null
volumePanel: HTMLElement | null
volumeRange: HTMLInputElement | null
volumeUnderline: HTMLElement | null
backwardBtn: HTMLButtonElement | null
forwardBtn: HTMLButtonElement | null
fullscreenBtn: HTMLButtonElement | null
iconPlay: SVGElement | null
iconPause: SVGElement | null
iconVolume: SVGElement | null
iconMuted: SVGElement | null
skipSegmentBtn: HTMLButtonElement | null
subtitleText: HTMLElement | null
autoplayBtn: HTMLInputElement | null
playPause: HTMLButtonElement | null;
muteBtn: HTMLButtonElement | null;
volumePanel: HTMLElement | null;
volumeRange: HTMLInputElement | null;
volumeUnderline: HTMLElement | null;
backwardBtn: HTMLButtonElement | null;
forwardBtn: HTMLButtonElement | null;
fullscreenBtn: HTMLButtonElement | null;
iconPlay: SVGElement | null;
iconPause: SVGElement | null;
iconVolume: SVGElement | null;
iconMuted: SVGElement | null;
skipSegmentBtn: HTMLButtonElement | null;
subtitleText: HTMLElement | null;
autoplayBtn: HTMLInputElement | null;
}
let controlsCache: Controls | null = null
let controlsCache: Controls | null = null;
const getControls = (): Controls => {
if (controlsCache) return controlsCache
const c = state.container
if (controlsCache) return controlsCache;
const c = state.container;
controlsCache = {
playPause: c.querySelector('[data-play-pause]'),
muteBtn: c.querySelector('[data-mute]'),
@@ -106,64 +109,88 @@ const getControls = (): Controls => {
skipSegmentBtn: c.querySelector('[data-skip]'),
subtitleText: c.querySelector('[data-subtitle-text]'),
autoplayBtn: document.querySelector('[data-autoplay]'),
}
return controlsCache
}
};
return controlsCache;
};
const updatePlayPauseIcons = (isPlaying: boolean): void => {
const { iconPlay, iconPause } = getControls()
iconPlay?.classList.toggle('hidden', isPlaying)
iconPause?.classList.toggle('hidden', !isPlaying)
}
const { iconPlay, iconPause } = getControls();
iconPlay?.classList.toggle('hidden', isPlaying);
iconPause?.classList.toggle('hidden', !isPlaying);
};
const updateMuteIcons = (isMuted: boolean): void => {
const { iconVolume, iconMuted } = getControls()
iconVolume?.classList.toggle('hidden', isMuted)
iconMuted?.classList.toggle('hidden', !isMuted)
}
const { iconVolume, iconMuted } = getControls();
iconVolume?.classList.toggle('hidden', isMuted);
iconMuted?.classList.toggle('hidden', !isMuted);
};
export const setupControls = (): void => {
const {
playPause, muteBtn, volumePanel, volumeRange,
backwardBtn, forwardBtn, fullscreenBtn, skipSegmentBtn,
} = getControls()
playPause,
muteBtn,
volumePanel,
volumeRange,
backwardBtn,
forwardBtn,
fullscreenBtn,
skipSegmentBtn,
} = getControls();
playPause?.addEventListener('click', () => { togglePlayPause(); showControls() })
state.video.addEventListener('click', () => { togglePlayPause(); showControls() })
playPause?.addEventListener('click', () => {
togglePlayPause();
showControls();
});
state.video.addEventListener('click', () => {
togglePlayPause();
showControls();
});
muteBtn?.addEventListener('click', () => { toggleMute(); showControls() })
muteBtn?.addEventListener('click', () => {
toggleMute();
showControls();
});
volumeRange?.addEventListener('input', () => {
const value = Number(volumeRange.value) / 100
setVolume(value)
showControls()
})
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'))
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'))
const value = Number(volumeRange.value) / 100;
setVolume(value);
showControls();
});
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'));
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'));
backwardBtn?.addEventListener('click', () => seekBy(-10))
forwardBtn?.addEventListener('click', () => seekBy(10))
backwardBtn?.addEventListener('click', () => seekBy(-10));
forwardBtn?.addEventListener('click', () => seekBy(10));
fullscreenBtn?.addEventListener('click', () => { toggleFullscreen(); showControls() })
fullscreenBtn?.addEventListener('click', () => {
toggleFullscreen();
showControls();
});
skipSegmentBtn?.addEventListener('click', () => {
if (!state.activeSkipSegment) return
state.video.currentTime = state.activeSkipSegment.end + 0.01
showControls()
})
if (!state.activeSkipSegment) return;
state.video.currentTime = state.activeSkipSegment.end + 0.01;
showControls();
});
document.addEventListener('fullscreenchange', () => {
state.isFullscreen = !!document.fullscreenElement
state.container.classList.toggle('fullscreen', state.isFullscreen)
if (state.isFullscreen) showControls()
})
state.isFullscreen = !!document.fullscreenElement;
state.container.classList.toggle('fullscreen', state.isFullscreen);
if (state.isFullscreen) showControls();
});
state.video.addEventListener('play', () => { updatePlayPauseIcons(true); showControls() })
state.video.addEventListener('pause', () => { updatePlayPauseIcons(false); showControls() })
state.video.addEventListener('volumechange', syncVolumeUI)
state.video.addEventListener('play', () => {
updatePlayPauseIcons(true);
showControls();
});
state.video.addEventListener('pause', () => {
updatePlayPauseIcons(false);
showControls();
});
state.video.addEventListener('volumechange', syncVolumeUI);
state.container.addEventListener('mousemove', showControls)
state.container.addEventListener('mousemove', showControls);
updatePlayPauseIcons(false)
syncVolumeUI()
}
updatePlayPauseIcons(false);
syncVolumeUI();
};

View File

@@ -1,9 +1,9 @@
import DOMPurify from 'dompurify'
import { state } from '../state'
import DOMPurify from 'dompurify';
import { state } from '../state';
export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (state.completionSent || !state.malID || !episodeNumber) return
state.completionSent = true
if (state.completionSent || !state.malID || !episodeNumber) return;
state.completionSent = true;
try {
const res = await fetch('/api/watch-complete', {
@@ -11,27 +11,27 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
})
});
if (!res.ok) {
state.completionSent = false
state.completionSent = false;
if (state.completionAttempts < 2) {
state.completionAttempts++
setTimeout(() => completeAnime(episodeNumber), 1000)
state.completionAttempts++;
setTimeout(() => completeAnime(episodeNumber), 1000);
}
return
return;
}
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
if (trigger) {
trigger.textContent = 'Completed '
const caret = document.createElement('span')
caret.className = 'text-xs'
caret.textContent = '▾'
trigger.appendChild(caret)
trigger.textContent = 'Completed ';
const caret = document.createElement('span');
caret.className = 'text-xs';
caret.textContent = '▾';
trigger.appendChild(caret);
}
const dropdown = document.getElementById('watch-status-dropdown')
const dropdown = document.getElementById('watch-status-dropdown');
if (dropdown) {
const payload = {
anime_id: String(state.malID),
@@ -41,27 +41,29 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
anime_image: state.container.dataset.animeImage ?? '',
status: 'completed',
airing: state.container.dataset.animeAiring === 'true',
}
};
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
credentials: 'same-origin',
}).then(async res => {
if (!res.ok) return
const html = await res.text()
const wrapper = document.createElement('span')
wrapper.id = 'watch-status-dropdown'
wrapper.innerHTML = DOMPurify.sanitize(html)
dropdown.replaceWith(wrapper)
}).catch(() => {})
})
.then(async res => {
if (!res.ok) return;
const html = await res.text();
const wrapper = document.createElement('span');
wrapper.id = 'watch-status-dropdown';
wrapper.innerHTML = DOMPurify.sanitize(html);
dropdown.replaceWith(wrapper);
})
.catch(() => {});
}
} catch {
state.completionSent = false
state.completionSent = false;
if (state.completionAttempts < 2) {
state.completionAttempts++
setTimeout(() => completeAnime(episodeNumber), 1000)
state.completionAttempts++;
setTimeout(() => completeAnime(episodeNumber), 1000);
}
}
}
};

View File

@@ -1,95 +1,97 @@
import { state } from '../state'
import { SkipSegment } from '../types'
import { displayTimeFromAbsolute } from '../timeline'
import { resolveActiveSegments, renderSegments } from '../skip/segments'
import { updateSubtitleOptions } from '../subtitles'
import { updateQualityOptions } from '../quality'
import { updateModeButtons } from '../mode'
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui'
import { markEpisodeTransition } from '../progress'
import { state } from '../state';
import { SkipSegment } from '../types';
import { displayTimeFromAbsolute } from '../timeline';
import { resolveActiveSegments, renderSegments } from '../skip/segments';
import { updateSubtitleOptions } from '../subtitles';
import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode';
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui';
import { markEpisodeTransition } from '../progress';
export const goToNextEpisode = async (): Promise<void> => {
const currentEp = Number.parseInt(state.currentEpisode, 10)
if (!currentEp) return
const currentEp = Number.parseInt(state.currentEpisode, 10);
if (!currentEp) return;
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
import('./complete').then(m => m.completeAnime(currentEp))
return
import('./complete').then(m => m.completeAnime(currentEp));
return;
}
if (!isAutoplayEnabled()) return
if (!isAutoplayEnabled()) return;
const nextEp = currentEp + 1
markEpisodeTransition(nextEp)
const nextEp = currentEp + 1;
markEpisodeTransition(nextEp);
try {
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`)
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`);
if (!res.ok) {
sessionStorage.setItem('mal:autoplay-next', 'true')
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
window.location.href = url.toString()
return
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
return;
}
const data = await res.json()
const data = await res.json();
state.modeSources = data.mode_sources ?? {}
state.availableModes = data.available_modes ?? []
state.modeSources = data.mode_sources ?? {};
state.availableModes = data.available_modes ?? [];
const fallback = state.availableModes.find(m => state.modeSources[m]?.token)
const fallback = state.availableModes.find(m => state.modeSources[m]?.token);
if (!fallback) {
sessionStorage.setItem('mal:autoplay-next', 'true')
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
window.location.href = url.toString()
return
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
return;
}
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`
state.video.load()
if (!state.video.paused) state.video.play().catch(() => {})
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`;
state.video.load();
if (!state.video.paused) state.video.play().catch(() => {});
state.currentEpisode = String(nextEp)
state.pendingSeekTime = null
state.completionSent = false
state.completionAttempts = 0
state.activeSubtitles = []
state.currentEpisode = String(nextEp);
state.pendingSeekTime = null;
state.completionSent = false;
state.completionAttempts = 0;
state.activeSubtitles = [];
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons()
updateOverlay(state.currentEpisode, data.episode_title ?? '')
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
updateOverlay(state.currentEpisode, data.episode_title ?? '');
if (data.segments?.length) {
state.parsedSegments = data.segments
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
.filter((s: SkipSegment) => s.end > s.start)
resolveActiveSegments()
renderSegments()
.filter((s: SkipSegment) => s.end > s.start);
resolveActiveSegments();
renderSegments();
}
state.episodeList?.querySelectorAll('[data-episode-id]').forEach(el => el.classList.remove('bg-accent/20'))
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`)
newListEl?.classList.add('bg-accent/20')
state.episodeList
?.querySelectorAll('[data-episode-id]')
.forEach(el => el.classList.remove('bg-accent/20'));
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`);
newListEl?.classList.add('bg-accent/20');
if (state.episodeGrid) {
state.episodeGrid.querySelectorAll('[data-episode-id]').forEach(el => {
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
})
switchEpisodeRange(Math.floor((nextEp - 1) / 100))
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`)
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
});
switchEpisodeRange(Math.floor((nextEp - 1) / 100));
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
}
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
history.pushState(null, '', url.toString())
state.transitionEpisode = null
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
history.pushState(null, '', url.toString());
state.transitionEpisode = null;
} catch {
sessionStorage.setItem('mal:autoplay-next', 'true')
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
window.location.href = url.toString()
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
}
}
};

View File

@@ -1,35 +1,38 @@
import { state } from '../state'
import { state } from '../state';
export const setupThumbnails = (): void => {
fetch(`/api/watch/thumbnails/${state.malID}`)
.then(res => res.json())
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
if (!state.episodeList) return
if (!state.episodeList) return;
data.forEach(item => {
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`)
if (!card) return
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
if (!card) return;
if (item.url) {
const imgContainer = card.querySelector('.relative.aspect-video')
const imgContainer = card.querySelector('.relative.aspect-video');
if (imgContainer) {
let img = imgContainer.querySelector('img')
let img = imgContainer.querySelector('img');
if (!img) {
img = document.createElement('img')
img.className = 'h-full w-full object-cover transition-transform group-hover:scale-105'
img.loading = 'lazy'
imgContainer.querySelector('.flex.h-full.w-full.items-center.justify-center')?.remove()
imgContainer.prepend(img)
img = document.createElement('img');
img.className =
'h-full w-full object-cover transition-transform group-hover:scale-105';
img.loading = 'lazy';
imgContainer
.querySelector('.flex.h-full.w-full.items-center.justify-center')
?.remove();
imgContainer.prepend(img);
}
img.src = item.url
img.alt = item.title ?? `Episode ${item.mal_id}`
img.src = item.url;
img.alt = item.title ?? `Episode ${item.mal_id}`;
}
}
if (item.title) {
const titleEl = card.querySelector('[data-episode-title]')
if (titleEl) titleEl.textContent = item.title
const titleEl = card.querySelector('[data-episode-title]');
if (titleEl) titleEl.textContent = item.title;
}
})
});
})
.catch(err => console.error('Failed to fetch thumbnails:', err))
}
.catch(err => console.error('Failed to fetch thumbnails:', err));
};

View File

@@ -1,57 +1,61 @@
import { state } from '../state'
import { updateSubtitleOptions } from '../subtitles'
import { updateQualityOptions } from '../quality'
import { updateModeButtons } from '../mode'
import { state } from '../state';
import { updateSubtitleOptions } from '../subtitles';
import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode';
export const setupAutoplayButton = (): void => {
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
if (!btn) return
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'
}
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
if (!btn) return;
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false';
};
export const isAutoplayEnabled = (): boolean => localStorage.getItem('mal:autoplay-enabled') !== 'false'
export const isAutoplayEnabled = (): boolean =>
localStorage.getItem('mal:autoplay-enabled') !== 'false';
export const updateOverlay = (episode: string, title: string): void => {
if (!state.videoOverlay) return
const p = state.videoOverlay.querySelector('p')
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`)
}
if (!state.videoOverlay) return;
const p = state.videoOverlay.querySelector('p');
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`);
};
const getEpisodeEls = () => {
const grid = state.episodeGrid
const list = state.episodeList
const grid = state.episodeGrid;
const list = state.episodeList;
return {
gridEls: grid ? Array.from(grid.querySelectorAll('[data-episode-id]')) : [],
listEls: list ? Array.from(list.querySelectorAll('[data-episode-id]')) : [],
}
}
};
};
export const updateEpisodeHighlight = (num: number): void => {
const { gridEls, listEls } = getEpisodeEls()
;[...gridEls, ...listEls].forEach(el => el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent'))
const { gridEls, listEls } = getEpisodeEls();
[...gridEls, ...listEls].forEach(el =>
el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent')
);
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`)
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`)
gridEl?.classList.add('ring-2', 'ring-accent')
listEl?.classList.add('ring-2', 'ring-accent')
;(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`);
gridEl?.classList.add('ring-2', 'ring-accent');
listEl?.classList.add('ring-2', 'ring-accent');
(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
export const switchEpisodeRange = (idx: number): void => {
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null
if (!dropdown) return
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[]
const target = btns[idx]
if (!target) return
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
if (!dropdown) return;
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
const target = btns[idx];
if (!target) return;
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10)
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10)
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10);
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10);
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null
if (label) label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null;
if (label)
label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`;
state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => {
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10)
el.classList.toggle('hidden', n < start || n > end)
})
}
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10);
el.classList.toggle('hidden', n < start || n > end);
});
};

View File

@@ -1,58 +1,72 @@
import { state } from './state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay, absoluteTimeFromRatio, getBounds } from './timeline'
import { showControls, toggleMute, togglePlayPause, toggleFullscreen, seekBy, setVolume, formatTime } from './controls'
import { state } from './state';
import {
displayTimeFromAbsolute,
absoluteTimeFromDisplay,
absoluteTimeFromRatio,
getBounds,
} from './timeline';
import {
showControls,
toggleMute,
togglePlayPause,
toggleFullscreen,
seekBy,
setVolume,
formatTime,
} from './controls';
export const setupKeyboard = (): void => {
document.addEventListener('keydown', (e) => {
const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
document.addEventListener('keydown', e => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
return;
switch (e.code) {
case 'Space':
case 'KeyK':
e.preventDefault()
togglePlayPause()
showControls()
break
e.preventDefault();
togglePlayPause();
showControls();
break;
case 'ArrowLeft':
case 'KeyJ':
e.preventDefault()
seekBy(-10)
break
e.preventDefault();
seekBy(-10);
break;
case 'ArrowRight':
case 'KeyL':
e.preventDefault()
seekBy(10)
break
e.preventDefault();
seekBy(10);
break;
case 'ArrowUp':
e.preventDefault()
setVolume(state.video.volume + 0.05)
showControls()
break
e.preventDefault();
setVolume(state.video.volume + 0.05);
showControls();
break;
case 'ArrowDown':
e.preventDefault()
setVolume(state.video.volume - 0.05)
showControls()
break
e.preventDefault();
setVolume(state.video.volume - 0.05);
showControls();
break;
case 'KeyM':
e.preventDefault()
toggleMute()
showControls()
break
e.preventDefault();
toggleMute();
showControls();
break;
case 'KeyF':
e.preventDefault()
toggleFullscreen()
showControls()
break
e.preventDefault();
toggleFullscreen();
showControls();
break;
default:
if (/^\d$/.test(e.key)) {
const b = getBounds()
const b = getBounds();
if (b.duration > 0) {
e.preventDefault()
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10)
showControls()
e.preventDefault();
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
showControls();
}
}
}
})
}
});
};

View File

@@ -1,193 +1,214 @@
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'
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
let initialized = false;
const hidePreviewPopover = (): void => {
state.previewPopover?.classList.remove('block')
state.previewPopover?.classList.add('hidden')
state.previewPopover!.style.left = '0px'
}
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')
}
state.previewPopover?.classList.remove('hidden');
state.previewPopover?.classList.add('block');
};
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 }
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)))
const barWidth = progressWrap.clientWidth
if (barWidth <= 0) { hidePreviewPopover(); return }
showPreviewPopover()
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
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'
const streamToken = state.modeSources[state.currentMode]?.token
if (streamToken) {
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`
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;
}
setupProgress()
setupControls()
setupKeyboard()
setupSkip()
setupSubtitles()
setupQuality()
setupMode()
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)));
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons()
setupAutoplayButton()
updateAutoSkipButton()
showControls()
const barWidth = progressWrap.clientWidth;
if (barWidth <= 0) {
hidePreviewPopover();
return;
}
showPreviewPopover();
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;
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
const streamToken = state.modeSources[state.currentMode]?.token;
if (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();
state.video.addEventListener('loadedmetadata', () => {
loading && (loading.style.display = 'none')
invalidateBounds()
loading && (loading.style.display = 'none');
invalidateBounds();
resolveActiveSegments()
renderSegments()
resolveActiveSegments();
renderSegments();
const startTime = Number(container.dataset.startTimeSeconds ?? '0')
const startTime = Number(container.dataset.startTimeSeconds ?? '0');
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
state.video.currentTime = startTime
state.video.currentTime = startTime;
}
if (state.pendingSeekTime !== null) {
state.video.currentTime = state.pendingSeekTime
state.pendingSeekTime = null
state.video.currentTime = state.pendingSeekTime;
state.pendingSeekTime = null;
}
if (state.shouldAutoPlay) state.video.play().catch(() => {})
if (state.shouldAutoPlay) state.video.play().catch(() => {});
updateTimeline(state.video.currentTime)
updateSkipButton(state.video.currentTime)
})
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
});
state.video.addEventListener('waiting', () => { loading && (loading.style.display = 'flex') })
state.video.addEventListener('playing', () => { loading && (loading.style.display = 'none') })
state.video.addEventListener('progress', () => { updateTimeline(state.video.currentTime) })
state.video.addEventListener('waiting', () => {
loading && (loading.style.display = 'flex');
});
state.video.addEventListener('playing', () => {
loading && (loading.style.display = 'none');
});
state.video.addEventListener('progress', () => {
updateTimeline(state.video.currentTime);
});
state.video.addEventListener('timeupdate', () => {
updateTimeline(state.video.currentTime)
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime))
updateSkipButton(state.video.currentTime)
})
updateTimeline(state.video.currentTime);
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
updateSkipButton(state.video.currentTime);
});
state.video.addEventListener('ended', () => { goToNextEpisode() })
state.video.addEventListener('ended', () => {
goToNextEpisode();
});
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()
})
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();
});
progressWrap?.addEventListener('mousemove', (e) => {
const rect = progressWrap.getBoundingClientRect()
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
})
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)
progressWrap?.addEventListener('mouseleave', hidePreviewPopover);
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)
})
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);
});
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)
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))
markEpisodeTransition(Number.parseInt(parts[2], 10));
}
})
});
state.video.addEventListener('click', showControls)
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
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)
clearTimeout(searchDebounce);
searchDebounce = window.setTimeout(() => {
const val = searchInput.value.replace(/\D/g, '')
const val = searchInput.value.replace(/\D/g, '');
if (!val) {
const cur = Number.parseInt(state.currentEpisode, 10)
switchEpisodeRange(Math.floor((cur - 1) / 100))
updateEpisodeHighlight(cur)
return
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)
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)
switchEpisodeRange(Math.floor((clamped - 1) / 100));
updateEpisodeHighlight(clamped);
}
}, 300)
})
}, 300);
});
}
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)
})
})
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
switchEpisodeRange(idx);
});
});
}
if (state.episodeGrid && state.totalEpisodes > 100) {
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100))
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100));
}
setupThumbnails()
}
setupThumbnails();
};
document.addEventListener('DOMContentLoaded', initPlayer)
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()
})
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
if (target?.querySelector('[data-video-player]')) initPlayer();
});

View File

@@ -1,66 +1,79 @@
import { state } from './state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
import { showControls } from './controls'
import { updateSubtitleOptions } from './subtitles'
import { updateQualityOptions } from './quality'
import { ModeSource } from './types'
import { state } from './state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
import { showControls } from './controls';
import { updateSubtitleOptions } from './subtitles';
import { updateQualityOptions } from './quality';
import { ModeSource } from './types';
const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.modeSources[mode]
if (!src?.token) return ''
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
return url
}
const src = state.modeSources[mode];
if (!src?.token) return '';
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
return url;
};
const loadVideo = (url: string): void => {
if (!url) return
const wasPlaying = !state.video.paused
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
state.video.src = url
state.video.load()
state.pendingSeekTime = prevTime
if (wasPlaying) state.video.play().catch(() => {})
}
if (!url) return;
const wasPlaying = !state.video.paused;
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
state.video.src = url;
state.video.load();
state.pendingSeekTime = prevTime;
if (wasPlaying) state.video.play().catch(() => {});
};
export const switchMode = (mode: string): void => {
if (!state.availableModes.includes(mode) || mode === state.currentMode) return
state.currentMode = mode
localStorage.setItem('player-audio-mode', mode)
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value))
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons()
}
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
state.currentMode = mode;
localStorage.setItem('player-audio-mode', mode);
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value));
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
};
export const updateModeButtons = (): void => {
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
const m = state.currentMode
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
const m = state.currentMode;
dub?.classList.toggle('text-accent', m === 'dub')
dub?.classList.toggle('text-white', m !== 'dub')
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'))
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'))
dub && (dub.disabled = !state.availableModes.includes('dub'))
dub?.classList.toggle('text-accent', m === 'dub');
dub?.classList.toggle('text-white', m !== 'dub');
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
dub && (dub.disabled = !state.availableModes.includes('dub'));
sub?.classList.toggle('text-accent', m === 'sub')
sub?.classList.toggle('text-white', m !== 'sub')
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'))
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'))
sub && (sub.disabled = !state.availableModes.includes('sub'))
}
sub?.classList.toggle('text-accent', m === 'sub');
sub?.classList.toggle('text-white', m !== 'sub');
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
sub && (sub.disabled = !state.availableModes.includes('sub'));
};
export const setupMode = (): void => {
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
dub?.addEventListener('click', () => { if (state.availableModes.includes('dub')) { switchMode('dub'); showControls() } })
sub?.addEventListener('click', () => { if (state.availableModes.includes('sub')) { switchMode('sub'); showControls() } })
dub?.addEventListener('click', () => {
if (state.availableModes.includes('dub')) {
switchMode('dub');
showControls();
}
});
sub?.addEventListener('click', () => {
if (state.availableModes.includes('sub')) {
switchMode('sub');
showControls();
}
});
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
autoplayBtn?.addEventListener('change', (e) => {
localStorage.setItem('mal:autoplay-enabled', (e.target as HTMLInputElement).checked ? 'true' : 'false')
showControls()
})
}
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
autoplayBtn?.addEventListener('change', e => {
localStorage.setItem(
'mal:autoplay-enabled',
(e.target as HTMLInputElement).checked ? 'true' : 'false'
);
showControls();
});
};

View File

@@ -1,70 +1,89 @@
import { state } from './state'
import { displayTimeFromAbsolute } from './timeline'
import { state } from './state';
import { displayTimeFromAbsolute } from './timeline';
const buildPayload = (episode: number, seconds: number) => JSON.stringify({
mal_id: state.malID,
episode,
time_seconds: seconds,
})
const buildPayload = (episode: number, seconds: number) =>
JSON.stringify({
mal_id: state.malID,
episode,
time_seconds: seconds,
});
const sendBeacon = (payload: string) => {
if (!navigator.sendBeacon) return false
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }))
return true
}
if (!navigator.sendBeacon) return false;
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }));
return true;
};
export const saveProgress = async (): Promise<void> => {
if (!state.malID || state.video.currentTime < 1) return
const episode = Number.parseInt(state.currentEpisode, 10)
if (!episode) return
if (!state.malID || state.video.currentTime < 1) return;
const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return;
const safeTime = displayTimeFromAbsolute(state.video.currentTime)
if (state.lastSavedProgress.episode === state.currentEpisode &&
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5) return
const safeTime = displayTimeFromAbsolute(state.video.currentTime);
if (
state.lastSavedProgress.episode === state.currentEpisode &&
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5
)
return;
const payload = buildPayload(episode, safeTime)
const payload = buildPayload(episode, safeTime);
try {
const res = await fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload })
if (!res.ok) return
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime }
const res = await fetch('/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
if (!res.ok) return;
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime };
} catch {}
}
};
const scheduleProgressSave = (): void => {
if (state.progressSaveTimer !== undefined) return
if (state.progressSaveTimer !== undefined) return;
state.progressSaveTimer = window.setTimeout(() => {
state.progressSaveTimer = undefined
saveProgress()
}, 30000)
}
state.progressSaveTimer = undefined;
saveProgress();
}, 30000);
};
export const markEpisodeTransition = (episodeNumber: number): void => {
if (!state.malID || !episodeNumber) return
if (state.progressSaveTimer !== undefined) { window.clearTimeout(state.progressSaveTimer); state.progressSaveTimer = undefined }
state.transitionEpisode = episodeNumber
const payload = buildPayload(episodeNumber, 0)
if (!sendBeacon(payload)) {
fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: payload }).catch(() => {})
if (!state.malID || !episodeNumber) return;
if (state.progressSaveTimer !== undefined) {
window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined;
}
}
state.transitionEpisode = episodeNumber;
const payload = buildPayload(episodeNumber, 0);
if (!sendBeacon(payload)) {
fetch('/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: payload,
}).catch(() => {});
}
};
export const setupProgress = (): void => {
state.video.addEventListener('timeupdate', () => {
scheduleProgressSave()
})
scheduleProgressSave();
});
state.video.addEventListener('pause', () => {
window.clearTimeout(state.progressSaveTimer)
state.progressSaveTimer = undefined
saveProgress()
})
window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined;
saveProgress();
});
window.addEventListener('mouseup', () => { state.isScrubbing = false; saveProgress() })
window.addEventListener('mouseup', () => {
state.isScrubbing = false;
saveProgress();
});
window.addEventListener('beforeunload', () => {
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return
const episode = Number.parseInt(state.currentEpisode, 10)
if (!episode) return
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)))
})
}
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return;
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
});
};

View File

@@ -1,59 +1,59 @@
import { state } from './state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
import { state } from './state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.modeSources[mode]
if (!src?.token) return ''
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
return url
}
const src = state.modeSources[mode];
if (!src?.token) return '';
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
return url;
};
const loadVideo = (url: string): void => {
if (!url) return
const wasPlaying = !state.video.paused
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
state.video.src = url
state.video.load()
state.pendingSeekTime = prevTime
if (wasPlaying) state.video.play().catch(() => {})
}
if (!url) return;
const wasPlaying = !state.video.paused;
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
state.video.src = url;
state.video.load();
state.pendingSeekTime = prevTime;
if (wasPlaying) state.video.play().catch(() => {});
};
export const switchQuality = (quality: string): void => {
const url = streamUrlForMode(state.currentMode, quality)
if (!url) return
localStorage.setItem('mal:preferred-quality', quality)
loadVideo(url)
}
const url = streamUrlForMode(state.currentMode, quality);
if (!url) return;
localStorage.setItem('mal:preferred-quality', quality);
loadVideo(url);
};
export const updateQualityOptions = (): void => {
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
if (!select) return
const qualities = state.modeSources[state.currentMode]?.qualities ?? []
select.innerHTML = ''
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
if (!select) return;
const qualities = state.modeSources[state.currentMode]?.qualities ?? [];
select.innerHTML = '';
const best = document.createElement('option')
best.value = 'best'
best.textContent = 'Auto / Best'
select.appendChild(best)
const best = document.createElement('option');
best.value = 'best';
best.textContent = 'Auto / Best';
select.appendChild(best);
qualities.forEach(q => {
const opt = document.createElement('option')
opt.value = q
opt.textContent = q
select.appendChild(opt)
})
const opt = document.createElement('option');
opt.value = q;
opt.textContent = q;
select.appendChild(opt);
});
const preferred = localStorage.getItem('mal:preferred-quality') || 'best'
select.value = qualities.includes(preferred) ? preferred : 'best'
const preferred = localStorage.getItem('mal:preferred-quality') || 'best';
select.value = qualities.includes(preferred) ? preferred : 'best';
const wrapper = select.parentElement
wrapper?.classList.toggle('hidden', qualities.length === 0)
}
const wrapper = select.parentElement;
wrapper?.classList.toggle('hidden', qualities.length === 0);
};
export const setupQuality = (): void => {
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
select?.addEventListener('change', (e) => {
switchQuality((e.target as HTMLSelectElement).value)
})
}
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
select?.addEventListener('change', e => {
switchQuality((e.target as HTMLSelectElement).value);
});
};

View File

@@ -1,50 +1,53 @@
import { state } from '../state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline'
import { showControls } from '../controls'
import { resolveActiveSegments, renderSegments } from './segments'
import { state } from '../state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
import { showControls } from '../controls';
import { resolveActiveSegments, renderSegments } from './segments';
const skipLabel = (type: string): string => type === 'ed' ? 'Skip outro' : 'Skip intro'
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
export const updateSkipButton = (currentTime: number): void => {
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null
const displayTime = displayTimeFromAbsolute(currentTime)
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null;
const displayTime = displayTimeFromAbsolute(currentTime);
const segment = state.activeSegments.find(s => {
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02))
return displayTime >= s.start + delay && displayTime < s.end
})
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02));
return displayTime >= s.start + delay && displayTime < s.end;
});
if (!segment) {
state.activeSkipSegment = null
btn?.classList.add('hidden')
return
state.activeSkipSegment = null;
btn?.classList.add('hidden');
return;
}
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true'
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true';
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01)
return
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
return;
}
state.activeSkipSegment = segment
state.activeSkipSegment = segment;
if (btn) {
btn.textContent = skipLabel(segment.type)
btn.title = skipLabel(segment.type)
btn.classList.remove('hidden')
btn.textContent = skipLabel(segment.type);
btn.title = skipLabel(segment.type);
btn.classList.remove('hidden');
}
}
};
export const updateAutoSkipButton = (): void => {
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true')
}
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true');
};
export const setupSkip = (): void => {
document.addEventListener('change', (e) => {
const target = e.target as HTMLElement
document.addEventListener('change', e => {
const target = e.target as HTMLElement;
if (target.hasAttribute('data-autoskip')) {
localStorage.setItem('mal:autoskip-enabled', (target as HTMLInputElement).checked ? 'true' : 'false')
showControls()
localStorage.setItem(
'mal:autoskip-enabled',
(target as HTMLInputElement).checked ? 'true' : 'false'
);
showControls();
}
})
}
});
};

View File

@@ -1,44 +1,46 @@
import { SkipSegment } from '../types'
import { state } from '../state'
import { SkipSegment } from '../types';
import { state } from '../state';
const MIN_SEGMENT_DURATION = 20
const MAX_SEGMENT_DURATION = 240
const MAX_INTRO_START = 180
const MIN_OUTRO_START_RATIO = 0.5
const MIN_SEGMENT_DURATION = 20;
const MAX_SEGMENT_DURATION = 240;
const MAX_INTRO_START = 180;
const MIN_OUTRO_START_RATIO = 0.5;
export const resolveActiveSegments = (): void => {
const bounds = state.video.duration
if (bounds <= 0) { state.activeSegments = []; return }
const bounds = state.video.duration;
if (bounds <= 0) {
state.activeSegments = [];
return;
}
state.activeSegments = state.parsedSegments
.filter(s => {
const len = s.end - s.start
if (len < MIN_SEGMENT_DURATION || len > MAX_SEGMENT_DURATION) return false
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false
state.activeSegments = state.parsedSegments.filter(s => {
const len = s.end - s.start;
if (len < MIN_SEGMENT_DURATION || len > MAX_SEGMENT_DURATION) return false;
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false;
if (s.type === 'op') {
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5
}
if (s.type === 'ed') {
return s.start >= bounds * MIN_OUTRO_START_RATIO
}
return false
})
}
if (s.type === 'op') {
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5;
}
if (s.type === 'ed') {
return s.start >= bounds * MIN_OUTRO_START_RATIO;
}
return false;
});
};
export const renderSegments = (): void => {
const track = state.container.querySelector('[data-segments]') as HTMLElement | null
if (!track) return
track.innerHTML = ''
const track = state.container.querySelector('[data-segments]') as HTMLElement | null;
if (!track) return;
track.innerHTML = '';
const bounds = state.video.duration
if (bounds <= 0) return
const bounds = state.video.duration;
if (bounds <= 0) return;
state.activeSegments.forEach(s => {
const bar = document.createElement('div')
bar.className = 'absolute top-0 h-full bg-white/80'
bar.style.left = `${(s.start / bounds) * 100}%`
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`
track.appendChild(bar)
})
}
const bar = document.createElement('div');
bar.className = 'absolute top-0 h-full bg-white/80';
bar.style.left = `${(s.start / bounds) * 100}%`;
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
track.appendChild(bar);
});
};

View File

@@ -1,43 +1,43 @@
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'
import { q, qs, dataset } from '../q'
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types';
import { q, qs, dataset } from '../q';
export interface PlayerState {
container: HTMLElement
video: HTMLVideoElement
progress: HTMLElement
scrubber: HTMLElement
buffered: HTMLElement
timeDisplay: HTMLElement
durationDisplay: HTMLElement
modeSources: Record<string, ModeSource>
availableModes: string[]
currentMode: string
currentEpisode: string
totalEpisodes: number
malID: number
streamURL: string
initialStreamToken: string
shouldAutoPlay: boolean
parsedSegments: SkipSegment[]
activeSegments: ActiveSegment[]
activeSkipSegment: ActiveSegment | null
activeSubtitles: SubtitleCue[]
currentSubtitleTracks: SubtitleTrack[]
lastKnownVolume: number
pendingSeekTime: number | null
isScrubbing: boolean
isFullscreen: boolean
playerControlsTimeout: number | undefined
progressSaveTimer: number | undefined
transitionEpisode: number | null
completionSent: boolean
completionAttempts: number
lastSavedProgress: { episode: string; seconds: number }
episodeGrid: HTMLElement | null
episodeList: HTMLElement | null
previewPopover: HTMLElement | null
previewTime: HTMLElement | null
videoOverlay: HTMLElement | null
container: HTMLElement;
video: HTMLVideoElement;
progress: HTMLElement;
scrubber: HTMLElement;
buffered: HTMLElement;
timeDisplay: HTMLElement;
durationDisplay: HTMLElement;
modeSources: Record<string, ModeSource>;
availableModes: string[];
currentMode: string;
currentEpisode: string;
totalEpisodes: number;
malID: number;
streamURL: string;
initialStreamToken: string;
shouldAutoPlay: boolean;
parsedSegments: SkipSegment[];
activeSegments: ActiveSegment[];
activeSkipSegment: ActiveSegment | null;
activeSubtitles: SubtitleCue[];
currentSubtitleTracks: SubtitleTrack[];
lastKnownVolume: number;
pendingSeekTime: number | null;
isScrubbing: boolean;
isFullscreen: boolean;
playerControlsTimeout: number | undefined;
progressSaveTimer: number | undefined;
transitionEpisode: number | null;
completionSent: boolean;
completionAttempts: number;
lastSavedProgress: { episode: string; seconds: number };
episodeGrid: HTMLElement | null;
episodeList: HTMLElement | null;
previewPopover: HTMLElement | null;
previewTime: HTMLElement | null;
videoOverlay: HTMLElement | null;
}
export const state: PlayerState = {
@@ -77,50 +77,53 @@ export const state: PlayerState = {
previewPopover: null,
previewTime: null,
videoOverlay: null,
}
};
export const initState = (c: HTMLElement): void => {
state.container = c
state.video = q<HTMLVideoElement>(c, 'video')!
state.progress = q<HTMLElement>(c, '[data-progress]')
state.scrubber = q<HTMLElement>(c, '[data-scrubber]')
state.buffered = q<HTMLElement>(c, '[data-buffered]')
state.timeDisplay = q<HTMLElement>(c, '[data-time]')
state.durationDisplay = q<HTMLElement>(c, '[data-duration]')
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]')
state.previewTime = q<HTMLElement>(c, '[data-preview-time]')
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]')
state.container = c;
state.video = q<HTMLVideoElement>(c, 'video')!;
state.progress = q<HTMLElement>(c, '[data-progress]');
state.scrubber = q<HTMLElement>(c, '[data-scrubber]');
state.buffered = q<HTMLElement>(c, '[data-buffered]');
state.timeDisplay = q<HTMLElement>(c, '[data-time]');
state.durationDisplay = q<HTMLElement>(c, '[data-duration]');
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]');
state.previewTime = q<HTMLElement>(c, '[data-preview-time]');
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]');
state.malID = Number.parseInt(dataset(c, 'malId'), 10)
state.currentEpisode = dataset(c, 'currentEpisode') || '1'
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10)
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream'
state.initialStreamToken = dataset(c, 'streamToken') || ''
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true'
sessionStorage.removeItem('mal:autoplay-next')
state.malID = Number.parseInt(dataset(c, 'malId'), 10);
state.currentEpisode = dataset(c, 'currentEpisode') || '1';
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10);
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream';
state.initialStreamToken = dataset(c, 'streamToken') || '';
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true';
sessionStorage.removeItem('mal:autoplay-next');
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]')
state.episodeList = qs<HTMLElement>('[data-episode-list]')
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]');
state.episodeList = qs<HTMLElement>('[data-episode-list]');
const safeJson = <T>(raw: string | undefined, fallback: T): T => {
try { return JSON.parse(raw ?? '') as T } catch { return fallback }
}
try {
return JSON.parse(raw ?? '') as T;
} catch {
return fallback;
}
};
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>)
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[])
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>);
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]);
const backendInitialMode = dataset(c, 'initialMode') || 'dub'
const storedMode = localStorage.getItem('player-audio-mode')
const initialMode = (storedMode && state.availableModes.includes(storedMode)) ? storedMode : backendInitialMode
const fallbackMode = Object.keys(state.modeSources).find(
m => state.modeSources[m]?.token
)
state.currentMode =
(state.modeSources[initialMode]?.token) ? initialMode :
(fallbackMode ?? state.availableModes[0] ?? 'dub')
const backendInitialMode = dataset(c, 'initialMode') || 'dub';
const storedMode = localStorage.getItem('player-audio-mode');
const initialMode =
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode;
const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token);
state.currentMode = state.modeSources[initialMode]?.token
? initialMode
: (fallbackMode ?? state.availableModes[0] ?? 'dub');
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[])
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[]);
state.parsedSegments = segments
.map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
.filter(s => s.end > s.start)
}
.filter(s => s.end > s.start);
};

View File

@@ -1,76 +1,99 @@
import { SubtitleCue, SubtitleTrack } from '../types'
import { state } from '../state'
import { parseVtt } from './vtt'
import { SubtitleCue, SubtitleTrack } from '../types';
import { state } from '../state';
import { parseVtt } from './vtt';
const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`
const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`;
const subtitlesForMode = (): SubtitleTrack[] => {
const src = state.modeSources[state.currentMode]
if (!src?.subtitles) return []
const src = state.modeSources[state.currentMode];
if (!src?.subtitles) return [];
return src.subtitles
.map(t => ({ lang: (t.lang || 'unknown').toLowerCase(), label: t.lang || 'Unknown', url: proxyUrl(t.token) }))
.filter(t => t.url !== '')
}
.map(t => ({
lang: (t.lang || 'unknown').toLowerCase(),
label: t.lang || 'Unknown',
url: proxyUrl(t.token),
}))
.filter(t => t.url !== '');
};
const hideSubtitleText = (): void => {
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
if (!el) return
el.textContent = ''
el.classList.remove('block')
el.classList.add('hidden')
}
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
if (!el) return;
el.textContent = '';
el.classList.remove('block');
el.classList.add('hidden');
};
const loadSubtitle = async (url: string): Promise<SubtitleCue[]> => {
try {
const res = await fetch(url)
if (!res.ok) return []
return parseVtt(await res.text())
} catch { return [] }
}
const res = await fetch(url);
if (!res.ok) return [];
return parseVtt(await res.text());
} catch {
return [];
}
};
export const updateSubtitleOptions = (): void => {
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
if (!select) return
state.currentSubtitleTracks = subtitlesForMode()
select.innerHTML = ''
const select = state.container.querySelector(
'[data-subtitle-select]'
) as HTMLSelectElement | null;
if (!select) return;
state.currentSubtitleTracks = subtitlesForMode();
select.innerHTML = '';
const none = document.createElement('option')
none.value = 'none'
none.textContent = 'Off'
select.appendChild(none)
select.value = 'none'
const none = document.createElement('option');
none.value = 'none';
none.textContent = 'Off';
select.appendChild(none);
select.value = 'none';
state.currentSubtitleTracks.forEach((t, i) => {
const opt = document.createElement('option')
opt.value = String(i)
opt.textContent = t.label
select.appendChild(opt)
})
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = t.label;
select.appendChild(opt);
});
const wrapper = select.parentElement
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0)
state.activeSubtitles = []
hideSubtitleText()
}
const wrapper = select.parentElement;
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0);
state.activeSubtitles = [];
hideSubtitleText();
};
export const updateSubtitleRender = (time: number): void => {
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
if (!el) return
if (!state.activeSubtitles.length) { hideSubtitleText(); return }
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
if (!el) return;
if (!state.activeSubtitles.length) {
hideSubtitleText();
return;
}
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end)
if (!cue) { hideSubtitleText(); return }
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end);
if (!cue) {
hideSubtitleText();
return;
}
el.textContent = cue.text
el.classList.remove('hidden')
}
el.textContent = cue.text;
el.classList.remove('hidden');
};
export const setupSubtitles = (): void => {
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
const select = state.container.querySelector(
'[data-subtitle-select]'
) as HTMLSelectElement | null;
select?.addEventListener('change', async () => {
if (select.value === 'none') { state.activeSubtitles = []; hideSubtitleText(); return }
const track = state.currentSubtitleTracks[Number(select.value)]
if (!track) { state.activeSubtitles = []; return }
state.activeSubtitles = await loadSubtitle(track.url)
})
}
if (select.value === 'none') {
state.activeSubtitles = [];
hideSubtitleText();
return;
}
const track = state.currentSubtitleTracks[Number(select.value)];
if (!track) {
state.activeSubtitles = [];
return;
}
state.activeSubtitles = await loadSubtitle(track.url);
});
};

View File

@@ -1,38 +1,43 @@
export const parseVttTime = (raw: string): number => {
const parts = raw.trim().split(':')
if (parts.length < 2) return 0
const secPart = parts.pop()!
const minPart = parts.pop()!
const hourPart = parts.pop() ?? '0'
return (Number(hourPart) * 3600) + (Number(minPart) * 60) + Number(secPart.replace(',', '.'))
}
const parts = raw.trim().split(':');
if (parts.length < 2) return 0;
const secPart = parts.pop()!;
const minPart = parts.pop()!;
const hourPart = parts.pop() ?? '0';
return Number(hourPart) * 3600 + Number(minPart) * 60 + Number(secPart.replace(',', '.'));
};
export const parseVttCue = (line: string, lines: string[], i: number) => {
if (!line.includes('-->')) return null
const [startRaw, endRaw] = line.split('-->')
const payload: string[] = []
let j = i + 1
if (!line.includes('-->')) return null;
const [startRaw, endRaw] = line.split('-->');
const payload: string[] = [];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== '') {
payload.push(lines[j]); j++
payload.push(lines[j]);
j++;
}
const text = payload.join('\n').replace(/<[^>]+>/g, '').trim()
if (!text) return null
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text }
}
const text = payload
.join('\n')
.replace(/<[^>]+>/g, '')
.trim();
if (!text) return null;
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text };
};
export const parseVtt = (text: string) => {
const lines = text.replace(/\r/g, '').split('\n')
const cues = []
const lines = text.replace(/\r/g, '').split('\n');
const cues = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const line = lines[i].trim();
if (!line) continue;
if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) {
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1)
if (cue) cues.push(cue); i++
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1);
if (cue) cues.push(cue);
i++;
} else if (line.includes('-->')) {
const cue = parseVttCue(line, lines, i)
if (cue) cues.push(cue)
const cue = parseVttCue(line, lines, i);
if (cue) cues.push(cue);
}
}
return cues
}
return cues;
};

View File

@@ -1,97 +1,101 @@
import { TimelineBounds } from './types'
import { state } from './state'
import { TimelineBounds } from './types';
import { state } from './state';
const formatTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 };
export const timelineBounds = (): TimelineBounds => {
const duration = Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0
let start = 0
const duration =
Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0;
let start = 0;
if (state.video.seekable.length > 0) {
const seekableStart = state.video.seekable.start(0)
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart
const seekableStart = state.video.seekable.start(0);
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart;
}
if (duration > start) {
return { start, end: duration, duration: duration - start }
return { start, end: duration, duration: duration - start };
}
if (state.video.seekable.length > 0) {
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1)
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1);
if (Number.isFinite(seekableEnd) && seekableEnd > start) {
return { start, end: seekableEnd, duration: seekableEnd - start }
return { start, end: seekableEnd, duration: seekableEnd - start };
}
}
return { start: 0, end: duration, duration }
}
return { start: 0, end: duration, duration };
};
export const invalidateBounds = (): void => {
cachedBounds = timelineBounds()
}
cachedBounds = timelineBounds();
};
export const getBounds = (): TimelineBounds => cachedBounds
export const getBounds = (): TimelineBounds => cachedBounds;
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
const b = getBounds()
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start
}
const b = getBounds();
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0;
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start;
};
export const absoluteTimeFromDisplay = (displayTime: number): number => {
const b = getBounds()
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0
return b.start + Math.max(0, Math.min(b.duration, displayTime))
}
const b = getBounds();
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0;
return b.start + Math.max(0, Math.min(b.duration, displayTime));
};
export const absoluteTimeFromRatio = (ratio: number): number => {
const b = getBounds()
if (!Number.isFinite(ratio) || b.duration <= 0) return 0
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration
}
const b = getBounds();
if (!Number.isFinite(ratio) || b.duration <= 0) return 0;
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration;
};
export const getBufferedEnd = (): number => {
const currentTime = state.video.currentTime
let end = 0
const currentTime = state.video.currentTime;
let end = 0;
for (let i = 0; i < state.video.buffered.length; i++) {
if (state.video.buffered.start(i) <= currentTime && state.video.buffered.end(i) >= currentTime) {
end = state.video.buffered.end(i)
break
if (
state.video.buffered.start(i) <= currentTime &&
state.video.buffered.end(i) >= currentTime
) {
end = state.video.buffered.end(i);
break;
}
}
if (end === 0) {
for (let i = 0; i < state.video.buffered.length; i++) {
if (state.video.buffered.end(i) > currentTime) {
end = Math.max(end, state.video.buffered.end(i))
end = Math.max(end, state.video.buffered.end(i));
}
}
}
return end
}
return end;
};
export const updateTimeline = (currentTime: number): void => {
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state
const b = getBounds()
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state;
const b = getBounds();
if (b.duration <= 0) {
progress.style.width = '0%'
buffered.style.width = '0%'
scrubber.style.left = '0%'
timeDisplay.textContent = '00:00'
durationDisplay.textContent = '00:00'
return
progress.style.width = '0%';
buffered.style.width = '0%';
scrubber.style.left = '0%';
timeDisplay.textContent = '00:00';
durationDisplay.textContent = '00:00';
return;
}
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100
progress.style.width = `${pct}%`
scrubber.style.left = `${pct}%`
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime))
durationDisplay.textContent = formatTime(b.duration)
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100;
progress.style.width = `${pct}%`;
scrubber.style.left = `${pct}%`;
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime));
durationDisplay.textContent = formatTime(b.duration);
const bufferedEnd = getBufferedEnd()
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100
buffered.style.width = `${bufferedPct}%`
}
const bufferedEnd = getBufferedEnd();
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100;
buffered.style.width = `${bufferedPct}%`;
};

View File

@@ -1,40 +1,40 @@
export interface ModeSource {
token: string
subtitles: SubtitleItem[]
qualities?: string[]
token: string;
subtitles: SubtitleItem[];
qualities?: string[];
}
export interface SubtitleItem {
lang: string
token: string
lang: string;
token: string;
}
export interface SkipSegment {
type: string
start: number
end: number
type: string;
start: number;
end: number;
}
export interface SubtitleCue {
start: number
end: number
text: string
start: number;
end: number;
text: string;
}
export interface SubtitleTrack {
lang: string
label: string
url: string
lang: string;
label: string;
url: string;
}
export interface ActiveSegment {
type: string
start: number
end: number
type: string;
start: number;
end: number;
}
export interface TimelineBounds {
start: number
end: number
duration: number
start: number;
end: number;
duration: number;
}