import { type ChannelSection, searchChannel } from '../../api/channelApi'; import { type PlaylistDto, fetchPlaylist } from '../../api/playlistApi'; import { type EnqueueMode, enqueueItem } from '../../api/queueApi'; import type { VideoDto } from '../../api/videoDto'; import { formatDuration, formatUploadDate } from '../../format'; import { isPlaylistUrl } from '../../playlistUrl'; 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 ou plusieurs éléments à la file d'attente. */ onItemQueued: () => void; } /** Ligne de la liste de résultats : une vidéo et son bouton d'ajout. */ interface ResultRow { /** Vidéo affichée sur cette ligne. */ readonly video: VideoDto; /** Bouton « Ajouter » de la ligne (désactivé une fois la vidéo ajoutée, ou pendant son ajout). */ readonly button: HTMLButtonElement; } /** Options d'affichage d'une liste de résultats. */ interface ShowVideosOptions { /** Message de statut affiché au-dessus de la liste (ex. « 12 résultat(s). »). */ readonly status: string; /** Affiche le bouton « Tout ajouter à la file ». */ readonly allowAddAll: boolean; } /** Zone de résultats partagée entre la recherche de chaîne et le chargement d'une playlist. */ interface ResultsView { /** Vide la liste et affiche un message d'attente. */ showLoading: (message: string) => void; /** Vide et masque toute la zone de résultats. */ clear: () => void; /** Affiche les vidéos fournies, avec leur message de statut. */ showVideos: (videos: readonly VideoDto[], options: ShowVideosOptions) => void; } const ADD_ALL_LABEL = 'Tout ajouter à la file'; /** * Récupère un élément du gabarit, en échouant franchement s'il manque : le * gabarit est statique, son absence est une erreur de programmation, pas un * cas à ignorer silencieusement. * * @param root - Racine du formulaire monté. * @param selector - Sélecteur CSS de l'élément attendu. * @returns L'élément trouvé. * @throws {Error} Si le gabarit ne contient pas l'élément. */ function requireElement(root: HTMLElement, selector: string): T { const element = root.querySelector(selector); if (!element) { throw new Error(`Élément introuvable dans le gabarit : ${selector}`); } return element; } /** * 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 ?? ''; } /** * @param error - Erreur capturée (de type inconnu). * @param fallback - Message à utiliser si `error` n'est pas une `Error`. * @returns Le message à présenter à l'utilisateur. */ function describeError(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } /** * 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 la vidéo d'une ligne à la file d'attente et marque son bouton * « Ajouté ✓ ». Le bouton est désactivé dès le début (pas de double ajout * pendant la requête) et réactivé si l'ajout échoue. * * @param row - Ligne de résultat à ajouter. * @param mode - Mode de téléchargement à appliquer. * @throws {Error} Si l'ajout échoue. */ async function enqueueResult(row: ResultRow, mode: EnqueueMode): Promise { row.button.disabled = true; try { await enqueueItem(row.video.url, mode, row.video.title, row.video.thumbnailUrl); row.button.textContent = 'Ajouté ✓'; } catch (error) { row.button.disabled = false; throw error; } } /** * Construit l'élément de liste d'une vidéo (miniature, titre, durée/date et * bouton d'ajout), sans y brancher de comportement. * * @param video - Vidéo à afficher. * @returns L'élément `
  • ` et son bouton « Ajouter ». */ function renderResultItem(video: VideoDto): { element: HTMLLIElement; button: HTMLButtonElement; } { const element = document.createElement('li'); element.className = 'download-form__result'; if (video.thumbnailUrl) { const img = document.createElement('img'); img.src = video.thumbnailUrl; img.alt = ''; // Une playlist ou une chaîne peut compter des centaines de vidéos : // sans chargement différé, chaque miniature serait demandée d'un coup. img.loading = 'lazy'; img.className = 'download-form__result-thumb'; element.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); element.append(info); const button = document.createElement('button'); button.type = 'button'; button.textContent = 'Ajouter'; element.append(button); return { element, button }; } /** * Câble la zone de résultats partagée : liste de vidéos avec un bouton * « Ajouter » par ligne, et, quand la liste le permet (playlist), un bouton * « Tout ajouter à la file ». * * @param root - Racine du formulaire monté. * @param callbacks - Rappels du formulaire. * @returns La vue permettant d'y afficher des résultats. */ function createResultsView(root: HTMLElement, callbacks: DownloadFormCallbacks): ResultsView { const header = requireElement(root, '[data-role="results-header"]'); const statusEl = requireElement(root, '[data-role="results-status"]'); const addAllButton = requireElement(root, '[data-role="add-all"]'); const errorEl = requireElement(root, '[data-role="results-error"]'); const list = requireElement(root, '[data-role="results"]'); let rows: ResultRow[] = []; // Incrémenté à chaque remplacement de la liste : une opération d'ajout // encore en cours sur l'ancienne liste ne doit plus toucher à l'affichage // (bouton, message d'erreur) de la nouvelle. let generation = 0; const clear = (): void => { generation += 1; rows = []; list.replaceChildren(); addAllButton.hidden = true; setMessage(errorEl, null); header.hidden = true; }; const showLoading = (message: string): void => { clear(); setMessage(statusEl, message); header.hidden = false; }; const addOne = (row: ResultRow): void => { const startedIn = generation; setMessage(errorEl, null); enqueueResult(row, readCurrentMode(root)) .then(() => { callbacks.onItemQueued(); }) .catch((error: unknown) => { if (startedIn === generation) { setMessage(errorEl, describeError(error, "Échec de l'ajout.")); } }); }; const addAll = async (): Promise => { const startedIn = generation; const pending = rows.filter((row) => !row.button.disabled); if (pending.length === 0) { return; } // Mode lu une seule fois : changer de mode en cours d'ajout ne doit pas // mélanger les modes au sein d'un même lot. const mode = readCurrentMode(root); addAllButton.disabled = true; setMessage(errorEl, null); let processed = 0; let failures = 0; let firstFailure: string | null = null; // Ajouts séquentiels : l'ordre de la playlist est conservé dans la file. for (const row of pending) { if (row.button.disabled) { // Ajoutée entre-temps depuis son propre bouton. continue; } processed += 1; if (startedIn === generation) { addAllButton.textContent = `Ajout… ${processed}/${pending.length}`; } try { await enqueueResult(row, mode); } catch (error) { failures += 1; firstFailure ??= describeError(error, "Échec de l'ajout."); } } callbacks.onItemQueued(); if (startedIn !== generation) { return; } if (failures === 0) { addAllButton.textContent = 'Tout ajouté ✓'; return; } addAllButton.disabled = false; addAllButton.textContent = ADD_ALL_LABEL; setMessage( errorEl, `${failures} vidéo(s) n'ont pas pu être ajoutée(s) : ${firstFailure ?? 'erreur inconnue'}`, ); }; addAllButton.addEventListener('click', () => { addAll().catch((error: unknown) => { console.error("Erreur inattendue lors de l'ajout groupé", error); }); }); const showVideos = (videos: readonly VideoDto[], options: ShowVideosOptions): void => { clear(); setMessage(statusEl, options.status); header.hidden = false; addAllButton.hidden = !options.allowAddAll; addAllButton.disabled = false; addAllButton.textContent = ADD_ALL_LABEL; const fragment = document.createDocumentFragment(); for (const video of videos) { const { element, button } = renderResultItem(video); const row: ResultRow = { video, button }; button.addEventListener('click', () => { addOne(row); }); rows.push(row); fragment.append(element); } list.replaceChildren(fragment); }; return { showLoading, clear, showVideos }; } /** * Décrit une playlist chargée pour la ligne de statut de la liste. * * @param playlist - Playlist chargée. * @returns Un texte du type « Playlist « Titre » : 12 vidéo(s). ». */ function describePlaylist(playlist: PlaylistDto): string { const name = playlist.title ? `Playlist « ${playlist.title} »` : 'Playlist'; return playlist.videos.length === 0 ? `${name} : aucune vidéo.` : `${name} : ${playlist.videos.length} vidéo(s).`; } /** * Charge une playlist et affiche ses vidéos dans la zone de résultats. * * @param url - Lien de la playlist. * @param results - Zone de résultats où afficher les vidéos. * @throws {Error} Si le chargement échoue (la zone de résultats est alors vidée). */ async function browsePlaylist(url: string, results: ResultsView): Promise { results.showLoading('Chargement de la playlist...'); let playlist: PlaylistDto; try { playlist = await fetchPlaylist(url); } catch (error) { results.clear(); throw error; } results.showVideos(playlist.videos, { status: describePlaylist(playlist), allowAddAll: playlist.videos.length > 0, }); } /** * Câble le formulaire d'ajout par URL : un lien de vidéo est ajouté * directement à la file, un lien de playlist affiche ses vidéos dans la zone * de résultats (à ajouter une par une ou d'un coup). * * @param root - Racine du formulaire monté. * @param results - Zone de résultats partagée. * @param callbacks - Rappels du formulaire. */ function wireUrlForm( root: HTMLElement, results: ResultsView, 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 { if (isPlaylistUrl(url)) { await browsePlaylist(url, results); } else { await enqueueItem(url, readCurrentMode(root), null); callbacks.onItemQueued(); } form.reset(); } catch (error) { setMessage(errorEl, describeError(error, "É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 results - Zone de résultats partagée. */ function wireChannelForm(root: HTMLElement, results: ResultsView): void { const form = root.querySelector('form[data-form="channel"]'); const errorEl = root.querySelector('[data-role="channel-error"]'); if (!form) { 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); results.showLoading('Recherche en cours...'); const submitButton = form.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; } try { const videos = await searchChannel(channel, section, keywords); results.showVideos(videos, { status: videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`, allowAddAll: false, }); } catch (error) { results.clear(); setMessage(errorEl, describeError(error, 'É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 ou de playlist, et 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; const results = createResultsView(root, callbacks); wireModeToggle(root); wireUrlForm(root, results, callbacks); wireChannelForm(root, results); }