import { type ChannelSection, type ChannelVideoDto, searchChannel } from '../../api/channelApi'; import { type EnqueueMode, enqueueItem } from '../../api/queueApi'; import { formatDuration, formatUploadDate } from '../../format'; import downloadFormTemplate from './DownloadForm.html'; import './DownloadForm.scss'; /** Rappels fournis par l'appelant pour réagir aux actions du formulaire. */ export interface DownloadFormCallbacks { /** Appelé juste après l'ajout réussi d'un élément à la file d'attente. */ onItemQueued: () => void; } /** * Affiche ou efface un message dans un élément de statut/erreur. * * @param el - Élément cible, ou `null` s'il n'a pas été trouvé dans le gabarit. * @param message - Message à afficher, ou `null` pour masquer l'élément. */ function setMessage(el: HTMLElement | null, message: string | null): void { if (!el) { return; } el.hidden = message === null; el.textContent = message ?? ''; } /** * Lit le mode de téléchargement et ses options depuis les contrôles * actuellement sélectionnés dans le formulaire. * * @param root - Racine du formulaire monté. * @returns Le mode à utiliser pour le prochain élément ajouté. */ function readCurrentMode(root: HTMLElement): EnqueueMode { const selected = root.querySelector('input[name="mode"]:checked')?.value ?? 'video'; if (selected === 'audio') { const audioFormat = (root.querySelector('[data-field="audioFormat"]')?.value as | 'mp3' | 'm4a' | 'opus' | undefined) ?? 'mp3'; return { mode: 'audio', audioFormat }; } if (selected === 'thumbnail') { return { mode: 'thumbnail' }; } return { mode: 'video', subtitles: root.querySelector('[data-field="subtitles"]')?.checked ?? false, embedThumbnail: root.querySelector('[data-field="embedThumbnail"]')?.checked ?? false, }; } /** * Affiche/masque les panneaux d'options quand le mode sélectionné change. * * @param root - Racine du formulaire monté. */ function wireModeToggle(root: HTMLElement): void { const fieldset = root.querySelector('.download-form__mode'); const optionPanels = root.querySelectorAll('[data-mode-options]'); fieldset?.addEventListener('change', () => { const selected = root.querySelector('input[name="mode"]:checked')?.value; for (const panel of optionPanels) { panel.hidden = panel.dataset.modeOptions !== selected; } }); } /** * Ajoute un résultat de recherche de chaîne à la file d'attente, avec * retour visuel sur le bouton concerné. * * @param video - Vidéo à ajouter. * @param formRoot - Racine du formulaire (pour lire le mode courant). * @param button - Bouton "Ajouter" cliqué, pour le désactiver pendant la requête. * @param errorEl - Élément où afficher une éventuelle erreur. * @param callbacks - Rappels du formulaire. */ async function handleAddResult( video: ChannelVideoDto, formRoot: HTMLElement, button: HTMLButtonElement, errorEl: HTMLElement | null, callbacks: DownloadFormCallbacks, ): Promise { button.disabled = true; setMessage(errorEl, null); try { await enqueueItem(video.url, readCurrentMode(formRoot), video.title, video.thumbnailUrl); button.textContent = 'Ajouté ✓'; callbacks.onItemQueued(); } catch (error) { button.disabled = false; setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout."); } } /** * Affiche la liste des résultats d'une recherche de chaîne. * * @param container - Élément `
    ` dans lequel rendre les résultats. * @param videos - Vidéos à afficher. * @param formRoot - Racine du formulaire (pour lire le mode courant lors d'un ajout). * @param errorEl - Élément où afficher une éventuelle erreur d'ajout. * @param callbacks - Rappels du formulaire. */ function renderResults( container: HTMLElement, videos: readonly ChannelVideoDto[], formRoot: HTMLElement, errorEl: HTMLElement | null, callbacks: DownloadFormCallbacks, ): void { container.replaceChildren(); for (const video of videos) { const item = document.createElement('li'); item.className = 'download-form__result'; if (video.thumbnailUrl) { const img = document.createElement('img'); img.src = video.thumbnailUrl; img.alt = ''; img.className = 'download-form__result-thumb'; item.append(img); } const info = document.createElement('div'); info.className = 'download-form__result-info'; const title = document.createElement('p'); title.className = 'download-form__result-title'; title.textContent = video.title; info.append(title); const meta = document.createElement('p'); meta.className = 'download-form__result-meta'; meta.textContent = [formatDuration(video.durationSeconds), formatUploadDate(video.uploadDate)] .filter((part) => part.length > 0) .join(' · '); info.append(meta); item.append(info); const addButton = document.createElement('button'); addButton.type = 'button'; addButton.textContent = 'Ajouter'; addButton.addEventListener('click', () => { handleAddResult(video, formRoot, addButton, errorEl, callbacks).catch((error: unknown) => { console.error('Erreur inattendue lors de l’ajout depuis la recherche', error); }); }); item.append(addButton); container.append(item); } } /** * Câble le formulaire d'ajout direct par URL. * * @param root - Racine du formulaire monté. * @param callbacks - Rappels du formulaire. */ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { const form = root.querySelector('form[data-form="url"]'); const errorEl = root.querySelector('[data-role="url-error"]'); if (!form) { return; } const handleSubmit = async (event: SubmitEvent): Promise => { event.preventDefault(); const url = form.querySelector('input[name="url"]')?.value.trim() ?? ''; if (!url) { return; } setMessage(errorEl, null); const submitButton = form.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; } try { await enqueueItem(url, readCurrentMode(root), null); form.reset(); callbacks.onItemQueued(); } catch (error) { setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout à la file."); } finally { if (submitButton) { submitButton.disabled = false; } } }; form.addEventListener('submit', (event) => { handleSubmit(event).catch((error: unknown) => { console.error('Erreur inattendue lors de l’ajout par URL', error); }); }); } /** * Câble le formulaire de recherche de chaîne. * * @param root - Racine du formulaire monté. * @param callbacks - Rappels du formulaire. */ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { const form = root.querySelector('form[data-form="channel"]'); const errorEl = root.querySelector('[data-role="channel-error"]'); const statusEl = root.querySelector('[data-role="channel-status"]'); const resultsEl = root.querySelector('[data-role="results"]'); if (!form || !resultsEl) { return; } const handleSubmit = async (event: SubmitEvent): Promise => { event.preventDefault(); const formData = new FormData(form); const channel = String(formData.get('channel') ?? '').trim(); const section = String(formData.get('section') ?? 'videos') as ChannelSection; const keywords = String(formData.get('keywords') ?? ''); if (!channel) { return; } setMessage(errorEl, null); setMessage(statusEl, 'Recherche en cours...'); resultsEl.replaceChildren(); const submitButton = form.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; } try { const videos = await searchChannel(channel, section, keywords); setMessage( statusEl, videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`, ); renderResults(resultsEl, videos, root, errorEl, callbacks); } catch (error) { setMessage(statusEl, null); setMessage(errorEl, error instanceof Error ? error.message : 'Échec de la recherche.'); } finally { if (submitButton) { submitButton.disabled = false; } } }; form.addEventListener('submit', (event) => { handleSubmit(event).catch((error: unknown) => { console.error('Erreur inattendue lors de la recherche de chaîne', error); }); }); } /** * Monte le formulaire d'ajout (URL directe + recherche de chaîne) dans * l'élément racine fourni. * * @param root - Élément DOM dans lequel injecter le formulaire. * @param callbacks - Rappels invoqués sur les actions du formulaire. */ export function mountDownloadForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { root.innerHTML = downloadFormTemplate; wireModeToggle(root); wireUrlForm(root, callbacks); wireChannelForm(root, callbacks); }