1// extract-audio-widget.mjs â Extract the audio track from a video (or audio) 2// file and download it as MP3, M4A, or WAV. 100% client-side. 3// 4// Reuses the site's existing in-browser ffmpeg.wasm machinery (the same engine 5// the notes widget uses): the file never leaves the browser and nothing is sent 6// to a server â the audio is decoded, the selected range is re-encoded, and the 7// result is offered as a direct download. 8// 9// Flow: 10// 1. Pick/drop a file â read its duration. 11// 2. Choose a range (two time boxes) + an output format (MP3 / M4A / WAV). 12// 3. Extract + re-encode that range with ffmpeg.wasm. 13// 4. Preview the audio and download it. 14 15import { ensureFfmpeg, extractAudioToFormat, AudioExtractionError } from "./lib/ffmpeg-extractor.mjs"; 16import { mountRangeSelector } from "./lib/media-range-selector.mjs"; 17import { 18 AUDIO_EXPORT_FORMATS, 19 getExportFormat, 20 DEFAULT_AUDIO_EXPORT_FORMAT_ID, 21} from "./lib/audio-export-formats.mjs"; 22import { 23 h, 24 looksLikeMedia, 25 readMediaDuration, 26 parseTimecode, 27 formatTimestamp, 28 slugify, 29 triggerDownload, 30 formatBytes, 31} from "./lib/media-ui.mjs"; 32import { resolveWidgetLocale, pickStrings, applyUnlimitedRangeCopy } from "./lib/widget-i18n.mjs"; 33 34// Fallback range end when browser metadata cannot reveal the file duration. 35const DEFAULT_INITIAL_RANGE_SECONDS = 10 * 60; 36 37const STRINGS_BY_LOCALE = { 38 en: { 39 dropTitle: "Drop a video here, or browse", 40 dropHint: 41 "Pick a video or audio file â MP4, MOV, WebM, MKV, MP3, M4A. You'll choose a range and a format. Your file never leaves your browser.", 42 browse: "Choose a file", 43 rangeHeading: "Choose the range and format", 44 formatLabel: "Audio format", 45 rangeStartLabel: "Start", 46 rangeEndLabel: "End", 47 rangeHint: "Up to 10 minutes per extraction. Times are mm:ss (or h:mm:ss).", 48 rangeLongNote: 49 "This file is longer than 10 minutes â we'll extract the selected range. Extract additional parts one range at a time.", 50 durationUnknownNote: 51 "We couldn't read this file's length, so the end defaults to 10:00 â adjust it if you need less.", 52 extract: "Extract audio", 53 changeFile: "Choose a different file", 54 rangeInvalid: "Enter a valid start and end â end must be after start.", 55 rangeTooLong: "Please select a range of 10 minutes or less.", 56 tooLarge: "That file is too large (over 500 MB). Try a shorter clip.", 57 notMedia: "That doesn't look like a video or audio file. Pick an MP4, MOV, WebM, MKV, MP3, or M4A.", 58 loadingEngine: "Loading the audio engine (first run downloads it once)â¦", 59 extracting: "Extracting audioâ¦", 60 genericError: "Something went wrong extracting audio from this file. Please try again.", 61 tryAgain: "Adjust & retry", 62 startOver: "Start over", 63 resultHeading: "Your audio is ready", 64 download: "Download audio", 65 extractAnother: "Extract another range", 66 privacyNote: "Everything runs in your browser â your file is never uploaded.", 67 ariaChooseVideoFile: "Choose a video file", 68 fileNameFallback: "Your file", 69 lengthPrefix: "Length", 70 lengthUnknown: "Length unknown", 71 }, 72 de: { 73 dropTitle: "Lege hier ein Video ab oder durchsuche deine Dateien", 74 dropHint: 75 "Wähle eine Video- oder Audiodatei â MP4, MOV, WebM, MKV, MP3, M4A. Du legst einen Bereich und ein Format fest. Deine Datei verlässt nie deinen Browser.", 76 browse: "Datei auswählen", 77 rangeHeading: "Wähle Bereich und Format", 78 formatLabel: "Audioformat", 79 rangeStartLabel: "Start", 80 rangeEndLabel: "Ende", 81 rangeHint: "Bis zu 10 Minuten pro Extraktion. Zeiten im Format mm:ss (oder h:mm:ss).", 82 rangeLongNote: 83 "Diese Datei ist länger als 10 Minuten â wir extrahieren den ausgewählten Bereich. Extrahiere weitere Teile jeweils einen Bereich nach dem anderen.", 84 durationUnknownNote: 85 "Wir konnten die Länge dieser Datei nicht lesen, daher steht das Ende standardmäÃig auf 10:00 â passe es an, wenn du weniger brauchst.", 86 extract: "Audio extrahieren", 87 changeFile: "Eine andere Datei auswählen", 88 rangeInvalid: "Gib einen gültigen Start und ein gültiges Ende ein â das Ende muss nach dem Start liegen.", 89 rangeTooLong: "Bitte wähle einen Bereich von 10 Minuten oder weniger.", 90 tooLarge: "Diese Datei ist zu groà (über 500 MB). Versuche einen kürzeren Clip.", 91 notMedia: "Das sieht nicht nach einer Video- oder Audiodatei aus. Wähle eine MP4, MOV, WebM, MKV, MP3 oder M4A.", 92 loadingEngine: "Audio-Engine wird geladen (beim ersten Mal wird sie einmalig heruntergeladen)â¦", 93 extracting: "Audio wird extrahiertâ¦", 94 genericError: "Beim Extrahieren des Audios aus dieser Datei ist etwas schiefgelaufen. Bitte versuche es erneut.", 95 tryAgain: "Anpassen & erneut versuchen", 96 startOver: "Von vorn beginnen", 97 resultHeading: "Dein Audio ist fertig", 98 download: "Audio herunterladen", 99 extractAnother: "Einen weiteren Bereich extrahieren", 100 privacyNote: "Alles läuft in deinem Browser â deine Datei wird nie hochgeladen.", 101 ariaChooseVideoFile: "Eine Videodatei auswählen", 102 fileNameFallback: "Deine Datei", 103 lengthPrefix: "Länge", 104 lengthUnknown: "Länge unbekannt", 105 }, 106 fr: { 107 dropTitle: "Déposez une vidéo ici, ou parcourez", 108 dropHint: 109 "Choisissez un fichier vidéo ou audio â MP4, MOV, WebM, MKV, MP3, M4A. Vous choisirez une plage et un format. Votre fichier ne quitte jamais votre navigateur.", 110 browse: "Choisir un fichier", 111 rangeHeading: "Choisissez la plage et le format", 112 formatLabel: "Format audio", 113 rangeStartLabel: "Début", 114 rangeEndLabel: "Fin", 115 rangeHint: "Jusqu'à 10 minutes par extraction. Les temps sont au format mm:ss (ou h:mm:ss).", 116 rangeLongNote: 117 "Ce fichier dure plus de 10 minutes â nous extrairons la plage sélectionnée. Extrayez d'autres parties une plage à la fois.", 118 durationUnknownNote: 119 "Nous n'avons pas pu lire la durée de ce fichier ; la fin est donc fixée par défaut à 10:00 â ajustez-la si besoin.", 120 extract: "Extraire l'audio", 121 changeFile: "Choisir un autre fichier", 122 rangeInvalid: "Saisissez un début et une fin valides â la fin doit être après le début.", 123 rangeTooLong: "Veuillez sélectionner une plage de 10 minutes maximum.", 124 tooLarge: "Ce fichier est trop volumineux (plus de 500 MB). Essayez un extrait plus court.", 125 notMedia: "Cela ne ressemble pas à un fichier vidéo ou audio. Choisissez un MP4, MOV, WebM, MKV, MP3 ou M4A.", 126 loadingEngine: "Chargement du moteur audio (téléchargé une seule fois au premier lancement)â¦", 127 extracting: "Extraction de l'audioâ¦", 128 genericError: "Une erreur s'est produite lors de l'extraction de l'audio de ce fichier. Veuillez réessayer.", 129 tryAgain: "Ajuster et réessayer", 130 startOver: "Recommencer", 131 resultHeading: "Votre audio est prêt", 132 download: "Télécharger l'audio", 133 extractAnother: "Extraire une autre plage", 134 privacyNote: "Tout s'exécute dans votre navigateur â votre fichier n'est jamais téléversé.", 135 ariaChooseVideoFile: "Choisir un fichier vidéo", 136 fileNameFallback: "Votre fichier", 137 lengthPrefix: "Durée", 138 lengthUnknown: "Durée inconnue", 139 }, 140 es: { 141 dropTitle: "Arrastra un vÃdeo aquà o búscalo", 142 dropHint: 143 "Elige un archivo de vÃdeo o audio â MP4, MOV, WebM, MKV, MP3, M4A. Elegirás un fragmento y un formato. Tu archivo nunca sale de tu navegador.", 144 browse: "Elegir un archivo", 145 rangeHeading: "Elige el fragmento y el formato", 146 formatLabel: "Formato de audio", 147 rangeStartLabel: "Inicio", 148 rangeEndLabel: "Final", 149 rangeHint: "Hasta 10 minutos por extracción. Los tiempos son mm:ss (o h:mm:ss).", 150 rangeLongNote: 151 "Este archivo dura más de 10 minutos â extraeremos el fragmento seleccionado. Extrae más partes de una en una.", 152 durationUnknownNote: 153 "No pudimos leer la duración de este archivo, asà que el final se establece en 10:00 â ajústalo si necesitas menos.", 154 extract: "Extraer audio", 155 changeFile: "Elegir otro archivo",
156 rangeInvalid: "Introduce un inicio y un final válidos â el final debe ser posterior al inicio.", 157 rangeTooLong: "Selecciona un fragmento de 10 minutos o menos.", 158 tooLarge: "Ese archivo es demasiado grande (más de 500 MB). Prueba con un clip más corto.", 159 notMedia: "Eso no parece un archivo de vÃdeo o audio. Elige un MP4, MOV, WebM, MKV, MP3 o M4A.", 160 loadingEngine: "Cargando el motor de audio (la primera vez se descarga una sola vez)â¦", 161 extracting: "Extrayendo el audioâ¦", 162 genericError: "Algo salió mal al extraer el audio de este archivo. Inténtalo de nuevo.", 163 tryAgain: "Ajustar y reintentar", 164 startOver: "Empezar de nuevo", 165 resultHeading: "Tu audio está listo", 166 download: "Descargar audio", 167 extractAnother: "Extraer otro fragmento", 168 privacyNote: "Todo se ejecuta en tu navegador â tu archivo nunca se sube.", 169 ariaChooseVideoFile: "Elegir un archivo de vÃdeo", 170 fileNameFallback: "Tu archivo", 171 lengthPrefix: "Duración", 172 lengthUnknown: "Duración desconocida", 173 }, 174 it: { 175 dropTitle: "Trascina qui un video, oppure scegline uno", 176 dropHint: 177 "Scegli un file video o audio â MP4, MOV, WebM, MKV, MP3, M4A. Sceglierai un intervallo e un formato. Il tuo file non lascia mai il browser.", 178 browse: "Scegli un file", 179 rangeHeading: "Scegli l'intervallo e il formato", 180 formatLabel: "Formato audio", 181 rangeStartLabel: "Inizio", 182 rangeEndLabel: "Fine", 183 rangeHint: "Fino a 10 minuti per estrazione. I tempi sono in mm:ss (o h:mm:ss).", 184 rangeLongNote: 185 "Questo file dura più di 10 minuti â estrarremo l'intervallo selezionato. Estrai altre parti un intervallo alla volta.", 186 durationUnknownNote: 187 "Non siamo riusciti a leggere la durata di questo file, quindi la fine è impostata su 10:00 â modificala se ti serve meno.", 188 extract: "Estrai audio", 189 changeFile: "Scegli un altro file", 190 rangeInvalid: "Inserisci un inizio e una fine validi â la fine deve essere dopo l'inizio.", 191 rangeTooLong: "Seleziona un intervallo di 10 minuti o meno.", 192 tooLarge: "Questo file è troppo grande (oltre 500 MB). Prova una clip più breve.", 193 notMedia: "Questo non sembra un file video o audio. Scegli un MP4, MOV, WebM, MKV, MP3 o M4A.", 194 loadingEngine: "Caricamento del motore audio (alla prima esecuzione viene scaricato una volta)â¦", 195 extracting: "Estrazione dell'audioâ¦", 196 genericError: "Qualcosa è andato storto nell'estrarre l'audio da questo file. Riprova.", 197 tryAgain: "Modifica e riprova", 198 startOver: "Ricomincia", 199 resultHeading: "Il tuo audio è pronto", 200 download: "Scarica l'audio", 201 extractAnother: "Estrai un altro intervallo", 202 privacyNote: "Tutto avviene nel tuo browser â il tuo file non viene mai caricato.", 203 ariaChooseVideoFile: "Scegli un file video", 204 fileNameFallback: "Il tuo file", 205 lengthPrefix: "Durata", 206 lengthUnknown: "Durata sconosciuta", 207 }, 208 nl: { 209 dropTitle: "Sleep hier een video, of blader", 210 dropHint: 211 "Kies een video- of audiobestand â MP4, MOV, WebM, MKV, MP3, M4A. Je kiest een bereik en een formaat. Je bestand verlaat je browser nooit.", 212 browse: "Kies een bestand", 213 rangeHeading: "Kies het bereik en het formaat", 214 formatLabel: "Audioformaat", 215 rangeStartLabel: "Begin", 216 rangeEndLabel: "Einde", 217 rangeHint: "Maximaal 10 minuten per extractie. Tijden zijn mm:ss (of h:mm:ss).", 218 rangeLongNote: 219 "Dit bestand is langer dan 10 minuten â we extraheren het geselecteerde bereik. Extraheer extra delen één bereik per keer.", 220 durationUnknownNote: 221 "We konden de lengte van dit bestand niet lezen, dus het einde staat standaard op 10:00 â pas het aan als je minder nodig hebt.", 222 extract: "Audio extraheren", 223 changeFile: "Kies een ander bestand", 224 rangeInvalid: "Voer een geldig begin en einde in â het einde moet na het begin liggen.", 225 rangeTooLong: "Selecteer een bereik van 10 minuten of minder.", 226 tooLarge: "Dat bestand is te groot (meer dan 500 MB). Probeer een korter fragment.", 227 notMedia: "Dat lijkt geen video- of audiobestand. Kies een MP4, MOV, WebM, MKV, MP3 of M4A.", 228 loadingEngine: "De audio-engine laden (de eerste keer wordt die eenmalig gedownload)â¦", 229 extracting: "Audio extraherenâ¦", 230 genericError: "Er ging iets mis bij het extraheren van audio uit dit bestand. Probeer het opnieuw.", 231 tryAgain: "Aanpassen en opnieuw", 232 startOver: "Opnieuw beginnen", 233 resultHeading: "Je audio is klaar", 234 download: "Audio downloaden", 235 extractAnother: "Nog een bereik extraheren", 236 privacyNote: "Alles draait in je browser â je bestand wordt nooit geüpload.", 237 ariaChooseVideoFile: "Kies een videobestand", 238 fileNameFallback: "Je bestand", 239 lengthPrefix: "Lengte", 240 lengthUnknown: "Lengte onbekend", 241 }, 242 sv: { 243 dropTitle: "Släpp en video här, eller bläddra", 244 dropHint: 245 "Välj en video- eller ljudfil â MP4, MOV, WebM, MKV, MP3, M4A. Du väljer ett intervall och ett format. Din fil lämnar aldrig din webbläsare.", 246 browse: "Välj en fil", 247 rangeHeading: "Välj intervall och format", 248 formatLabel: "Ljudformat", 249 rangeStartLabel: "Start", 250 rangeEndLabel: "Slut", 251 rangeHint: "Upp till 10 minuter per extrahering. Tider anges som mm:ss (eller h:mm:ss).", 252 rangeLongNote: 253 "Den här filen är längre än 10 minuter â vi extraherar det valda intervallet. Extrahera fler delar ett intervall i taget.", 254 durationUnknownNote: 255 "Vi kunde inte läsa filens längd, sÃ¥ slutet sätts som standard till 10:00 â justera det om du behöver mindre.", 256 extract: "Extrahera ljud", 257 changeFile: "Välj en annan fil", 258 rangeInvalid: "Ange en giltig start och ett giltigt slut â slutet mÃ¥ste vara efter starten.", 259 rangeTooLong: "Välj ett intervall pÃ¥ 10 minuter eller mindre.", 260 tooLarge: "Den filen är för stor (över 500 MB). Prova ett kortare klipp.", 261 notMedia: "Det där ser inte ut som en video- eller ljudfil. Välj en MP4, MOV, WebM, MKV, MP3 eller M4A.", 262 loadingEngine: "Laddar ljudmotorn (första gÃ¥ngen laddas den ner en gÃ¥ng)â¦", 263 extracting: "Extraherar ljudâ¦", 264 genericError: "NÃ¥got gick fel när ljud skulle extraheras frÃ¥n den här filen. Försök igen.", 265 tryAgain: "Justera & försök igen", 266 startOver: "Börja om", 267 resultHeading: "Ditt ljud är klart", 268 download: "Ladda ner ljud", 269 extractAnother: "Extrahera ett annat intervall", 270 privacyNote: "Allt körs i din webbläsare â din fil laddas aldrig upp.", 271 ariaChooseVideoFile: "Välj en videofil", 272 fileNameFallback: "Din fil", 273 lengthPrefix: "Längd", 274 lengthUnknown: "Okänd längd", 275 }, 276 da: { 277 dropTitle: "Slip en video her, eller gennemse", 278 dropHint: 279 "Vælg en video- eller lydfil â MP4, MOV, WebM, MKV, MP3, M4A. Du vælger et interval og et format. Din fil forlader aldrig din browser.", 280 browse: "Vælg en fil", 281 rangeHeading: "Vælg interval og format", 282 formatLabel: "Lydformat", 283 rangeStartLabel: "Start", 284 rangeEndLabel: "Slut", 285 rangeHint: "Op til 10 minutter pr. udtræk. Tidspunkter er mm:ss (eller h:mm:ss).", 286 rangeLongNote: 287 "Denne fil er længere end 10 minutter â vi udtrækker det valgte interval. Udtræk flere dele ét interval ad gangen.", 288 durationUnknownNote: 289 "Vi kunne ikke læse filens længde, sÃ¥ slut er som standard 10:00 â justér det, hvis du har brug for mindre.", 290 extract: "Udtræk lyd", 291 changeFile: "Vælg en anden fil", 292 rangeInvalid: "Angiv en gyldig start og slut â slut skal være efter start.", 293 rangeTooLong: "Vælg et interval pÃ¥ 10 minutter eller mindre.", 294 tooLarge: "Den fil er for stor (over 500 MB). Prøv et kortere klip.", 295 notMedia: "Det ligner ikke en video- eller lydfil. Vælg en MP4, MOV, WebM, MKV, MP3 eller M4A.", 296 loadingEngine: "Indlæser lydmotoren (første kørsel henter den én gang)â¦", 297 extracting: "Udtrækker lydâ¦", 298 genericError: "Noget gik galt under udtrækningen af lyd fra denne fil. Prøv igen.", 299 tryAgain: "Justér og prøv igen", 300 startOver: "Start forfra", 301 resultHeading: "Din lyd er klar", 302 download: "Download lyd", 303 extractAnother: "Udtræk et andet interval", 304 privacyNote: "Alt kører i din browser â din fil uploades aldrig.", 305 ariaChooseVideoFile: "Vælg en videofil", 306 fileNameFallback: "Din fil", 307 lengthPrefix: "Længde", 308 lengthUnknown: "Ukendt længde", 309 }, 310 no: { 311 dropTitle: "Slipp en video her, eller bla gjennom", 312 dropHint: 313 "Velg en video- eller lydfil â MP4, MOV, WebM, MKV, MP3, M4A. Du velger et omrÃ¥de og et format. Filen din forlater aldri nettleseren din.", 314 browse: "Velg en fil", 315 rangeHeading: "Velg omrÃ¥de og format", 316 formatLabel: "Lydformat", 317 rangeStartLabel: "Start", 318 rangeEndLabel: "Slutt", 319 rangeHint: "Opptil 10 minutter per uttrekk. Tider er mm:ss (eller h:mm:ss).", 320 rangeLongNote: 321 "Denne filen er lengre enn 10 minutter â vi trekker ut det valgte omrÃ¥det. Trekk ut flere deler ett omrÃ¥de om gangen.", 322 durationUnknownNote: 323 "Vi klarte ikke Ã¥ lese lengden pÃ¥ denne filen, sÃ¥ slutten settes som standard til 10:00 â juster den hvis du trenger mindre.", 324 extract: "Trekk ut lyd", 325 changeFile: "Velg en annen fil", 326 rangeInvalid: "Skriv inn en gyldig start og slutt â slutten mÃ¥ være etter starten.", 327 rangeTooLong: "Velg et omrÃ¥de pÃ¥ 10 minutter eller mindre.", 328 tooLarge: "Den filen er for stor (over 500 MB). Prøv et kortere klipp.", 329 notMedia: "Dette ser ikke ut som en video- eller lydfil. Velg en MP4, MOV, WebM, MKV, MP3 eller M4A.", 330 loadingEngine: "Laster lydmotoren (lastes ned én gang ved første kjøring)â¦", 331 extracting: "Trekker ut lydâ¦", 332 genericError: "Noe gikk galt under uttrekk av lyd fra denne filen. Prøv igjen.", 333 tryAgain: "Juster og prøv igjen", 334 startOver: "Start pÃ¥ nytt", 335 resultHeading: "Lyden din er klar", 336 download: "Last ned lyd", 337 extractAnother: "Trekk ut et nytt omrÃ¥de", 338 privacyNote: "Alt kjører i nettleseren din â filen din lastes aldri opp.", 339 ariaChooseVideoFile: "Velg en videofil", 340 fileNameFallback: "Filen din", 341 lengthPrefix: "Lengde", 342 lengthUnknown: "Ukjent lengde", 343 }, 344 fi: { 345 dropTitle: "Pudota video tähän tai selaa", 346 dropHint: 347 "Valitse video- tai äänitiedosto â MP4, MOV, WebM, MKV, MP3, M4A. Valitset jakson ja muodon. Tiedostosi ei koskaan poistu selaimestasi.", 348 browse: "Valitse tiedosto", 349 rangeHeading: "Valitse jakso ja muoto", 350 formatLabel: "Ãänimuoto", 351 rangeStartLabel: "Alku", 352 rangeEndLabel: "Loppu", 353 rangeHint: "Enintään 10 minuuttia poimintaa kohti. Ajat ovat muodossa mm:ss (tai h:mm:ss).", 354 rangeLongNote: 355 "Tämä tiedosto on yli 10 minuuttia pitkä â poimimme valitun jakson. Poimi lisää osia yksi jakso kerrallaan.", 356 durationUnknownNote: 357 "Emme pystyneet lukemaan tämän tiedoston kestoa, joten loppu on oletuksena 10:00 â säädä sitä, jos tarvitset lyhyemmän.", 358 extract: "Poimi ääni", 359 changeFile: "Valitse toinen tiedosto", 360 rangeInvalid: "Anna kelvollinen alku ja loppu â lopun on oltava alun jälkeen.", 361 rangeTooLong: "Valitse enintään 10 minuutin jakso.", 362 tooLarge: "Tiedosto on liian suuri (yli 500 MB). Kokeile lyhyempää leikettä.", 363 notMedia: "Tämä ei näytä video- tai äänitiedostolta. Valitse MP4, MOV, WebM, MKV, MP3 tai M4A.", 364 loadingEngine: "Ladataan äänimoottoria (ensimmäisellä kerralla se ladataan kerran)â¦", 365 extracting: "Poimitaan ääntäâ¦", 366 genericError: "Ãänen poimimisessa tästä tiedostosta tapahtui virhe. Yritä uudelleen.",
367 tryAgain: "Säädä ja yritä uudelleen", 368 startOver: "Aloita alusta", 369 resultHeading: "Ãänesi on valmis", 370 download: "Lataa ääni", 371 extractAnother: "Poimi toinen jakso", 372 privacyNote: "Kaikki tapahtuu selaimessasi â tiedostoasi ei koskaan ladata palvelimelle.", 373 ariaChooseVideoFile: "Valitse videotiedosto", 374 fileNameFallback: "Tiedostosi", 375 lengthPrefix: "Kesto", 376 lengthUnknown: "Kesto tuntematon", 377 }, 378}; 379 380// âââ widget mount ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 381 382export function mountExtractAudioWidget(containerEl, config = {}) { 383 const STRINGS = applyUnlimitedRangeCopy(pickStrings(STRINGS_BY_LOCALE, resolveWidgetLocale()), resolveWidgetLocale()); 384 const initialRangeEndSeconds = 385 Number(config.defaultRangeEndSeconds) || Number(config.maxUploadDurationSeconds) || DEFAULT_INITIAL_RANGE_SECONDS; 386 387 let phase = "idle"; // idle | range | working | result | error 388 let statusLabel = ""; 389 let statusPercent = null; // 0-100 or null for indeterminate 390 let activeRun = 0; // guards against stale renders after Start over 391 let lastFile = null; 392 let fileDuration = null; // seconds, or null if unknown 393 let rangeStart = 0; 394 let rangeEnd = initialRangeEndSeconds; 395 let rangeError = null; 396 let selectedFormatId = DEFAULT_AUDIO_EXPORT_FORMAT_ID; 397 let resultPayload = null; // { url, byteSize, filename, format, durationSeconds } 398 let errorState = null; // { message, retryable } 399 let rangeSelector = null; // live media-range-selector instance (range step only) 400 401 function render() { 402 // Always tear down the range selector before re-rendering: this revokes its 403 // object URL, disconnects the ResizeObserver, and removes its listeners, so 404 // no preview/slider resource leaks across phase changes. 405 if (rangeSelector) { 406 rangeSelector.destroy(); 407 rangeSelector = null; 408 } 409 containerEl.innerHTML = ""; 410 let node; 411 if (phase === "idle") node = renderIdle(); 412 else if (phase === "range") node = renderRange(); 413 else if (phase === "working") node = renderWorking(); 414 else if (phase === "result") node = renderResult(); 415 else if (phase === "error") node = renderError(); 416 containerEl.appendChild(node); 417 } 418 419 function setStatus(percent, label) { 420 statusPercent = percent; 421 if (label) statusLabel = label; 422 if (phase === "working") render(); 423 } 424 425 // Revoke any object URL we created for a previous result so the blob can be 426 // garbage-collected; a single page session may extract many files. 427 function releaseResult() { 428 if (resultPayload && resultPayload.url) { 429 URL.revokeObjectURL(resultPayload.url); 430 } 431 resultPayload = null; 432 } 433 434 function reset() { 435 releaseResult(); 436 phase = "idle"; 437 statusLabel = ""; 438 statusPercent = null; 439 errorState = null; 440 rangeError = null; 441 lastFile = null; 442 fileDuration = null; 443 activeRun += 1; // cancel any late callbacks from a previous run 444 render(); 445 } 446 447 // ââ file pick â range step ââââââââââââââââââââââââââââââââââââââââââââââââ 448 449 async function onFile(file) { 450 if (!looksLikeMedia(file)) { 451 errorState = { message: STRINGS.notMedia, retryable: false }; 452 phase = "error"; 453 render(); 454 return; 455 } 456 457 releaseResult(); 458 lastFile = file; 459 const runId = ++activeRun; 460 phase = "working"; 461 setStatus(null, STRINGS.loadingEngine); 462 463 const duration = await readMediaDuration(file); 464 // A newer file pick or a reset may have superseded us while we read the 465 // metadata; bail so a late duration can't clobber the current run. 466 if (runId !== activeRun) return; 467 fileDuration = duration; 468 rangeStart = 0; 469 rangeEnd = fileDuration || initialRangeEndSeconds; 470 rangeError = null; 471 phase = "range"; 472 render(); 473 } 474 475 function onExtract(startValue, endValue, formatId) { 476 const start = parseTimecode(startValue); 477 let end = parseTimecode(endValue); 478 if (start == null || end == null || start < 0 || end <= start) { 479 rangeError = STRINGS.rangeInvalid; 480 render(); 481 return; 482 } 483 if (fileDuration) end = Math.min(end, fileDuration); 484 rangeStart = start; 485 rangeEnd = end; 486 selectedFormatId = formatId || selectedFormatId; 487 rangeError = null; 488 const runId = ++activeRun; 489 phase = "working"; 490 setStatus(null, STRINGS.loadingEngine); 491 runExtraction(runId, start, end, getExportFormat(selectedFormatId)).catch((err) => { 492 if (runId !== activeRun) return; 493 console.error("[extract-audio-widget] extraction failed", err); 494 const message = err instanceof AudioExtractionError ? err.message :
494STRINGS.genericError; 495 errorState = { message, retryable: !(err instanceof AudioExtractionError) || err.retryable }; 496 phase = "error"; 497 render(); 498 }); 499 } 500 501 async function runExtraction(runId, startSeconds, endSeconds, format) { 502 const file = lastFile; 503 const selectedDuration = endSeconds - startSeconds; 504 505 setStatus(null, STRINGS.loadingEngine); 506 await ensureFfmpeg(); 507 if (runId !== activeRun) return; 508 509 const fileBuffer = await file.arrayBuffer(); 510 if (runId !== activeRun) return; 511 512 setStatus(0, STRINGS.extracting); 513 const audio = await extractAudioToFormat({ 514 fileBuffer, 515 sourceFileName: file.name || "video", 516 format, 517 startSeconds, 518 durationSeconds: selectedDuration, 519 onProgress: (percent, label) => { 520 if (runId === activeRun) setStatus(percent, label || STRINGS.extracting); 521 }, 522 }); 523 if (runId !== activeRun) return; 524 525 releaseResult(); 526 const baseName = slugify(stripExtension(file.name) || "audio", "audio"); 527 resultPayload = { 528 blob: audio.blob, 529 url: URL.createObjectURL(audio.blob), 530 byteSize: audio.byteSize, 531 mimeType: audio.mimeType, 532 filename: `${baseName}.${audio.extension}`, 533 format, 534 durationSeconds: selectedDuration, 535 }; 536 phase = "result"; 537 render(); 538 } 539 540 // ââ renderers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 541 542 function renderIdle() { 543 const input = h("input", { 544 type: "file", 545 className: "upload-file-input", 546 accept: "video/*,audio/*", 547 "aria-label": STRINGS.ariaChooseVideoFile, 548 }); 549 input.addEventListener("change", () => { 550 const file = input.files && input.files[0]; 551 if (file) onFile(file); 552 }); 553 554 const dropzone = h( 555 "div", 556 { className: "upload-dropzone", role: "button", tabindex: "0" }, 557 h("p", { className: "upload-drop-title" }, STRINGS.dropTitle), 558 h("p", { className: "upload-drop-hint" }, STRINGS.dropHint), 559 h( 560 "button", 561 { 562 type: "button", 563 className: "button button-primary upload-browse-btn", 564 onClick: () => input.click(), 565 }, 566 STRINGS.browse, 567 ), 568 input, 569 ); 570 571 dropzone.addEventListener("keydown", (e) => { 572 if (e.key === "Enter" || e.key === " ") { 573 e.preventDefault(); 574 input.click(); 575 } 576 }); 577 dropzone.addEventListener("dragover", (e) => { 578 e.preventDefault(); 579 dropzone.classList.add("is-dragover"); 580 }); 581 dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragover")); 582 dropzone.addEventListener("drop", (e) => { 583 e.preventDefault(); 584 dropzone.classList.remove("is-dragover"); 585 const file = e.dataTransfer?.files && e.dataTransfer.files[0]; 586 if (file) onFile(file); 587 }); 588 589 return h("div", { className: "upload-widget-idle" }, dropzone); 590 } 591 592 function renderRange() { 593 const fileName = (lastFile && lastFile.name) || STRINGS.fileNameFallback; 594 const durationLabel = 595 fileDuration != null ? `${STRINGS.lengthPrefix} ${formatTimestamp(fileDuration)}` : STRINGS.lengthUnknown; 596 597 // Format chips (native radios styled as chips so selection is accessible and 598 // needs no re-render). Read the checked value at submit time. 599 const formatInputs = []; 600 const formatChips = AUDIO_EXPORT_FORMATS.map((format) => { 601 const radio = h("input", { 602 type: "radio", 603 name: "extract-format", 604 value: format.id, 605 className: "format-chip-input", 606 ...(format.id === selectedFormatId ? { checked: "checked" } : {}), 607 }); 608 formatInputs.push(radio); 609 return h( 610 "label", 611 { className: "format-chip", title: format.description }, 612 radio, 613 h("span", { className: "format-chip-label" }, format.label), 614 h("span", { className: "format-chip-desc" }, format.description), 615 ); 616 }); 617 618 const selectedFormatId2 = () => 619 (formatInputs.find((r) => r.checked) || {}).value || selectedFormatId; 620 const submit = () => { 621 const { startSeconds, endSeconds } = rangeSelector.getRange();
622 onExtract(formatTimestamp(startSeconds), formatTimestamp(endSeconds), selectedFormatId2()); 623 }; 624 625 // Preview (<audio> for audio sources, <video> otherwise) + dual-handle slider 626 // + synced mm:ss Start/End fields. The selector learns the duration from the 627 // media's loadedmetadata if fileDuration was unknown and builds its slider then. 628 rangeSelector = mountRangeSelector({ 629 file: lastFile, 630 durationSeconds: fileDuration, 631 startSeconds: rangeStart, 632 endSeconds: rangeEnd, 633 maxDurationSeconds: null, 634 startLabel: STRINGS.rangeStartLabel, 635 endLabel: STRINGS.rangeEndLabel, 636 onSubmit: submit, // Enter in a field triggers an extract 637 }); 638 639 const card = h( 640 "div", 641 { className: "range-card" }, 642 h("h3", { className: "range-heading" }, STRINGS.rangeHeading), 643 h("p", { className: "range-source" }, `${fileName} · ${durationLabel}`), 644 rangeSelector.element, 645 h("p", { className: "range-hint" }, STRINGS.rangeHint), 646 h("p", { className: "format-group-label" }, STRINGS.formatLabel), 647 h("div", { className: "format-chip-row", role: "radiogroup", "aria-label": STRINGS.formatLabel }, ...formatChips), 648 ); 649 if (fileDuration == null) { 650 card.appendChild(h("p", { className: "range-long-note" }, STRINGS.durationUnknownNote)); 651 } 652 if (rangeError) card.appendChild(h("p", { className: "notes-input-error" }, rangeError)); 653 card.appendChild( 654 h( 655 "div", 656 { className: "notes-result-actions" }, 657 h("button", { type: "button", className: "button button-primary", onClick: submit }, STRINGS.extract), 658 h("button", { type: "button", className: "button button-ghost", onClick: reset }, STRINGS.changeFile), 659 ), 660 ); 661 return card; 662 } 663 664 function renderWorking() { 665 const bar = h( 666 "div", 667 { className: "upload-progress-track", "aria-hidden": "true" }, 668 h("div", { 669 className: statusPercent == null ? "upload-progress-fill is-indeterminate" : "upload-progress-fill", 670 style: statusPercent == null ? null : `width:${Math.max(2, Math.min(100, statusPercent))}%`, 671 }), 672 ); 673 return h( 674 "div", 675 { className: "notes-status-area" }, 676 h("span", { className: "notes-spinner", "aria-hidden": "true" }), 677 h("p", { className: "notes-status-label" }, statusLabel || STRINGS.extracting), 678 bar, 679 h("p", { className: "notes-status-hint" }, STRINGS.privacyNote), 680 ); 681 } 682 683 function renderError() { 684 const actions = h( 685 "div", 686 { className: "notes-result-actions" }, 687 errorState?.retryable && lastFile 688 ? h( 689 "button", 690 { 691 type: "button", 692 className: "button button-primary", 693 onClick: () => { 694 rangeError = null; 695 phase = "range"; 696 render(); 697 }, 698 }, 699 STRINGS.tryAgain, 700 ) 701 : null, 702 h("button", { type: "button", className: "button button-ghost", onClick: reset }, STRINGS.startOver), 703 ); 704 return h( 705 "div", 706 { className: "notes-error-area" }, 707 h("p", { className: "notes-error-msg" }, errorState?.message || STRINGS.genericError), 708 actions, 709 ); 710 } 711 712 function renderResult() { 713 const data = resultPayload; 714 const meta = `${data.format.label} · ${formatBytes(data.byteSize)} · ${formatTimestamp(data.durationSeconds)}`; 715 716 const player = h("audio", { 717 className: "audio-result-player", 718 controls: "controls", 719 src: data.url, 720 preload: "metadata", 721 }); 722 723 const download = h( 724 "button", 725 { 726 type: "button", 727 className: "button button-primary", 728 onClick: () => triggerDownload(data.blob, data.filename, data.mimeType), 729 }, 730 STRINGS.download, 731 ); 732 const extractAnother = h( 733 "button", 734 { 735 type: "button", 736 className: "button button-secondary", 737 onClick: () => { 738 rangeError = null; 739 phase = "range"; 740 render(); 741 }, 742 }, 743 STRINGS.extractAnother, 744 ); 745 const startOver = h( 746 "button", 747 { type: "button", className: "button button-ghost", onClick: reset }, 748 STRINGS.startOver, 749 ); 750 751 return h( 752 "div", 753 { className: "notes-result audio-result" }, 754 h( 755 "div", 756 { className: "notes-result-head" }, 757 h("h3", { className: "notes-result-title" }, STRINGS.resultHeading), 758 h("p", { className: "notes-result-source" }, `${data.filename} · ${meta}`), 759 ), 760 player, 761 h("div", { className: "notes-result-actions" }, download, extractAnother, startOver), 762 ); 763 } 764 765 render(); 766} 767 768// ââ helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 769 770function stripExtension(name) { 771 if (!name || typeof name !== "string") return "";
772 const dot = name.lastIndexOf("."); 773 return dot > 0 ? name.slice(0, dot) : name; 774}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.