@@ -310,6 +310,156 @@ export class BinaryMedia {
310310 element . addEventListener ( 'error' , onError , { once : true } ) ;
311311 }
312312
313+ // Added: trigger a download using BinaryMedia pipeline (formerly BinaryFileDownload)
314+ public static async downloadAsync (
315+ element : HTMLElement ,
316+ streamRef : { stream : ( ) => Promise < ReadableStream < Uint8Array > > } | null ,
317+ mimeType : string ,
318+ cacheKey : string ,
319+ totalBytes : number | null ,
320+ fileName ?: string | null ,
321+ attemptNativePicker = true ,
322+ ) : Promise < MediaLoadResult > {
323+ if ( ! element || ! cacheKey ) {
324+ return { success : false , fromCache : false , objectUrl : null , error : 'Invalid parameters' } ;
325+ }
326+
327+ const nativePickerAvailable = attemptNativePicker && typeof ( window as unknown as { showSaveFilePicker ?: unknown } ) . showSaveFilePicker === 'function' ;
328+
329+ // Try cache first
330+ let cacheHitBlob : Blob | null = null ;
331+ let _fromCache = false ;
332+ try {
333+ const cache = await this . getCache ( ) ;
334+ if ( cache ) {
335+ const cached = await cache . match ( encodeURIComponent ( cacheKey ) ) ;
336+ if ( cached ) {
337+ cacheHitBlob = await cached . blob ( ) ;
338+ _fromCache = true ;
339+ }
340+ }
341+ } catch ( err ) {
342+ this . logger . log ( LogLevel . Debug , `Cache lookup failed (download): ${ err } ` ) ;
343+ }
344+
345+ // If we have a cache hit and native picker is available, stream blob to file directly
346+ if ( cacheHitBlob && nativePickerAvailable ) {
347+ try {
348+ const handle = await ( window as unknown as { showSaveFilePicker : ( opts : any ) => Promise < any > } ) . showSaveFilePicker ( { suggestedName : fileName || cacheKey } ) ; // eslint-disable-line @typescript-eslint/no-explicit-any
349+ const writer = await handle . createWritable ( ) ;
350+ const stream = cacheHitBlob . stream ( ) ;
351+ const result = await this . writeStreamToFile ( element , stream as ReadableStream < Uint8Array > , writer , totalBytes ) ;
352+ if ( result === 'success' ) {
353+ return { success : true , fromCache : true , objectUrl : null } ;
354+ }
355+ // aborted treated as failure
356+ return { success : false , fromCache : true , objectUrl : null , error : 'Aborted' } ;
357+ } catch ( pickerErr ) {
358+ // User might have cancelled; fall back to anchor download if we still have blob
359+ this . logger . log ( LogLevel . Debug , `Native picker path failed or cancelled: ${ pickerErr } ` ) ;
360+ }
361+ }
362+
363+ if ( cacheHitBlob && ! nativePickerAvailable ) {
364+ // Fallback anchor path using cached blob
365+ const url = URL . createObjectURL ( cacheHitBlob ) ;
366+ this . triggerDownload ( url , ( fileName || cacheKey ) ) ;
367+ return { success : true , fromCache : true , objectUrl : url } ;
368+ }
369+
370+ if ( ! streamRef ) {
371+ return { success : false , fromCache : false , objectUrl : null , error : 'No stream provided' } ;
372+ }
373+
374+ // Stream and optionally cache (dup logic from streamAndCreateUrl, without setting element attributes)
375+ this . loadingElements . add ( element ) ;
376+ const controller = new AbortController ( ) ;
377+ this . controllers . set ( element , controller ) ;
378+
379+ try {
380+ const readable = await streamRef . stream ( ) ;
381+
382+ // If native picker available, we can stream directly to file, optionally tee for cache
383+ if ( nativePickerAvailable ) {
384+ try {
385+ const handle = await ( window as unknown as { showSaveFilePicker : ( opts : any ) => Promise < any > } ) . showSaveFilePicker ( { suggestedName : fileName || cacheKey } ) ; // eslint-disable-line @typescript-eslint/no-explicit-any
386+ const writer = await handle . createWritable ( ) ;
387+
388+ let workingStream : ReadableStream < Uint8Array > = readable ;
389+ let cacheStream : ReadableStream < Uint8Array > | null = null ;
390+ if ( cacheKey ) {
391+ const cache = await this . getCache ( ) ;
392+ if ( cache ) {
393+ const tees = readable . tee ( ) ;
394+ workingStream = tees [ 0 ] ;
395+ cacheStream = tees [ 1 ] ;
396+ cache . put ( encodeURIComponent ( cacheKey ) , new Response ( cacheStream ) ) . catch ( err => {
397+ this . logger . log ( LogLevel . Debug , `Failed to put cache entry (download/native): ${ err } ` ) ;
398+ } ) ;
399+ }
400+ }
401+
402+ const writeResult = await this . writeStreamToFile ( element , workingStream , writer , totalBytes , controller ) ;
403+ if ( writeResult === 'success' ) {
404+ return { success : true , fromCache : false , objectUrl : null } ;
405+ }
406+ if ( writeResult === 'aborted' ) {
407+ return { success : false , fromCache : false , objectUrl : null , error : 'Aborted' } ;
408+ }
409+ } catch ( pickerErr ) {
410+ this . logger . log ( LogLevel . Debug , `Native picker streaming path failed or cancelled after selection: ${ pickerErr } ` ) ;
411+ // Fall through to in-memory blob fallback
412+ }
413+ }
414+
415+ // In-memory path (existing logic)
416+ let displayStream : ReadableStream < Uint8Array > = readable ;
417+ if ( cacheKey ) {
418+ const cache = await this . getCache ( ) ;
419+ if ( cache ) {
420+ const [ display , cacheStream ] = readable . tee ( ) ;
421+ displayStream = display ;
422+ cache . put ( encodeURIComponent ( cacheKey ) , new Response ( cacheStream ) ) . catch ( err => {
423+ this . logger . log ( LogLevel . Debug , `Failed to put cache entry (download): ${ err } ` ) ;
424+ } ) ;
425+ }
426+ }
427+
428+ const chunks : Uint8Array [ ] = [ ] ;
429+ let bytesRead = 0 ;
430+ for await ( const chunk of this . iterateStream ( displayStream , controller . signal ) ) {
431+ if ( controller . signal . aborted ) {
432+ return { success : false , fromCache : false , objectUrl : null , error : 'Aborted' } ;
433+ }
434+ chunks . push ( chunk ) ;
435+ bytesRead += chunk . byteLength ;
436+ if ( totalBytes ) {
437+ const progress = Math . min ( 1 , bytesRead / totalBytes ) ;
438+ element . style . setProperty ( '--blazor-media-progress' , progress . toString ( ) ) ;
439+ }
440+ }
441+
442+ if ( bytesRead === 0 ) {
443+ return { success : false , fromCache : false , objectUrl : null , error : 'Empty stream' } ;
444+ }
445+
446+ const combined = this . combineChunks ( chunks ) ;
447+ const blob = new Blob ( [ combined ] , { type : mimeType } ) ;
448+ const url = URL . createObjectURL ( blob ) ;
449+ this . triggerDownload ( url , fileName || cacheKey ) ;
450+ return { success : true , fromCache : false , objectUrl : url } ;
451+ } catch ( error ) {
452+ this . logger . log ( LogLevel . Debug , `Error in downloadAsync: ${ error } ` ) ;
453+ return { success : false , fromCache : false , objectUrl : null , error : String ( error ) } ;
454+ } finally {
455+ if ( this . controllers . get ( element ) === controller ) {
456+ this . controllers . delete ( element ) ;
457+ }
458+ this . loadingElements . delete ( element ) ;
459+ element . style . removeProperty ( '--blazor-media-progress' ) ;
460+ }
461+ }
462+
313463 private static async getCache ( ) : Promise < Cache | null > {
314464 if ( ! ( 'caches' in window ) ) {
315465 this . logger . log ( LogLevel . Warning , 'Cache API not supported in this browser' ) ;
@@ -372,4 +522,77 @@ export class BinaryMedia {
372522 }
373523 }
374524 }
525+
526+ private static triggerDownload ( url : string , fileName : string ) : void {
527+ try {
528+ const a = document . createElement ( 'a' ) ;
529+ a . href = url ;
530+ a . download = fileName ;
531+ a . style . display = 'none' ;
532+ document . body . appendChild ( a ) ;
533+ a . click ( ) ;
534+ setTimeout ( ( ) => {
535+ try {
536+ document . body . removeChild ( a ) ;
537+ } catch {
538+ // ignore
539+ }
540+ } , 0 ) ;
541+ } catch {
542+ // ignore
543+ }
544+ }
545+
546+ // Helper to stream data directly to a FileSystemWritableFileStream with progress & abort handling
547+ private static async writeStreamToFile (
548+ element : HTMLElement ,
549+ stream : ReadableStream < Uint8Array > ,
550+ writer : any , // eslint-disable-line @typescript-eslint/no-explicit-any
551+ totalBytes : number | null ,
552+ controller ?: AbortController
553+ ) : Promise < 'success' | 'aborted' | 'error' > {
554+ const reader = stream . getReader ( ) ;
555+ let written = 0 ;
556+ try {
557+ for ( ; ; ) {
558+ if ( controller ?. signal . aborted ) {
559+ try {
560+ await writer . abort ( ) ;
561+ } catch {
562+ /* ignore */
563+ }
564+ element . style . removeProperty ( '--blazor-media-progress' ) ;
565+ return 'aborted' ;
566+ }
567+ const { done, value } = await reader . read ( ) ;
568+ if ( done ) {
569+ break ;
570+ }
571+ if ( value ) {
572+ await writer . write ( value ) ;
573+ written += value . byteLength ;
574+ if ( totalBytes ) {
575+ const progress = Math . min ( 1 , written / totalBytes ) ;
576+ element . style . setProperty ( '--blazor-media-progress' , progress . toString ( ) ) ;
577+ }
578+ }
579+ }
580+ await writer . close ( ) ;
581+ return 'success' ;
582+ } catch ( e ) {
583+ try {
584+ await writer . abort ( ) ;
585+ } catch {
586+ /* ignore */
587+ }
588+ return controller ?. signal . aborted ? 'aborted' : 'error' ;
589+ } finally {
590+ element . style . removeProperty ( '--blazor-media-progress' ) ;
591+ try {
592+ reader . releaseLock ?.( ) ;
593+ } catch {
594+ /* ignore */
595+ }
596+ }
597+ }
375598}
0 commit comments