web player now supports ASS subtitles
This commit is contained in:
@@ -15,6 +15,7 @@ const AnimePlayer = (function() {
|
||||
let _localEntryId = null;
|
||||
let _totalEpisodes = 0;
|
||||
let _manualExtensionId = null;
|
||||
let _activeSubtitleIndex = -1;
|
||||
|
||||
let hlsInstance = null;
|
||||
let subtitleRenderer = null;
|
||||
@@ -448,48 +449,6 @@ const AnimePlayer = (function() {
|
||||
if (_rpcActive) sendRPC({ paused: true });
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!els.video) return;
|
||||
|
||||
// Update progress bar
|
||||
const percent = (els.video.currentTime / els.video.duration) * 100;
|
||||
if (els.progressPlayed) {
|
||||
els.progressPlayed.style.width = `${percent}%`;
|
||||
}
|
||||
if (els.progressHandle) {
|
||||
els.progressHandle.style.left = `${percent}%`;
|
||||
}
|
||||
|
||||
// Update time display
|
||||
if (els.timeDisplay) {
|
||||
const current = formatTime(els.video.currentTime);
|
||||
const total = formatTime(els.video.duration);
|
||||
els.timeDisplay.textContent = `${current} / ${total}`;
|
||||
}
|
||||
|
||||
// Update progress for AniList
|
||||
if (!_progressUpdated && els.video.duration) {
|
||||
const percentage = els.video.currentTime / els.video.duration;
|
||||
if (percentage >= 0.8) {
|
||||
updateProgress();
|
||||
_progressUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update subtitles - SAFE CHECK
|
||||
// We only call setCurrentTime if renderer exists AND is not in disposed state
|
||||
if (subtitleRenderer) {
|
||||
try {
|
||||
subtitleRenderer.setCurrentTime(els.video.currentTime);
|
||||
} catch (e) {
|
||||
// If the worker is dead or instance is invalid, silence the error
|
||||
// and potentially nullify the renderer to stop further attempts
|
||||
console.warn("Subtitle renderer error during timeupdate:", e);
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onProgress() {
|
||||
if (!els.video || !els.progressBuffer) return;
|
||||
if (els.video.buffered.length > 0) {
|
||||
@@ -747,11 +706,27 @@ const AnimePlayer = (function() {
|
||||
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
|
||||
} else if (type === 'subtitle') {
|
||||
const idx = parseInt(value);
|
||||
_activeSubtitleIndex = idx; // <--- ACTUALIZAMOS EL ESTADO AQUÍ
|
||||
|
||||
// 1. Lógica nativa (para mantener compatibilidad interna)
|
||||
if (els.video && els.video.textTracks) {
|
||||
Array.from(els.video.textTracks).forEach((track, i) => {
|
||||
track.mode = (i === idx) ? 'showing' : 'hidden';
|
||||
// Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada.
|
||||
track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Lógica de JASSUB
|
||||
if (subtitleRenderer) {
|
||||
if (idx === -1) {
|
||||
subtitleRenderer.dispose();
|
||||
} else {
|
||||
const sub = _currentSubtitles[idx];
|
||||
if (sub) {
|
||||
subtitleRenderer.setTrack(sub.src);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type === 'speed') {
|
||||
if (els.video) els.video.playbackRate = parseFloat(value);
|
||||
}
|
||||
@@ -762,45 +737,79 @@ const AnimePlayer = (function() {
|
||||
}
|
||||
|
||||
function getActiveSubtitleIndex() {
|
||||
if (!els.video || !els.video.textTracks) return -1;
|
||||
for (let i = 0; i < els.video.textTracks.length; i++) {
|
||||
if (els.video.textTracks[i].mode === 'showing') return i;
|
||||
}
|
||||
return -1;
|
||||
return _activeSubtitleIndex;
|
||||
}
|
||||
|
||||
// Subtitle renderer with libass
|
||||
async function initSubtitleRenderer() {
|
||||
if (!window.SubtitlesOctopus || !els.video || !els.subtitlesCanvas) return;
|
||||
if (!els.video) return;
|
||||
|
||||
// Ensure clean slate
|
||||
// Cleanup previous instance
|
||||
if (subtitleRenderer) {
|
||||
try { subtitleRenderer.dispose(); } catch(e) { console.warn(e); }
|
||||
try {
|
||||
subtitleRenderer.dispose();
|
||||
} catch(e) {
|
||||
console.warn('Error disposing renderer:', e);
|
||||
}
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
// Find ASS subtitle
|
||||
const assSubtitle = _currentSubtitles.find(sub =>
|
||||
sub.src && (sub.src.endsWith('.ass') || sub.label?.toLowerCase().includes('ass'))
|
||||
(sub.src && sub.src.endsWith('.ass')) ||
|
||||
(sub.label && sub.label.toLowerCase().includes('ass'))
|
||||
);
|
||||
|
||||
if (!assSubtitle) return;
|
||||
if (!assSubtitle) {
|
||||
console.log('No ASS subtitles found in current list');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
subtitleRenderer = new SubtitlesOctopus({
|
||||
video: els.video,
|
||||
canvas: els.subtitlesCanvas,
|
||||
subUrl: assSubtitle.src,
|
||||
fonts: [],
|
||||
workerUrl: '/libs/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/libs/subtitles-octopus-worker-legacy.js',
|
||||
});
|
||||
console.log('Initializing JASSUB for:', assSubtitle.label);
|
||||
|
||||
// Check if JASSUB global is available
|
||||
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
||||
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas ---
|
||||
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.init(assSubtitle.src);
|
||||
} else {
|
||||
console.warn('JASSUB library not loaded.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Subtitle renderer error:', e);
|
||||
console.error('Subtitle renderer setup error:', e);
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!els.video) return;
|
||||
|
||||
// Update progress bar
|
||||
const percent = (els.video.currentTime / els.video.duration) * 100;
|
||||
if (els.progressPlayed) {
|
||||
els.progressPlayed.style.width = `${percent}%`;
|
||||
}
|
||||
if (els.progressHandle) {
|
||||
els.progressHandle.style.left = `${percent}%`;
|
||||
}
|
||||
|
||||
// Update time display
|
||||
if (els.timeDisplay) {
|
||||
const current = formatTime(els.video.currentTime);
|
||||
const total = formatTime(els.video.duration);
|
||||
els.timeDisplay.textContent = `${current} / ${total}`;
|
||||
}
|
||||
|
||||
// Update progress for AniList
|
||||
if (!_progressUpdated && els.video.duration) {
|
||||
const percentage = els.video.currentTime / els.video.duration;
|
||||
if (percentage >= 0.8) {
|
||||
updateProgress();
|
||||
_progressUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Player lifecycle
|
||||
async function playEpisode(episodeNumber) {
|
||||
const targetEp = parseInt(episodeNumber);
|
||||
@@ -1163,62 +1172,56 @@ const AnimePlayer = (function() {
|
||||
}
|
||||
|
||||
function initVideoPlayer(url, type, subtitles = []) {
|
||||
// Double check cleanup
|
||||
// 1. CLEANUP FIRST: Destroy subtitle renderer while elements still exist
|
||||
// This prevents "removeChild" errors because the DOM is still intact
|
||||
if (subtitleRenderer) {
|
||||
try {
|
||||
subtitleRenderer.dispose();
|
||||
} catch(e) {
|
||||
console.warn('Renderer dispose error (clean):', e);
|
||||
}
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
// 2. Destroy HLS instance
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy();
|
||||
hlsInstance = null;
|
||||
}
|
||||
if (subtitleRenderer) {
|
||||
try { subtitleRenderer.dispose(); } catch(e) {}
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
const container = document.querySelector('.video-frame');
|
||||
if (!container) return;
|
||||
|
||||
// --- SAFE VIDEO ELEMENT REPLACEMENT ---
|
||||
// 3. Remove OLD Elements
|
||||
const oldVideo = container.querySelector('video');
|
||||
const oldCanvas = container.querySelector('#subtitles-canvas');
|
||||
|
||||
if (oldVideo) {
|
||||
try {
|
||||
// Remove listeners to stop events from firing during removal
|
||||
oldVideo.ontimeupdate = null;
|
||||
oldVideo.onplay = null;
|
||||
oldVideo.onpause = null;
|
||||
|
||||
// Stop playback
|
||||
oldVideo.pause();
|
||||
oldVideo.removeAttribute('src');
|
||||
oldVideo.load(); // Forces media unload
|
||||
|
||||
// Remove from DOM
|
||||
if (oldVideo.parentNode) {
|
||||
oldVideo.parentNode.removeChild(oldVideo);
|
||||
} else {
|
||||
oldVideo.load();
|
||||
oldVideo.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error cleaning up old video element:", e);
|
||||
// Continue anyway, we need to create the new player
|
||||
}
|
||||
if (oldCanvas) {
|
||||
oldCanvas.remove();
|
||||
}
|
||||
|
||||
// 4. Create NEW Elements
|
||||
const newVideo = document.createElement('video');
|
||||
newVideo.id = 'player';
|
||||
newVideo.crossOrigin = 'anonymous';
|
||||
newVideo.playsInline = true;
|
||||
|
||||
// Insert new video carefully
|
||||
if (container.firstChild) {
|
||||
container.insertBefore(newVideo, container.firstChild);
|
||||
} else {
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.id = 'subtitles-canvas';
|
||||
container.appendChild(newCanvas)
|
||||
container.appendChild(newVideo);
|
||||
}
|
||||
|
||||
els.video = newVideo;
|
||||
els.subtitlesCanvas = newCanvas;
|
||||
|
||||
// Re-setup control listeners
|
||||
setupCustomControls();
|
||||
|
||||
// 5. Initialize Player (HLS or Native)
|
||||
if (Hls.isSupported() && type === 'm3u8') {
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
@@ -1240,49 +1243,63 @@ const AnimePlayer = (function() {
|
||||
if (els.loader) els.loader.style.display = 'none';
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => {
|
||||
buildSettingsPanel();
|
||||
});
|
||||
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel());
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel());
|
||||
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => {
|
||||
buildSettingsPanel();
|
||||
});
|
||||
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
|
||||
} else if (els.video.canPlayType('application/vnd.apple.mpegurl') && type === 'm3u8') {
|
||||
// Native HLS support (Safari)
|
||||
els.video.src = url;
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
els.video.play().catch(() => {});
|
||||
if(els.loader) els.loader.style.display = 'none';
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
els.video.src = url;
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
els.video.play().catch(() => {});
|
||||
if(els.loader) els.loader.style.display = 'none';
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = 'none';
|
||||
}
|
||||
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Try to init ASS subtitle renderer
|
||||
// 6. Init Subtitles with explicit delay
|
||||
// We use setTimeout instead of requestAnimationFrame to let the Layout Engine catch up
|
||||
if (type === 'm3u8') {
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
|
||||
|
||||
// IMPORTANTE: Esperar a loadedmetadata antes de init subtitles
|
||||
if (els.video.readyState >= 1) {
|
||||
initSubtitleRenderer();
|
||||
} else {
|
||||
els.video.addEventListener('loadedmetadata', () => {
|
||||
initSubtitleRenderer();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
els.video.play().catch(() => {});
|
||||
if (els.loader) els.loader.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
els.video.src = url;
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
|
||||
// Para video directo, esperar metadata
|
||||
els.video.addEventListener('loadedmetadata', () => {
|
||||
initSubtitleRenderer();
|
||||
}, { once: true });
|
||||
|
||||
els.video.play().catch(() => {});
|
||||
if(els.loader) els.loader.style.display = 'none';
|
||||
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function attachSubtitles(subtitles) {
|
||||
if (!els.video) return;
|
||||
|
||||
// Remove existing tracks
|
||||
_activeSubtitleIndex = -1;
|
||||
|
||||
Array.from(els.video.querySelectorAll('track')).forEach(t => t.remove());
|
||||
|
||||
if (subtitles.length === 0) return;
|
||||
|
||||
subtitles.forEach((sub, i) => {
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
@@ -1293,10 +1310,15 @@ const AnimePlayer = (function() {
|
||||
els.video.appendChild(track);
|
||||
});
|
||||
|
||||
// Enable first track
|
||||
setTimeout(() => {
|
||||
if (els.video.textTracks && els.video.textTracks.length > 0) {
|
||||
_activeSubtitleIndex = 0;
|
||||
|
||||
if (!subtitleRenderer) {
|
||||
els.video.textTracks[0].mode = 'showing';
|
||||
} else {
|
||||
els.video.textTracks[0].mode = 'hidden';
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
161
desktop/src/scripts/anime/subtitle-renderer.js
Normal file
161
desktop/src/scripts/anime/subtitle-renderer.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const BASE_PATH = '/src/scripts/jassub/';
|
||||
|
||||
class SubtitleRenderer {
|
||||
// 1. Aceptamos 'canvas' en el constructor
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.instance = null;
|
||||
this.currentUrl = null;
|
||||
}
|
||||
|
||||
async init(subtitleUrl) {
|
||||
if (!this.video || !this.canvas) return; // 2. Verificamos canvas
|
||||
|
||||
this.dispose();
|
||||
|
||||
const finalUrl = subtitleUrl.includes('/api/proxy')
|
||||
? subtitleUrl
|
||||
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
||||
|
||||
this.currentUrl = finalUrl;
|
||||
|
||||
try {
|
||||
this.instance = new JASSUB({
|
||||
video: this.video,
|
||||
canvas: this.canvas,
|
||||
subUrl: finalUrl,
|
||||
|
||||
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
||||
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
||||
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
||||
|
||||
blendMode: 'js',
|
||||
asyncRender: true,
|
||||
onDemand: true,
|
||||
targetFps: 60,
|
||||
debug: false
|
||||
});
|
||||
|
||||
console.log('JASSUB initialized for:', finalUrl);
|
||||
|
||||
} catch (e) {
|
||||
console.error("JASSUB Init Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.instance && this.instance.resize) {
|
||||
this.instance.resize();
|
||||
}
|
||||
}
|
||||
|
||||
setTrack(url) {
|
||||
const finalUrl = url.includes('/api/proxy')
|
||||
? url
|
||||
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
|
||||
if (this.instance) {
|
||||
this.instance.setTrackByUrl(finalUrl);
|
||||
this.currentUrl = finalUrl;
|
||||
} else {
|
||||
this.init(url);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.instance) {
|
||||
try {
|
||||
this.instance.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error destroying JASSUB:", e);
|
||||
}
|
||||
this.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple Renderer remains unchanged for SRT/VTT (Non-ASS)
|
||||
class SimpleSubtitleRenderer {
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.cues = [];
|
||||
this.destroyed = false;
|
||||
|
||||
this.setupCanvas();
|
||||
this.video.addEventListener('timeupdate', () => this.render());
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
const updateSize = () => {
|
||||
if (!this.video || !this.canvas) return;
|
||||
const rect = this.video.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
};
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
this.resizeHandler = updateSize;
|
||||
}
|
||||
|
||||
async loadSubtitles(url) {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`);
|
||||
const text = await response.text();
|
||||
this.cues = this.parseSRT(text);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitles:', error);
|
||||
}
|
||||
}
|
||||
|
||||
parseSRT(srtText) {
|
||||
const blocks = srtText.trim().split('\n\n');
|
||||
return blocks.map(block => {
|
||||
const lines = block.split('\n');
|
||||
if (lines.length < 3) return null;
|
||||
const timeMatch = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})/);
|
||||
if (!timeMatch) return null;
|
||||
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
||||
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
||||
const text = lines.slice(2).join('\n');
|
||||
return { start, end, text };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.destroyed) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
const currentTime = this.video.currentTime;
|
||||
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
||||
if (cue) this.drawSubtitle(cue.text);
|
||||
}
|
||||
|
||||
drawSubtitle(text) {
|
||||
const lines = text.split('\n');
|
||||
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
||||
this.ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
const lineHeight = fontSize * 1.2;
|
||||
const startY = this.canvas.height - 60;
|
||||
lines.reverse().forEach((line, index) => {
|
||||
const y = startY - (index * lineHeight);
|
||||
this.ctx.strokeStyle = 'black';
|
||||
this.ctx.lineWidth = 4;
|
||||
this.ctx.strokeText(line, this.canvas.width / 2, y);
|
||||
this.ctx.fillStyle = 'white';
|
||||
this.ctx.fillText(line, this.canvas.width / 2, y);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.destroyed = true;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
window.SubtitleRenderer = SubtitleRenderer;
|
||||
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
||||
BIN
desktop/src/scripts/jassub/default.woff2
Normal file
BIN
desktop/src/scripts/jassub/default.woff2
Normal file
Binary file not shown.
BIN
desktop/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
BIN
desktop/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
Binary file not shown.
11
desktop/src/scripts/jassub/jassub-worker.js
Normal file
11
desktop/src/scripts/jassub/jassub-worker.js
Normal file
File diff suppressed because one or more lines are too long
BIN
desktop/src/scripts/jassub/jassub-worker.wasm
Normal file
BIN
desktop/src/scripts/jassub/jassub-worker.wasm
Normal file
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon" />
|
||||
<title>WaifuBoard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
|
||||
<script type="module">
|
||||
import JASSUB from 'https://cdn.jsdelivr.net/npm/jassub@1.8.8/dist/jassub.es.js';
|
||||
window.JASSUB = JASSUB;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/anime.css" />
|
||||
@@ -302,6 +305,7 @@
|
||||
<script src="/src/scripts/utils/youtube-player-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/utils/match-modal.js"></script>
|
||||
<script src="/src/scripts/anime/subtitle-renderer.js"></script>
|
||||
|
||||
<script src="/src/scripts/anime/player.js"></script>
|
||||
<script src="/src/scripts/anime/entry.js"></script>
|
||||
|
||||
@@ -15,6 +15,7 @@ const AnimePlayer = (function() {
|
||||
let _localEntryId = null;
|
||||
let _totalEpisodes = 0;
|
||||
let _manualExtensionId = null;
|
||||
let _activeSubtitleIndex = -1;
|
||||
|
||||
let hlsInstance = null;
|
||||
let subtitleRenderer = null;
|
||||
@@ -448,48 +449,6 @@ const AnimePlayer = (function() {
|
||||
if (_rpcActive) sendRPC({ paused: true });
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!els.video) return;
|
||||
|
||||
// Update progress bar
|
||||
const percent = (els.video.currentTime / els.video.duration) * 100;
|
||||
if (els.progressPlayed) {
|
||||
els.progressPlayed.style.width = `${percent}%`;
|
||||
}
|
||||
if (els.progressHandle) {
|
||||
els.progressHandle.style.left = `${percent}%`;
|
||||
}
|
||||
|
||||
// Update time display
|
||||
if (els.timeDisplay) {
|
||||
const current = formatTime(els.video.currentTime);
|
||||
const total = formatTime(els.video.duration);
|
||||
els.timeDisplay.textContent = `${current} / ${total}`;
|
||||
}
|
||||
|
||||
// Update progress for AniList
|
||||
if (!_progressUpdated && els.video.duration) {
|
||||
const percentage = els.video.currentTime / els.video.duration;
|
||||
if (percentage >= 0.8) {
|
||||
updateProgress();
|
||||
_progressUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update subtitles - SAFE CHECK
|
||||
// We only call setCurrentTime if renderer exists AND is not in disposed state
|
||||
if (subtitleRenderer) {
|
||||
try {
|
||||
subtitleRenderer.setCurrentTime(els.video.currentTime);
|
||||
} catch (e) {
|
||||
// If the worker is dead or instance is invalid, silence the error
|
||||
// and potentially nullify the renderer to stop further attempts
|
||||
console.warn("Subtitle renderer error during timeupdate:", e);
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onProgress() {
|
||||
if (!els.video || !els.progressBuffer) return;
|
||||
if (els.video.buffered.length > 0) {
|
||||
@@ -747,11 +706,27 @@ const AnimePlayer = (function() {
|
||||
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
|
||||
} else if (type === 'subtitle') {
|
||||
const idx = parseInt(value);
|
||||
_activeSubtitleIndex = idx; // <--- ACTUALIZAMOS EL ESTADO AQUÍ
|
||||
|
||||
// 1. Lógica nativa (para mantener compatibilidad interna)
|
||||
if (els.video && els.video.textTracks) {
|
||||
Array.from(els.video.textTracks).forEach((track, i) => {
|
||||
track.mode = (i === idx) ? 'showing' : 'hidden';
|
||||
// Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada.
|
||||
track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Lógica de JASSUB
|
||||
if (subtitleRenderer) {
|
||||
if (idx === -1) {
|
||||
subtitleRenderer.dispose();
|
||||
} else {
|
||||
const sub = _currentSubtitles[idx];
|
||||
if (sub) {
|
||||
subtitleRenderer.setTrack(sub.src);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type === 'speed') {
|
||||
if (els.video) els.video.playbackRate = parseFloat(value);
|
||||
}
|
||||
@@ -762,45 +737,79 @@ const AnimePlayer = (function() {
|
||||
}
|
||||
|
||||
function getActiveSubtitleIndex() {
|
||||
if (!els.video || !els.video.textTracks) return -1;
|
||||
for (let i = 0; i < els.video.textTracks.length; i++) {
|
||||
if (els.video.textTracks[i].mode === 'showing') return i;
|
||||
}
|
||||
return -1;
|
||||
return _activeSubtitleIndex;
|
||||
}
|
||||
|
||||
// Subtitle renderer with libass
|
||||
async function initSubtitleRenderer() {
|
||||
if (!window.SubtitlesOctopus || !els.video || !els.subtitlesCanvas) return;
|
||||
if (!els.video) return;
|
||||
|
||||
// Ensure clean slate
|
||||
// Cleanup previous instance
|
||||
if (subtitleRenderer) {
|
||||
try { subtitleRenderer.dispose(); } catch(e) { console.warn(e); }
|
||||
try {
|
||||
subtitleRenderer.dispose();
|
||||
} catch(e) {
|
||||
console.warn('Error disposing renderer:', e);
|
||||
}
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
// Find ASS subtitle
|
||||
const assSubtitle = _currentSubtitles.find(sub =>
|
||||
sub.src && (sub.src.endsWith('.ass') || sub.label?.toLowerCase().includes('ass'))
|
||||
(sub.src && sub.src.endsWith('.ass')) ||
|
||||
(sub.label && sub.label.toLowerCase().includes('ass'))
|
||||
);
|
||||
|
||||
if (!assSubtitle) return;
|
||||
if (!assSubtitle) {
|
||||
console.log('No ASS subtitles found in current list');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
subtitleRenderer = new SubtitlesOctopus({
|
||||
video: els.video,
|
||||
canvas: els.subtitlesCanvas,
|
||||
subUrl: assSubtitle.src,
|
||||
fonts: [],
|
||||
workerUrl: '/libs/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/libs/subtitles-octopus-worker-legacy.js',
|
||||
});
|
||||
console.log('Initializing JASSUB for:', assSubtitle.label);
|
||||
|
||||
// Check if JASSUB global is available
|
||||
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
||||
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas ---
|
||||
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.init(assSubtitle.src);
|
||||
} else {
|
||||
console.warn('JASSUB library not loaded.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Subtitle renderer error:', e);
|
||||
console.error('Subtitle renderer setup error:', e);
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!els.video) return;
|
||||
|
||||
// Update progress bar
|
||||
const percent = (els.video.currentTime / els.video.duration) * 100;
|
||||
if (els.progressPlayed) {
|
||||
els.progressPlayed.style.width = `${percent}%`;
|
||||
}
|
||||
if (els.progressHandle) {
|
||||
els.progressHandle.style.left = `${percent}%`;
|
||||
}
|
||||
|
||||
// Update time display
|
||||
if (els.timeDisplay) {
|
||||
const current = formatTime(els.video.currentTime);
|
||||
const total = formatTime(els.video.duration);
|
||||
els.timeDisplay.textContent = `${current} / ${total}`;
|
||||
}
|
||||
|
||||
// Update progress for AniList
|
||||
if (!_progressUpdated && els.video.duration) {
|
||||
const percentage = els.video.currentTime / els.video.duration;
|
||||
if (percentage >= 0.8) {
|
||||
updateProgress();
|
||||
_progressUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Player lifecycle
|
||||
async function playEpisode(episodeNumber) {
|
||||
const targetEp = parseInt(episodeNumber);
|
||||
@@ -1163,62 +1172,56 @@ const AnimePlayer = (function() {
|
||||
}
|
||||
|
||||
function initVideoPlayer(url, type, subtitles = []) {
|
||||
// Double check cleanup
|
||||
// 1. CLEANUP FIRST: Destroy subtitle renderer while elements still exist
|
||||
// This prevents "removeChild" errors because the DOM is still intact
|
||||
if (subtitleRenderer) {
|
||||
try {
|
||||
subtitleRenderer.dispose();
|
||||
} catch(e) {
|
||||
console.warn('Renderer dispose error (clean):', e);
|
||||
}
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
// 2. Destroy HLS instance
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy();
|
||||
hlsInstance = null;
|
||||
}
|
||||
if (subtitleRenderer) {
|
||||
try { subtitleRenderer.dispose(); } catch(e) {}
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
const container = document.querySelector('.video-frame');
|
||||
if (!container) return;
|
||||
|
||||
// --- SAFE VIDEO ELEMENT REPLACEMENT ---
|
||||
// 3. Remove OLD Elements
|
||||
const oldVideo = container.querySelector('video');
|
||||
const oldCanvas = container.querySelector('#subtitles-canvas');
|
||||
|
||||
if (oldVideo) {
|
||||
try {
|
||||
// Remove listeners to stop events from firing during removal
|
||||
oldVideo.ontimeupdate = null;
|
||||
oldVideo.onplay = null;
|
||||
oldVideo.onpause = null;
|
||||
|
||||
// Stop playback
|
||||
oldVideo.pause();
|
||||
oldVideo.removeAttribute('src');
|
||||
oldVideo.load(); // Forces media unload
|
||||
|
||||
// Remove from DOM
|
||||
if (oldVideo.parentNode) {
|
||||
oldVideo.parentNode.removeChild(oldVideo);
|
||||
} else {
|
||||
oldVideo.load();
|
||||
oldVideo.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error cleaning up old video element:", e);
|
||||
// Continue anyway, we need to create the new player
|
||||
}
|
||||
if (oldCanvas) {
|
||||
oldCanvas.remove();
|
||||
}
|
||||
|
||||
// 4. Create NEW Elements
|
||||
const newVideo = document.createElement('video');
|
||||
newVideo.id = 'player';
|
||||
newVideo.crossOrigin = 'anonymous';
|
||||
newVideo.playsInline = true;
|
||||
|
||||
// Insert new video carefully
|
||||
if (container.firstChild) {
|
||||
container.insertBefore(newVideo, container.firstChild);
|
||||
} else {
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.id = 'subtitles-canvas';
|
||||
container.appendChild(newCanvas)
|
||||
container.appendChild(newVideo);
|
||||
}
|
||||
|
||||
els.video = newVideo;
|
||||
els.subtitlesCanvas = newCanvas;
|
||||
|
||||
// Re-setup control listeners
|
||||
setupCustomControls();
|
||||
|
||||
// 5. Initialize Player (HLS or Native)
|
||||
if (Hls.isSupported() && type === 'm3u8') {
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
@@ -1240,49 +1243,63 @@ const AnimePlayer = (function() {
|
||||
if (els.loader) els.loader.style.display = 'none';
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => {
|
||||
buildSettingsPanel();
|
||||
});
|
||||
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel());
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel());
|
||||
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => {
|
||||
buildSettingsPanel();
|
||||
});
|
||||
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
|
||||
} else if (els.video.canPlayType('application/vnd.apple.mpegurl') && type === 'm3u8') {
|
||||
// Native HLS support (Safari)
|
||||
els.video.src = url;
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
els.video.play().catch(() => {});
|
||||
if(els.loader) els.loader.style.display = 'none';
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
els.video.src = url;
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
els.video.play().catch(() => {});
|
||||
if(els.loader) els.loader.style.display = 'none';
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = 'none';
|
||||
}
|
||||
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Try to init ASS subtitle renderer
|
||||
// 6. Init Subtitles with explicit delay
|
||||
// We use setTimeout instead of requestAnimationFrame to let the Layout Engine catch up
|
||||
if (type === 'm3u8') {
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
|
||||
|
||||
// IMPORTANTE: Esperar a loadedmetadata antes de init subtitles
|
||||
if (els.video.readyState >= 1) {
|
||||
initSubtitleRenderer();
|
||||
} else {
|
||||
els.video.addEventListener('loadedmetadata', () => {
|
||||
initSubtitleRenderer();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
els.video.play().catch(() => {});
|
||||
if (els.loader) els.loader.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
els.video.src = url;
|
||||
attachSubtitles(subtitles);
|
||||
buildSettingsPanel();
|
||||
|
||||
// Para video directo, esperar metadata
|
||||
els.video.addEventListener('loadedmetadata', () => {
|
||||
initSubtitleRenderer();
|
||||
}, { once: true });
|
||||
|
||||
els.video.play().catch(() => {});
|
||||
if(els.loader) els.loader.style.display = 'none';
|
||||
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function attachSubtitles(subtitles) {
|
||||
if (!els.video) return;
|
||||
|
||||
// Remove existing tracks
|
||||
_activeSubtitleIndex = -1;
|
||||
|
||||
Array.from(els.video.querySelectorAll('track')).forEach(t => t.remove());
|
||||
|
||||
if (subtitles.length === 0) return;
|
||||
|
||||
subtitles.forEach((sub, i) => {
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
@@ -1293,10 +1310,15 @@ const AnimePlayer = (function() {
|
||||
els.video.appendChild(track);
|
||||
});
|
||||
|
||||
// Enable first track
|
||||
setTimeout(() => {
|
||||
if (els.video.textTracks && els.video.textTracks.length > 0) {
|
||||
_activeSubtitleIndex = 0;
|
||||
|
||||
if (!subtitleRenderer) {
|
||||
els.video.textTracks[0].mode = 'showing';
|
||||
} else {
|
||||
els.video.textTracks[0].mode = 'hidden';
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
161
docker/src/scripts/anime/subtitle-renderer.js
Normal file
161
docker/src/scripts/anime/subtitle-renderer.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const BASE_PATH = '/src/scripts/jassub/';
|
||||
|
||||
class SubtitleRenderer {
|
||||
// 1. Aceptamos 'canvas' en el constructor
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.instance = null;
|
||||
this.currentUrl = null;
|
||||
}
|
||||
|
||||
async init(subtitleUrl) {
|
||||
if (!this.video || !this.canvas) return; // 2. Verificamos canvas
|
||||
|
||||
this.dispose();
|
||||
|
||||
const finalUrl = subtitleUrl.includes('/api/proxy')
|
||||
? subtitleUrl
|
||||
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
||||
|
||||
this.currentUrl = finalUrl;
|
||||
|
||||
try {
|
||||
this.instance = new JASSUB({
|
||||
video: this.video,
|
||||
canvas: this.canvas,
|
||||
subUrl: finalUrl,
|
||||
|
||||
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
||||
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
||||
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
||||
|
||||
blendMode: 'js',
|
||||
asyncRender: true,
|
||||
onDemand: true,
|
||||
targetFps: 60,
|
||||
debug: false
|
||||
});
|
||||
|
||||
console.log('JASSUB initialized for:', finalUrl);
|
||||
|
||||
} catch (e) {
|
||||
console.error("JASSUB Init Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.instance && this.instance.resize) {
|
||||
this.instance.resize();
|
||||
}
|
||||
}
|
||||
|
||||
setTrack(url) {
|
||||
const finalUrl = url.includes('/api/proxy')
|
||||
? url
|
||||
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
|
||||
if (this.instance) {
|
||||
this.instance.setTrackByUrl(finalUrl);
|
||||
this.currentUrl = finalUrl;
|
||||
} else {
|
||||
this.init(url);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.instance) {
|
||||
try {
|
||||
this.instance.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error destroying JASSUB:", e);
|
||||
}
|
||||
this.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple Renderer remains unchanged for SRT/VTT (Non-ASS)
|
||||
class SimpleSubtitleRenderer {
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.cues = [];
|
||||
this.destroyed = false;
|
||||
|
||||
this.setupCanvas();
|
||||
this.video.addEventListener('timeupdate', () => this.render());
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
const updateSize = () => {
|
||||
if (!this.video || !this.canvas) return;
|
||||
const rect = this.video.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
};
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
this.resizeHandler = updateSize;
|
||||
}
|
||||
|
||||
async loadSubtitles(url) {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`);
|
||||
const text = await response.text();
|
||||
this.cues = this.parseSRT(text);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitles:', error);
|
||||
}
|
||||
}
|
||||
|
||||
parseSRT(srtText) {
|
||||
const blocks = srtText.trim().split('\n\n');
|
||||
return blocks.map(block => {
|
||||
const lines = block.split('\n');
|
||||
if (lines.length < 3) return null;
|
||||
const timeMatch = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})/);
|
||||
if (!timeMatch) return null;
|
||||
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
||||
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
||||
const text = lines.slice(2).join('\n');
|
||||
return { start, end, text };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.destroyed) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
const currentTime = this.video.currentTime;
|
||||
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
||||
if (cue) this.drawSubtitle(cue.text);
|
||||
}
|
||||
|
||||
drawSubtitle(text) {
|
||||
const lines = text.split('\n');
|
||||
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
||||
this.ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
const lineHeight = fontSize * 1.2;
|
||||
const startY = this.canvas.height - 60;
|
||||
lines.reverse().forEach((line, index) => {
|
||||
const y = startY - (index * lineHeight);
|
||||
this.ctx.strokeStyle = 'black';
|
||||
this.ctx.lineWidth = 4;
|
||||
this.ctx.strokeText(line, this.canvas.width / 2, y);
|
||||
this.ctx.fillStyle = 'white';
|
||||
this.ctx.fillText(line, this.canvas.width / 2, y);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.destroyed = true;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
window.SubtitleRenderer = SubtitleRenderer;
|
||||
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
||||
BIN
docker/src/scripts/jassub/default.woff2
Normal file
BIN
docker/src/scripts/jassub/default.woff2
Normal file
Binary file not shown.
BIN
docker/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
BIN
docker/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
Binary file not shown.
11
docker/src/scripts/jassub/jassub-worker.js
Normal file
11
docker/src/scripts/jassub/jassub-worker.js
Normal file
File diff suppressed because one or more lines are too long
BIN
docker/src/scripts/jassub/jassub-worker.wasm
Normal file
BIN
docker/src/scripts/jassub/jassub-worker.wasm
Normal file
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,10 @@
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon" />
|
||||
<title>WaifuBoard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<script src="/src/scripts/libass/subtitles-octopus.js"></script>
|
||||
<link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<script type="module">
|
||||
import JASSUB from 'https://cdn.jsdelivr.net/npm/jassub@1.8.8/dist/jassub.es.js';
|
||||
window.JASSUB = JASSUB;
|
||||
</script> <link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/anime.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/player.css" />
|
||||
@@ -282,6 +284,7 @@
|
||||
<script src="/src/scripts/utils/youtube-player-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/utils/match-modal.js"></script>
|
||||
<script src="/src/scripts/anime/subtitle-renderer.js"></script>
|
||||
|
||||
<script src="/src/scripts/anime/player.js"></script>
|
||||
<script src="/src/scripts/anime/entry.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user