Files
ohm_streaming/static/js/anime-details.js
T
root 819acf04f8 feat: redesign download UX — batch select, season download, toast feedback
Episode list:
- Added 'Saison complète' header button to download all episodes at once
- Added multi-select mode with checkboxes for batch episode download
- Individual download buttons now show visual feedback (checkmark + reset)
- Better grid/list toggle with selection state indicators

Search results (anime + series):
- Redesigned download dropdown with icons, descriptions, spinner on click
- Smooth scale/opacity transitions on dropdown open/close
- Consistent btn-success color for all download actions

Series search JS:
- Replaced basic <select> with scrollable episode list inline
- Added 'Tout télécharger' button per series card
- Replaced all alert() calls with toast notifications
- Episode buttons show checkmark on successful download

Anime details JS:
- Added batch download button next to episode select
- Fixed pre-existing lint error (escaped quote in translateSynopsis)
- Standardized download icon to fa-arrow-down across all cards

Recommendations + Tabs JS:
- Unified download button color (btn-success) across all card types
- Consistent icon (fa-arrow-down) for download actions

Toast system:
- Connected to existing Alpine.js toast infrastructure (show-toast events)
2026-04-11 21:08:29 +00:00

611 lines
25 KiB
JavaScript

// Anime details module
// Search anime and display details
async function searchAnimeDetails(query, malId = null) {
const resultsContainer = document.getElementById('animeSearchResults');
if (!resultsContainer) return;
try {
resultsContainer.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Recherche en cours...</span></div>';
// If we have a MAL ID, fetch directly by ID, otherwise search by query
let malUrl;
if (malId) {
malUrl = `${API_BASE}/anime/mal/${malId}`;
} else {
malUrl = `${API_BASE}/anime/mal/search?q=${encodeURIComponent(query)}&limit=5`;
}
// Search MAL and get streaming results in parallel
const [malResponse, streamingData] = await Promise.allSettled([
fetch(malUrl),
searchAnime(query, 'vostfr', false)
]);
let animeData = null;
let malFound = false;
// Check MAL search results
if (malResponse.status === 'fulfilled') {
try {
// malResponse.value is the Response object from fetch
const response = malResponse.value;
// Check if the HTTP request was successful
if (response.ok) {
const data = await response.json();
console.log('MAL search response:', data);
// Handle both direct ID response and search response
if (data.anime) {
animeData = data.anime;
malFound = true;
} else if (data.mal_id) {
// Direct MAL ID response
animeData = data;
malFound = true;
}
} else {
console.warn(`MAL search returned HTTP ${response.status}`);
}
} catch (e) {
console.error('Error parsing MAL response:', e);
}
} else {
console.error('MAL search promise rejected:', malResponse.reason);
}
// Build streaming results HTML
let streamingHtml = '';
if (streamingData.status === 'fulfilled' && streamingData.value && streamingData.value.results) {
const providersData = await getProvidersInfo();
// Build results HTML
const streamingParts = [];
let hasResults = false;
// Display results from each provider - render all cards in parallel
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
if (results && results.length > 0) {
hasResults = true;
const provider = providersData.anime_providers[providerId];
// Render all cards for this provider
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
const cards = await Promise.all(cardPromises);
streamingParts.push(...cards);
}
}
// Only add header and wrapper if we have results
if (hasResults) {
streamingParts.unshift(
`<div class="flex items-center gap-2 mb-4 mt-5">
<h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">`
);
streamingParts.push('</div>');
streamingHtml = streamingParts.join('');
}
}
// Display results
if (malFound && animeData) {
// We found MAL data - display anime details card
let html = renderAnimeDetails(animeData);
// Append streaming results if available
html += streamingHtml;
resultsContainer.innerHTML = html;
// Now load seasons after HTML is in DOM
if (streamingData.status === 'fulfilled' && streamingData.value && streamingData.value.results) {
loadStreamingResultsSeasons(streamingData.value.results);
}
} else {
// MAL found nothing but we have streaming results
if (streamingHtml) {
resultsContainer.innerHTML = `
<div class="text-center py-12 text-base-content/50 mb-5">
<i class="fa-solid fa-circle-info text-3xl mb-3 block"></i>
<p>Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
<p class="text-xs mt-2 text-base-content/40">
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
</p>
</div>
${streamingHtml}
`;
// Now load seasons after HTML is in DOM
if (streamingData.status === 'fulfilled' && streamingData.value && streamingData.value.results) {
loadStreamingResultsSeasons(streamingData.value.results);
}
} else {
resultsContainer.innerHTML = `
<div class="text-center py-16 text-base-content/50">
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
<p>Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
<p class="text-xs mt-2 text-base-content/40">
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
</p>
</div>
`;
}
}
} catch (error) {
console.error('Error searching anime details:', error);
resultsContainer.innerHTML = `
<div class="text-center py-16 text-base-content/50">
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
<p>Erreur lors de la recherche.</p>
<p class="text-xs mt-2 text-error">${error.message}</p>
</div>
`;
}
}
// Get provider search results as HTML
async function getProviderSearchResults(query) {
try {
// Use the existing searchAnime function
const data = await searchAnime(query, 'vostfr', false);
if (!data.results) {
return '';
}
// Build results HTML
const htmlParts = [];
let hasResults = false;
// Display results from each provider
for (const [providerId, results] of Object.entries(data.results)) {
if (results && results.length > 0) {
hasResults = true;
const providersData = await getProvidersInfo();
const provider = providersData.anime_providers[providerId];
// Render all cards for this provider in parallel
const cardPromises = results.map((anime) => renderAnimeCard(anime, providerId, provider, 'vostfr'));
const cards = await Promise.all(cardPromises);
htmlParts.push(...cards);
}
}
// Only add header and wrapper if we have results
if (hasResults) {
htmlParts.unshift(
`<div class="flex items-center gap-2 mb-4 mt-5">
<h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">`
);
htmlParts.push('</div>');
}
return htmlParts.join('');
} catch (error) {
console.error('Error getting provider search results:', error);
return '';
}
}
// After displaying streaming results, load seasons for Anime-Sama
async function loadStreamingResultsSeasons(providerResults) {
// providerResults should be the data.results object
let delayCounter = 0;
for (const [providerId, results] of Object.entries(providerResults)) {
if (results && results.length > 0) {
results.forEach((anime, index) => {
// Only load seasons for Anime-Sama
if (providerId === 'animesama' || (anime.url && anime.url.includes('anime-sama'))) {
// Stagger requests: 500ms delay between each anime
setTimeout(() => {
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
}, 500 * delayCounter);
delayCounter++;
}
});
}
}
}
// Render anime details card
function renderAnimeDetails(anime) {
const images = anime.images || {};
const imageUrl = images.jpg?.large_image_url || images.jpg?.image_url || images.webp?.large_image_url || '';
const genres = anime.genres || [];
const themes = anime.themes || [];
const studios = anime.studios || [];
const score = anime.score || 0;
const rank = anime.rank || 0;
const popularity = anime.popularity || 0;
const synopsis = anime.synopsis || '';
const related = anime.related || [];
// Generate unique ID for synopsis element
const synopsisId = `synopsis-${anime.mal_id}`;
// Filter only seasons (Sequel, Prequel)
const seasons = related.filter(r => {
const relationType = r.type?.toLowerCase() || '';
return relationType === 'sequel' || relationType === 'prequel';
});
return `
<div class="card bg-base-200 border border-base-300 shadow-lg">
<!-- Header with poster and basic info -->
<div class="flex flex-col md:flex-row gap-4 p-4">
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="w-40 h-56 object-cover rounded-lg shrink-0">` : ''}
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold">${escapeHtml(anime.title)}</h2>
${anime.title_english && anime.title_english !== anime.title ? `
<p class="text-sm text-base-content/60">${escapeHtml(anime.title_english)}</p>
` : ''}
<div class="flex flex-wrap gap-2 mt-2">
${score > 0 ? `<span class="badge badge-warning badge-sm"><i class="fa-solid fa-star"></i> ${score.toFixed(2)}</span>` : ''}
${rank > 0 ? `<span class="badge badge-outline badge-sm">#${rank}</span>` : ''}
${popularity > 0 ? `<span class="badge badge-ghost badge-sm">Popularité #${popularity}</span>` : ''}
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-3 text-sm text-base-content/70">
${anime.episodes ? `<span><i class="fa-solid fa-tv"></i> ${anime.episodes} épisodes</span>` : ''}
${anime.status ? `<span><i class="fa-solid fa-tower-broadcast"></i> ${translateStatus(anime.status)}</span>` : ''}
${anime.duration ? `<span><i class="fa-solid fa-clock"></i> ${escapeHtml(anime.duration)}</span>` : ''}
${anime.year ? `<span><i class="fa-solid fa-calendar"></i> ${anime.year}</span>` : ''}
</div>
${studios.length > 0 ? `
<div class="text-sm mt-2 text-base-content/60">
Studio: ${studios.map(s => escapeHtml(s)).join(', ')}
</div>
` : ''}
<div class="flex flex-wrap gap-2 mt-3">
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-link"></i> Voir sur MAL
</a>
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-success btn-sm">
<i class="fa-solid fa-arrow-down"></i> Télécharger
</button>
</div>
</div>
</div>
<!-- Genres and themes -->
${(genres.length > 0 || themes.length > 0) ? `
<div class="px-4 pb-3 flex flex-wrap gap-1">
${genres.map(g => `<span class="badge badge-primary badge-outline badge-sm">${escapeHtml(g)}</span>`).join('')}
${themes.map(t => `<span class="badge badge-accent badge-outline badge-sm">${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
<!-- Synopsis with translation button -->
${synopsis ? `
<div class="px-4 pb-4">
<div class="flex justify-between items-center mb-2">
<h3 class="font-semibold"><i class="fa-solid fa-book"></i> Synopsis</h3>
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-sm btn-xs">
<i class="fa-solid fa-globe"></i> Traduire en français
</button>
</div>
<p id="${synopsisId}" class="text-sm text-base-content/80 leading-relaxed">${escapeHtml(synopsis)}</p>
</div>
` : ''}
<!-- Seasons (Sequel/Prequel) -->
${seasons.length > 0 ? `
<div class="px-4 pb-4">
<h3 class="font-semibold mb-2"><i class="fa-solid fa-tv"></i> Saisons</h3>
<div class="space-y-3">
${seasons.map(season => `
<div>
<div class="badge badge-info badge-sm mb-1">${translateRelationType(season.type)}</div>
<div class="space-y-1">
${season.entries.map(entry => `
<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-base-300/50 cursor-pointer"
onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})">
${entry.type ? `<span class="badge badge-warning badge-sm shrink-0">${escapeHtml(entry.type)}</span>` : ''}
<span class="text-sm">${escapeHtml(entry.title)}</span>
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" class="ml-auto text-base-content/30 hover:text-base-content text-lg" title="Voir sur MyAnimeList" onclick="event.stopPropagation()">↗</a>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
}
// Load streaming results from providers
async function loadStreamingResults(query) {
const container = document.getElementById('streamingResults');
if (!container) return;
try {
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Recherche des sources de streaming...</span></div>';
// Load providers info
const providersData = await getProvidersInfo();
const animeProviders = Object.entries(providersData.anime_providers);
// Search on all providers
const results = await Promise.allSettled(
animeProviders.map(([id, provider]) =>
loadEpisodes(null, query).then(episodes => ({
provider: id,
name: provider.name,
icon: provider.icon,
episodes: episodes.episodes || []
}))
)
);
// Filter successful results
const successfulResults = results
.filter(r => r.status === 'fulfilled' && r.value.episodes.length > 0)
.map(r => r.value);
if (successfulResults.length === 0) {
container.innerHTML = `
<div class="text-center py-16 text-base-content/50">
<i class="fa-solid fa-triangle-exclamation text-3xl mb-3 block"></i>
<p>Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
</div>
`;
return;
}
// Display results
container.innerHTML = `
<div class="flex items-center gap-2 mb-4">
<h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Disponible sur</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
</div>
`;
} catch (error) {
console.error('Error loading streaming results:', error);
container.innerHTML = `
<div class="text-center py-16 text-base-content/50">
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
<p>Erreur lors de la recherche des sources de streaming.</p>
</div>
`;
}
}
// Render a single streaming result
function renderStreamingResult(result, query) {
const { provider, name, icon, episodes } = result;
return `
<div class="card bg-base-200 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-lg">${icon}</span>
<span class="font-semibold text-sm">${escapeHtml(name)}</span>
</div>
<span class="badge badge-ghost badge-sm">${episodes.length} épisodes</span>
</div>
<div class="space-y-2">
<select class="select select-bordered select-sm w-full streaming-episode-select" data-provider="${provider}" data-query="${escapeHtml(query)}">
<option value="">Sélectionner un épisode</option>
${episodes.slice(0, 20).map(ep => `
<option value="${escapeHtml(ep.url)}">Épisode ${ep.episode}</option>
`).join('')}
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
</select>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm flex-1 streaming-download-btn" onclick="downloadSelectedEpisode(this)">
<i class="fa-solid fa-download"></i> Télécharger
</button>
<button class="btn btn-success btn-sm streaming-download-all-btn"
onclick="downloadAllEpisodes(this, '${escapeHtml(query)}', '${escapeHtml(provider)}')"
title="Télécharger toute la saison">
<i class="fas fa-layer-group"></i>
</button>
</div>
</div>
<a href="#" class="link link-hover text-xs mt-2" onclick="searchAnimeOnProviders('${escapeHtml(query)}'); return false;">
Voir tous les épisodes sur ${escapeHtml(name)}
</a>
</div>
</div>
`;
}
// Download all episodes from a streaming result card
async function downloadAllEpisodes(button, query, provider) {
const card = button.closest('.card');
const select = card.querySelector('.streaming-episode-select');
const totalEps = select.options.length - 1; // exclude disabled options
const hasMore = select.querySelector('option[disabled]');
button.disabled = true;
const originalHtml = button.innerHTML;
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span>';
let completed = 0;
const promises = [];
for (const option of select.options) {
if (!option.value || option.disabled) continue;
promises.push(
fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(option.value)}`, { method: 'POST' })
.then(r => { completed++; return r; })
);
}
const results = await Promise.allSettled(promises);
const successCount = results.filter(r => r.status === 'fulfilled').length;
button.innerHTML = '<i class="fas fa-check"></i>';
showToast(`${successCount} épisodes mis en file de téléchargement`);
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 4000);
// Refresh downloads list
if (typeof loadDownloads === 'function') {
loadDownloads();
}
}
// Download selected episode from streaming results
async function downloadSelectedEpisode(button) {
const select = button.parentElement.querySelector('.streaming-episode-select');
const episodeUrl = select.value;
if (!episodeUrl) {
alert('Veuillez sélectionner un épisode');
return;
}
try {
await downloadEpisode(episodeUrl);
loadDownloads();
} catch (error) {
console.error('Download error:', error);
alert('Erreur lors du téléchargement');
}
}
// Translate status
function translateStatus(status) {
const translations = {
'Airing': 'En cours',
'Finished Airing': 'Terminé',
'To Be Aired': 'À venir',
'Currently Airing': 'En cours'
};
return translations[status] || status;
}
// Translate relation type to French
function translateRelationType(type) {
const translations = {
'Sequel': 'Suite',
'Prequel': 'Préquelle',
'Spin-off': 'Spin-off',
'Side Story': 'Histoire secondaire',
'Summary': 'Résumé',
'Other': 'Autre',
'Alternative Setting': 'Version alternative',
'Full Story': 'Histoire complète'
};
return translations[type] || type;
}
// Translate synopsis to French using backend API
async function translateSynopsis(synopsisId, button) {
const synopsisElement = document.getElementById(synopsisId);
if (!synopsisElement) return;
// Get original text (use textContent to get pure text without HTML)
const originalText = synopsisElement.dataset.original || synopsisElement.textContent;
// Check if already translated
if (synopsisElement.dataset.translated === 'true') {
// Revert to original
synopsisElement.textContent = originalText;
synopsisElement.dataset.translated = 'false';
button.innerHTML = '<i class="fa-solid fa-globe"></i> Traduire en français';
return;
}
// Store original text
synopsisElement.dataset.original = originalText;
// Show loading state
button.disabled = true;
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Traduction...';
synopsisElement.style.opacity = '0.5';
try {
console.log('Translating text (first 100 chars):', originalText.substring(0, 100) + '...');
// Use backend translation API
const response = await fetch(`${API_BASE}/translate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: originalText.substring(0, 5000)
})
});
console.log('Translation API response status:', response.status);
if (response.ok) {
const data = await response.json();
console.log('Translation successful!');
synopsisElement.textContent = data.translatedText;
synopsisElement.dataset.translated = 'true';
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l&#39;original';
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
console.error('Translation API error:', errorData);
throw new Error(errorData.detail || 'Translation failed');
}
} catch (error) {
console.error('Translation error:', error);
synopsisElement.style.opacity = '1';
// Show user-friendly error using DaisyUI alert styling
const errorMessage = document.createElement('div');
errorMessage.className = 'alert alert-error alert-sm mt-2 text-xs translation-error';
errorMessage.innerHTML = `
<i class="fa-solid fa-triangle-exclamation"></i>
<span>Service de traduction temporairement indisponible. Essayez à nouveau dans quelques instants.</span>
`;
// Remove existing error message if any
const existingError = synopsisElement.parentElement.querySelector('.translation-error');
if (existingError) {
existingError.remove();
}
synopsisElement.parentElement.appendChild(errorMessage);
// Auto-remove error after 5 seconds
setTimeout(() => {
if (errorMessage.parentElement) {
errorMessage.remove();
}
}, 5000);
} finally {
button.disabled = false;
synopsisElement.style.opacity = '1';
}
}
// Fallback translation - kept for compatibility but no longer used
async function fallbackTranslation(text, synopsisElement, button) {
// This function is deprecated since we now use backend translation
console.log('Fallback translation called (should not happen)');
}