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;

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 one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -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>