feat: add prettier and eslint with pre-commit hook
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}%`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user