download anime episodes choosing quality, audio, subs...
This commit is contained in:
@@ -34,7 +34,14 @@ const AnimePlayer = (function() {
|
||||
epTitle: null,
|
||||
prevBtn: null,
|
||||
nextBtn: null,
|
||||
mpvBtn: null
|
||||
mpvBtn: null,
|
||||
downloadBtn: null,
|
||||
downloadModal: null,
|
||||
dlQualityList: null,
|
||||
dlAudioList: null,
|
||||
dlSubsList: null,
|
||||
dlConfirmBtn: null,
|
||||
dlCancelBtn: null
|
||||
};
|
||||
|
||||
function init(animeId, initialSource, isLocal, animeData) {
|
||||
@@ -57,8 +64,46 @@ const AnimePlayer = (function() {
|
||||
els.video = document.getElementById('player');
|
||||
els.loader = document.getElementById('player-loading');
|
||||
els.loaderText = document.getElementById('player-loading-text');
|
||||
els.downloadBtn = document.getElementById('download-btn');
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.addEventListener('click', downloadEpisode);
|
||||
}
|
||||
els.downloadModal = document.getElementById('download-modal');
|
||||
els.dlQualityList = document.getElementById('dl-quality-list');
|
||||
els.dlAudioList = document.getElementById('dl-audio-list');
|
||||
els.dlSubsList = document.getElementById('dl-subs-list');
|
||||
els.dlConfirmBtn = document.getElementById('confirm-dl-btn');
|
||||
els.dlCancelBtn = document.getElementById('cancel-dl-btn');
|
||||
|
||||
const closeDlModalBtn = document.getElementById('close-download-modal');
|
||||
|
||||
if (els.dlConfirmBtn) els.dlConfirmBtn.onclick = executeDownload;
|
||||
if (els.dlCancelBtn) els.dlCancelBtn.onclick = () => els.downloadModal.style.display = 'none';
|
||||
if (closeDlModalBtn) closeDlModalBtn.onclick = () => els.downloadModal.style.display = 'none';
|
||||
const closeModal = () => {
|
||||
if (els.downloadModal) {
|
||||
els.downloadModal.classList.remove('show');
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
if(!els.downloadModal.classList.contains('show')) {
|
||||
els.downloadModal.style.display = 'none';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
if (els.dlCancelBtn) els.dlCancelBtn.onclick = closeModal;
|
||||
|
||||
if (closeDlModalBtn) closeDlModalBtn.onclick = closeModal;
|
||||
els.mpvBtn = document.getElementById('mpv-btn');
|
||||
if (els.downloadModal) {
|
||||
els.downloadModal.addEventListener('click', (e) => {
|
||||
|
||||
if (e.target === els.downloadModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV);
|
||||
|
||||
els.serverSelect = document.getElementById('server-select');
|
||||
@@ -183,6 +228,11 @@ const AnimePlayer = (function() {
|
||||
|
||||
_currentEpisode = targetEp;
|
||||
|
||||
if (els.downloadBtn) {
|
||||
els.downloadBtn.style.display = _isLocal ? 'none' : 'flex';
|
||||
resetDownloadButtonIcon();
|
||||
}
|
||||
|
||||
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
|
||||
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
|
||||
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
|
||||
@@ -215,6 +265,256 @@ const AnimePlayer = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadEpisode() {
|
||||
if (!_rawVideoData || !_rawVideoData.url) {
|
||||
alert("Stream not loaded yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isInFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
|
||||
|
||||
if (isInFullscreen) {
|
||||
try {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
await document.webkitExitFullscreen();
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (err) {
|
||||
console.warn("Error al salir de fullscreen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const isM3U8 = hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0;
|
||||
const hasMultipleAudio = hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 1;
|
||||
|
||||
const hasSubs = _currentSubtitles && _currentSubtitles.length > 0;
|
||||
|
||||
if (isM3U8 || hasMultipleAudio || hasSubs) {
|
||||
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
openDownloadModal();
|
||||
} else {
|
||||
|
||||
executeDownload(null, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openDownloadModal() {
|
||||
if(!els.downloadModal) {
|
||||
console.error("Modal element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
els.dlQualityList.innerHTML = '';
|
||||
els.dlAudioList.innerHTML = '';
|
||||
els.dlSubsList.innerHTML = '';
|
||||
|
||||
let showQuality = false;
|
||||
let showAudio = false;
|
||||
let showSubs = false;
|
||||
|
||||
if (hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0) {
|
||||
showQuality = true;
|
||||
|
||||
const levels = hlsInstance.levels.map((l, index) => ({...l, originalIndex: index}))
|
||||
.sort((a, b) => b.height - a.height);
|
||||
|
||||
levels.forEach((level, i) => {
|
||||
const isSelected = i === 0;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dl-item';
|
||||
div.innerHTML = `
|
||||
<input type="radio" name="dl-quality" value="${level.originalIndex}" ${isSelected ? 'checked' : ''}>
|
||||
<span>${level.height}p</span>
|
||||
<span class="tag-info">${(level.bitrate / 1000000).toFixed(1)} Mbps</span>
|
||||
`;
|
||||
div.onclick = (e) => {
|
||||
if(e.target.tagName !== 'INPUT') div.querySelector('input').checked = true;
|
||||
};
|
||||
els.dlQualityList.appendChild(div);
|
||||
});
|
||||
}
|
||||
document.getElementById('dl-quality-section').style.display = showQuality ? 'block' : 'none';
|
||||
|
||||
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
|
||||
showAudio = true;
|
||||
hlsInstance.audioTracks.forEach((track, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dl-item';
|
||||
|
||||
const isCurrent = hlsInstance.audioTrack === index;
|
||||
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" name="dl-audio" value="${index}" checked>
|
||||
<span>${track.name || track.lang || `Audio ${index+1}`}</span>
|
||||
<span class="tag-info">${track.lang || 'unk'}</span>
|
||||
`;
|
||||
div.onclick = (e) => {
|
||||
if(e.target.tagName !== 'INPUT') {
|
||||
const cb = div.querySelector('input');
|
||||
cb.checked = !cb.checked;
|
||||
}
|
||||
};
|
||||
els.dlAudioList.appendChild(div);
|
||||
});
|
||||
}
|
||||
document.getElementById('dl-audio-section').style.display = showAudio ? 'block' : 'none';
|
||||
|
||||
if (_currentSubtitles && _currentSubtitles.length > 0) {
|
||||
showSubs = true;
|
||||
_currentSubtitles.forEach((sub, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dl-item';
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" name="dl-subs" value="${index}" checked>
|
||||
<span>${sub.label || sub.language || 'Unknown'}</span>
|
||||
`;
|
||||
div.onclick = (e) => {
|
||||
if(e.target.tagName !== 'INPUT') {
|
||||
const cb = div.querySelector('input');
|
||||
cb.checked = !cb.checked;
|
||||
}
|
||||
};
|
||||
els.dlSubsList.appendChild(div);
|
||||
});
|
||||
}
|
||||
document.getElementById('dl-subs-section').style.display = showSubs ? 'block' : 'none';
|
||||
|
||||
els.downloadModal.style.display = 'flex';
|
||||
|
||||
els.downloadModal.offsetHeight;
|
||||
els.downloadModal.classList.add('show');
|
||||
}
|
||||
|
||||
async function executeDownload(e, skipModal = false) {
|
||||
if(els.downloadModal) {
|
||||
els.downloadModal.classList.remove('show');
|
||||
setTimeout(() => els.downloadModal.style.display = 'none', 300);
|
||||
}
|
||||
const btn = els.downloadBtn;
|
||||
const originalBtnContent = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
||||
|
||||
let body = {
|
||||
anilist_id: parseInt(_animeId),
|
||||
episode_number: parseInt(_currentEpisode),
|
||||
stream_url: _rawVideoData.url,
|
||||
headers: _rawVideoData.headers || {},
|
||||
chapters: _skipIntervals.map(i => ({
|
||||
title: i.type === 'op' ? 'Opening' : 'Ending',
|
||||
start_time: i.startTime,
|
||||
end_time: i.endTime
|
||||
})),
|
||||
subtitles: []
|
||||
};
|
||||
|
||||
if (skipModal) {
|
||||
|
||||
if (_currentSubtitles) {
|
||||
body.subtitles = _currentSubtitles.map(sub => ({
|
||||
language: sub.label || 'Unknown',
|
||||
url: sub.src
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
|
||||
const selectedSubs = Array.from(els.dlSubsList.querySelectorAll('input:checked'));
|
||||
body.subtitles = selectedSubs.map(cb => {
|
||||
const i = parseInt(cb.value);
|
||||
return {
|
||||
language: _currentSubtitles[i].label || 'Unknown',
|
||||
url: _currentSubtitles[i].src
|
||||
};
|
||||
});
|
||||
|
||||
const isQualityVisible = document.getElementById('dl-quality-section').style.display !== 'none';
|
||||
|
||||
if (isQualityVisible && hlsInstance && hlsInstance.levels) {
|
||||
body.is_master = true;
|
||||
|
||||
const qualityInput = document.querySelector('input[name="dl-quality"]:checked');
|
||||
const qualityIndex = qualityInput ? parseInt(qualityInput.value) : 0;
|
||||
const level = hlsInstance.levels[qualityIndex];
|
||||
|
||||
if (level) {
|
||||
body.variant = {
|
||||
resolution: level.width ? `${level.width}x${level.height}` : '1920x1080',
|
||||
bandwidth: level.bitrate,
|
||||
codecs: level.attrs ? level.attrs.CODECS : '',
|
||||
playlist_url: level.url
|
||||
};
|
||||
}
|
||||
|
||||
const audioInputs = document.querySelectorAll('input[name="dl-audio"]:checked');
|
||||
if (audioInputs.length > 0 && hlsInstance.audioTracks) {
|
||||
body.audio = Array.from(audioInputs).map(input => {
|
||||
const i = parseInt(input.value);
|
||||
const track = hlsInstance.audioTracks[i];
|
||||
return {
|
||||
group: track.groupId || 'audio',
|
||||
language: track.lang || 'unk',
|
||||
name: track.name || `Audio ${i}`,
|
||||
playlist_url: track.url
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/library/download/anime', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 200) {
|
||||
|
||||
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
||||
} else if (res.status === 409) {
|
||||
|
||||
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`;
|
||||
} else {
|
||||
|
||||
console.error("Download Error:", data);
|
||||
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Request failed:", err);
|
||||
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
|
||||
} finally {
|
||||
|
||||
setTimeout(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
resetDownloadButtonIcon();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function resetDownloadButtonIcon() {
|
||||
if (!els.downloadBtn) return;
|
||||
els.downloadBtn.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
if (plyrInstance) plyrInstance.destroy();
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
@@ -508,40 +808,34 @@ const AnimePlayer = (function() {
|
||||
const controls = plyrEl.querySelector('.plyr__controls');
|
||||
if (!controls || controls.querySelector('#quality-control-wrapper')) return;
|
||||
|
||||
// 1. Crear el Wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
||||
wrapper.id = 'quality-control-wrapper';
|
||||
|
||||
// 2. Crear el Botón Visual (Fake)
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'plyr__custom-control-btn';
|
||||
// Icono + Texto Inicial
|
||||
|
||||
btn.innerHTML = `${ICONS.settings} <span class="label-text">Auto</span>`;
|
||||
|
||||
// 3. Crear el Select Real (Invisible)
|
||||
const select = document.createElement('select');
|
||||
select.className = 'plyr__sr-only-select'; // Clase auxiliar si quieres depurar, sino usa el CSS wrapper
|
||||
select.className = 'plyr__sr-only-select';
|
||||
|
||||
// Opción AUTO
|
||||
const autoOpt = document.createElement('option');
|
||||
autoOpt.value = -1;
|
||||
autoOpt.textContent = 'Auto';
|
||||
select.appendChild(autoOpt);
|
||||
|
||||
// Opciones de Niveles
|
||||
levels.forEach((l, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = i;
|
||||
opt.textContent = `${l.height}p`; // Texto que sale en el dropdown nativo
|
||||
opt.textContent = `${l.height}p`;
|
||||
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Sincronizar estado inicial
|
||||
select.value = hls.currentLevel;
|
||||
updateLabel(select.value);
|
||||
|
||||
// Evento Change
|
||||
select.onchange = () => {
|
||||
hls.currentLevel = Number(select.value);
|
||||
updateLabel(select.value);
|
||||
@@ -551,7 +845,7 @@ const AnimePlayer = (function() {
|
||||
const index = Number(val);
|
||||
let text = 'Auto';
|
||||
if (index !== -1 && levels[index]) {
|
||||
// Solo el número + p (ej: 720p)
|
||||
|
||||
text = `${levels[index].height}p`;
|
||||
}
|
||||
btn.innerHTML = `<span class="label-text">${text}</span>`;
|
||||
@@ -560,8 +854,6 @@ const AnimePlayer = (function() {
|
||||
wrapper.appendChild(select);
|
||||
wrapper.appendChild(btn);
|
||||
|
||||
// Insertar en controles Plyr (antes del botón de pantalla completa o ajustes)
|
||||
// Insertamos antes del 5º elemento (usualmente settings o fullscreen)
|
||||
const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1;
|
||||
controls.insertBefore(wrapper, controls.children[insertIndex]);
|
||||
}
|
||||
@@ -573,17 +865,14 @@ const AnimePlayer = (function() {
|
||||
const controls = plyrEl.querySelector('.plyr__controls');
|
||||
if (!controls || controls.querySelector('#audio-control-wrapper')) return;
|
||||
|
||||
// 1. Wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
||||
wrapper.id = 'audio-control-wrapper';
|
||||
|
||||
// 2. Botón Visual
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'plyr__custom-control-btn';
|
||||
btn.innerHTML = `<span class="label-text">Audio 1</span>`;
|
||||
|
||||
// 3. Select Invisible
|
||||
const select = document.createElement('select');
|
||||
|
||||
hls.audioTracks.forEach((t, i) => {
|
||||
@@ -605,10 +894,8 @@ const AnimePlayer = (function() {
|
||||
const index = Number(val);
|
||||
const track = hls.audioTracks[index];
|
||||
|
||||
// Priorizamos el idioma (lang), luego el nombre
|
||||
let rawText = track.lang || track.name || `A${index + 1}`;
|
||||
|
||||
// Tomamos solo las 2 primeras letras y las pasamos a Mayúsculas
|
||||
let shortText = rawText.substring(0, 2).toUpperCase();
|
||||
|
||||
btn.querySelector('.label-text').innerText = shortText;
|
||||
@@ -617,7 +904,6 @@ const AnimePlayer = (function() {
|
||||
wrapper.appendChild(select);
|
||||
wrapper.appendChild(btn);
|
||||
|
||||
// Insertar antes del selector de calidad si existe, o en la posición 4
|
||||
const qualityWrapper = controls.querySelector('#quality-control-wrapper');
|
||||
if(qualityWrapper) {
|
||||
controls.insertBefore(wrapper, qualityWrapper);
|
||||
|
||||
Reference in New Issue
Block a user