web player now supports ASS subtitles

This commit is contained in:
2026-01-03 14:53:42 +01:00
parent 136914ba4a
commit 3fe39bd6df
22 changed files with 650 additions and 3617 deletions

View File

@@ -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.remove();
}
} catch (e) {
console.warn("Error cleaning up old video element:", e);
// Continue anyway, we need to create the new player
}
oldVideo.removeAttribute('src');
oldVideo.load();
oldVideo.remove();
}
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 {
container.appendChild(newVideo);
}
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
initSubtitleRenderer();
// 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) {
els.video.textTracks[0].mode = 'showing';
_activeSubtitleIndex = 0;
if (!subtitleRenderer) {
els.video.textTracks[0].mode = 'showing';
} else {
els.video.textTracks[0].mode = 'hidden';
}
}
}, 100);
}

View 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;