Skip to content

Commit 2cd1262

Browse files
committed
Add filedownload prototype
1 parent 9f97e42 commit 2cd1262

File tree

4 files changed

+383
-7
lines changed

4 files changed

+383
-7
lines changed

src/Components/Web.JS/src/Rendering/BinaryMedia.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Components.Rendering;
5+
6+
namespace Microsoft.AspNetCore.Components.Web.Media;
7+
8+
/// <summary>
9+
/// A component that provides a button which, when clicked, downloads the provided media source
10+
/// either via a save-as dialog or directly, using the same BinaryMedia pipeline as <see cref="Image"/> and <see cref="Video"/>.
11+
/// </summary>
12+
/// <remarks>
13+
/// Unlike <see cref="Image"/> and <see cref="Video"/>, this component does not automatically load the media.
14+
/// It defers loading until the user clicks the button. The stream is then materialized, optionally cached,
15+
/// and a client-side download is triggered.
16+
/// </remarks>
17+
public sealed class FileDownload : MediaComponentBase
18+
{
19+
/// <summary>
20+
/// Optional file name to suggest to the browser for the download.
21+
/// </summary>
22+
[Parameter] public string? FileName { get; set; }
23+
24+
/// <summary>
25+
/// Provides custom button text. Defaults to "Download".
26+
/// </summary>
27+
[Parameter] public string? ButtonText { get; set; }
28+
29+
/// <inheritdoc />
30+
protected override string TagName => "button"; // Render a button element
31+
32+
/// <inheritdoc />
33+
protected override string TargetAttributeName => "data-download-object-url"; // Not an actual browser attribute; used for diagnostics.
34+
35+
/// <inheritdoc />
36+
protected override string MarkerAttributeName => "data-blazor-file-download";
37+
38+
/// <inheritdoc />
39+
protected override bool ShouldAutoLoad => false; // Manual trigger via click.
40+
41+
/// <summary>
42+
/// Builds the button element with click handler wiring.
43+
/// </summary>
44+
protected override void BuildRenderTree(RenderTreeBuilder builder)
45+
{
46+
builder.OpenElement(0, TagName);
47+
48+
if (!string.IsNullOrEmpty(_currentObjectUrl))
49+
{
50+
builder.AddAttribute(1, TargetAttributeName, _currentObjectUrl);
51+
}
52+
53+
builder.AddAttribute(2, MarkerAttributeName, "");
54+
55+
if (IsLoading)
56+
{
57+
builder.AddAttribute(3, "data-state", "loading");
58+
}
59+
else if (_hasError)
60+
{
61+
builder.AddAttribute(3, "data-state", "error");
62+
}
63+
64+
builder.AddAttribute(4, "type", "button");
65+
builder.AddAttribute(5, "onclick", EventCallback.Factory.Create(this, OnClickAsync));
66+
builder.AddMultipleAttributes(6, AdditionalAttributes);
67+
builder.AddElementReferenceCapture(7, er => Element = er);
68+
69+
builder.AddContent(8, ButtonText ?? "Download");
70+
71+
builder.CloseElement();
72+
}
73+
74+
private async Task OnClickAsync()
75+
{
76+
if (Source is null || !IsInteractive)
77+
{
78+
return;
79+
}
80+
81+
// Cancel any existing load
82+
CancelPreviousLoad();
83+
var token = ResetCancellationToken();
84+
_currentSource = Source;
85+
_hasError = false;
86+
_currentObjectUrl = null; // Always recreate for downloads
87+
RequestRender();
88+
89+
var source = Source;
90+
91+
try
92+
{
93+
var result = await InvokeBinaryMediaAsync(
94+
"Blazor._internal.BinaryMedia", // JS method we will add
95+
source,
96+
token,
97+
FileName);
98+
99+
if (result is not null && _activeCacheKey == source.CacheKey && !token.IsCancellationRequested)
100+
{
101+
if (result.Success)
102+
{
103+
_currentObjectUrl = result.ObjectUrl; // store created object URL for potential diagnostics
104+
}
105+
else
106+
{
107+
_hasError = true;
108+
}
109+
RequestRender();
110+
}
111+
}
112+
catch (OperationCanceledException)
113+
{
114+
}
115+
catch
116+
{
117+
_hasError = true;
118+
RequestRender();
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)