diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index d5f3128d424a..9b75165a4442 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -19,7 +19,8 @@ import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods' import { WebStartOptions } from './Platform/WebStartOptions'; import { RuntimeAPI } from '@microsoft/dotnet-runtime'; import { JSEventRegistry } from './Services/JSEventRegistry'; -import { BinaryImageComponent } from './Rendering/BinaryImageComponent'; +import { BinaryMedia } from './Rendering/BinaryMedia'; + // TODO: It's kind of hard to tell which .NET platform(s) some of these APIs are relevant to. // It's important to know this information when dealing with the possibility of mulitple .NET platforms being available. @@ -51,7 +52,7 @@ export interface IBlazor { navigationManager: typeof navigationManagerInternalFunctions | any; domWrapper: typeof domFunctions; Virtualize: typeof Virtualize; - BinaryImageComponent: typeof BinaryImageComponent; + BinaryMedia: typeof BinaryMedia; PageTitle: typeof PageTitle; forceCloseConnection?: () => Promise; InputFile?: typeof InputFile; @@ -113,7 +114,7 @@ export const Blazor: IBlazor = { NavigationLock, getJSDataStreamChunk: getNextChunk, attachWebRendererInterop, - BinaryImageComponent, + BinaryMedia, }, }; diff --git a/src/Components/Web.JS/src/Rendering/BinaryImageComponent.ts b/src/Components/Web.JS/src/Rendering/BinaryImageComponent.ts deleted file mode 100644 index 12ffbfe7e72c..000000000000 --- a/src/Components/Web.JS/src/Rendering/BinaryImageComponent.ts +++ /dev/null @@ -1,377 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -import { Logger, LogLevel } from '../Platform/Logging/Logger'; -import { ConsoleLogger } from '../Platform/Logging/Loggers'; - -export interface ImageLoadResult { - success: boolean; - fromCache: boolean; - objectUrl: string | null; - error?: string; -} - -/** - * Provides functionality for rendering binary image data in Blazor components. - */ -export class BinaryImageComponent { - private static readonly CACHE_NAME = 'blazor-image-cache'; - - private static cachePromise?: Promise = undefined; - - private static logger: Logger = new ConsoleLogger(LogLevel.Warning); - - private static loadingImages: Set = new Set(); - - private static activeCacheKey: WeakMap = new WeakMap(); - - private static trackedImages: WeakMap = new WeakMap(); - - private static observersByParent: WeakMap = new WeakMap(); - - private static controllers: WeakMap = new WeakMap(); - - private static initializeParentObserver(parent: Element): void { - if (this.observersByParent.has(parent)) { - return; - } - - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - // Handle removed nodes within this parent subtree - if (mutation.type === 'childList') { - for (const node of Array.from(mutation.removedNodes)) { - if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as Element; - - if (element.tagName === 'IMG' && this.trackedImages.has(element as HTMLImageElement)) { - this.revokeTrackedUrl(element as HTMLImageElement); - } - - // Any tracked descendants - element.querySelectorAll('img').forEach((img) => { - if (this.trackedImages.has(img as HTMLImageElement)) { - this.revokeTrackedUrl(img as HTMLImageElement); - } - }); - } - } - } - - // Handle src attribute changes on tracked images - if (mutation.type === 'attributes' && (mutation as MutationRecord).attributeName === 'src') { - const img = (mutation.target as Element) as HTMLImageElement; - if (this.trackedImages.has(img)) { - const tracked = this.trackedImages.get(img); - if (tracked && img.src !== tracked.url) { - this.revokeTrackedUrl(img); - } - } - } - } - }); - - observer.observe(parent, { - childList: true, - attributes: true, - attributeFilter: ['src'], - }); - - this.observersByParent.set(parent, observer); - } - - private static revokeTrackedUrl(img: HTMLImageElement): void { - const tracked = this.trackedImages.get(img); - if (tracked) { - try { - URL.revokeObjectURL(tracked.url); - } catch { - // ignore - } - this.trackedImages.delete(img); - this.loadingImages.delete(img); - this.activeCacheKey.delete(img); - } - - // Abort any in-flight stream tied to this element - const controller = this.controllers.get(img); - if (controller) { - try { - controller.abort(); - } catch { - // ignore - } - this.controllers.delete(img); - } - } - - /** - * Single entry point for setting image - handles cache check and streaming - */ - public static async setImageAsync( - imgElement: HTMLImageElement, - streamRef: { stream: () => Promise> } | null, - mimeType: string, - cacheKey: string, - totalBytes: number | null - ): Promise { - if (!imgElement || !cacheKey) { - return { success: false, fromCache: false, objectUrl: null, error: 'Invalid parameters' }; - } - - // Ensure we are observing this image's parent - const parent = imgElement.parentElement; - if (parent) { - this.initializeParentObserver(parent); - } - - // If there was a previous different key for this element, abort its in-flight operation - const previousKey = this.activeCacheKey.get(imgElement); - if (previousKey && previousKey !== cacheKey) { - const prevController = this.controllers.get(imgElement); - if (prevController) { - try { - prevController.abort(); - } catch { - // ignore - } - this.controllers.delete(imgElement); - } - } - - this.activeCacheKey.set(imgElement, cacheKey); - - try { - // Try cache first - try { - const cache = await this.getCache(); - if (cache) { - const cachedResponse = await cache.match(encodeURIComponent(cacheKey)); - if (cachedResponse) { - const blob = await cachedResponse.blob(); - const url = URL.createObjectURL(blob); - - this.setImageUrl(imgElement, url, cacheKey); - - return { success: true, fromCache: true, objectUrl: url }; - } - } - } catch (err) { - this.logger.log(LogLevel.Debug, `Cache lookup failed: ${err}`); - } - - if (streamRef) { - const url = await this.streamAndCreateUrl(imgElement, streamRef, mimeType, cacheKey, totalBytes); - if (url) { - return { success: true, fromCache: false, objectUrl: url }; - } - } - - return { success: false, fromCache: false, objectUrl: null, error: 'No/empty stream provided and not in cache' }; - } catch (error) { - this.logger.log(LogLevel.Debug, `Error in setImageAsync: ${error}`); - return { success: false, fromCache: false, objectUrl: null, error: String(error) }; - } - } - - private static setImageUrl(imgElement: HTMLImageElement, url: string, cacheKey: string): void { - const tracked = this.trackedImages.get(imgElement); - if (tracked) { - try { - URL.revokeObjectURL(tracked.url); - } catch { - // ignore - } - } - - this.trackedImages.set(imgElement, { url, cacheKey }); - - imgElement.src = url; - - this.setupEventHandlers(imgElement, cacheKey); - } - - private static async streamAndCreateUrl( - imgElement: HTMLImageElement, - streamRef: { stream: () => Promise> }, - mimeType: string, - cacheKey: string, - totalBytes: number | null - ): Promise { - this.loadingImages.add(imgElement); - - // Create and track an AbortController for this element - const controller = new AbortController(); - this.controllers.set(imgElement, controller); - - const readable = await streamRef.stream(); - let displayStream = readable; - - if (cacheKey) { - const cache = await this.getCache(); - if (cache) { - const [display, cacheStream] = readable.tee(); - displayStream = display; - - cache.put(encodeURIComponent(cacheKey), new Response(cacheStream)).catch(err => { - this.logger.log(LogLevel.Debug, `Failed to cache: ${err}`); - }); - } - } - - const chunks: Uint8Array[] = []; - let bytesRead = 0; - let aborted = false; - let resultUrl: string | null = null; - - try { - for await (const chunk of this.iterateStream(displayStream, controller.signal)) { - if (controller.signal.aborted) { // Stream aborted due to a new setImageAsync call with a key change - aborted = true; - break; - } - - chunks.push(chunk); - bytesRead += chunk.byteLength; - - if (totalBytes) { - const progress = Math.min(1, bytesRead / totalBytes); - imgElement.style.setProperty('--blazor-image-progress', progress.toString()); - } - } - - if (!aborted) { - if (bytesRead === 0) { - if (typeof totalBytes === 'number' && totalBytes > 0) { - throw new Error('Stream was already consumed or at end position'); - } - resultUrl = null; - } else { - const combined = this.combineChunks(chunks); - const blob = new Blob([combined], { type: mimeType }); - const url = URL.createObjectURL(blob); - this.setImageUrl(imgElement, url, cacheKey); - resultUrl = url; - } - } else { - resultUrl = null; - } - } finally { - if (this.controllers.get(imgElement) === controller) { - this.controllers.delete(imgElement); - } - this.loadingImages.delete(imgElement); - imgElement.style.removeProperty('--blazor-image-progress'); - } - - return resultUrl; - } - - private static combineChunks(chunks: Uint8Array[]): Uint8Array { - if (chunks.length === 1) { - return chunks[0]; - } - - const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); - const combined = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - return combined; - } - - private static setupEventHandlers( - imgElement: HTMLImageElement, - cacheKey: string | null = null - ): void { - const onLoad = (_e: Event) => { - if (!cacheKey || BinaryImageComponent.activeCacheKey.get(imgElement) === cacheKey) { - BinaryImageComponent.loadingImages.delete(imgElement); - imgElement.style.removeProperty('--blazor-image-progress'); - } - }; - - const onError = (_e: Event) => { - if (!cacheKey || BinaryImageComponent.activeCacheKey.get(imgElement) === cacheKey) { - BinaryImageComponent.loadingImages.delete(imgElement); - imgElement.style.removeProperty('--blazor-image-progress'); - imgElement.setAttribute('data-state', 'error'); - } - }; - - imgElement.addEventListener('load', onLoad, { once: true }); - imgElement.addEventListener('error', onError, { once: true }); - } - - /** - * Opens or creates the cache storage - */ - private static async getCache(): Promise { - if (!('caches' in window)) { - this.logger.log(LogLevel.Warning, 'Cache API not supported in this browser'); - return null; - } - - if (!this.cachePromise) { - this.cachePromise = (async () => { - try { - return await caches.open(this.CACHE_NAME); - } catch (error) { - this.logger.log(LogLevel.Debug, `Failed to open cache: ${error}`); - return null; - } - })(); - } - - const cache = await this.cachePromise; - // If opening failed previously, allow retry next time - if (!cache) { - this.cachePromise = undefined; - } - return cache; - } - - /** - * Async iterator over a ReadableStream that ensures proper cancellation when iteration stops early. - */ - private static async *iterateStream(stream: ReadableStream, signal?: AbortSignal): AsyncGenerator { - const reader = stream.getReader(); - let finished = false; - - try { - while (true) { - if (signal?.aborted) { - try { - await reader.cancel(); - } catch { - // ignore - } - return; - } - const { done, value } = await reader.read(); - if (done) { - finished = true; - return; - } - if (value) { - yield value; - } - } - } finally { - if (!finished) { - try { - await reader.cancel(); - } catch { - // ignore - } - } - try { - reader.releaseLock?.(); - } catch { - // ignore - } - } - } -} diff --git a/src/Components/Web.JS/src/Rendering/BinaryMedia.ts b/src/Components/Web.JS/src/Rendering/BinaryMedia.ts new file mode 100644 index 000000000000..c4e2475bd95c --- /dev/null +++ b/src/Components/Web.JS/src/Rendering/BinaryMedia.ts @@ -0,0 +1,735 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { Logger, LogLevel } from '../Platform/Logging/Logger'; +import { ConsoleLogger } from '../Platform/Logging/Loggers'; + +// Minimal File System Access API typings +interface FileSystemWritableFileStream { + write(data: BufferSource | Blob | Uint8Array): Promise; + close(): Promise; + abort(): Promise; +} +interface FileSystemFileHandle { + createWritable(): Promise; +} +interface SaveFilePickerOptions { + suggestedName?: string; +} +declare global { + interface Window { + showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise; + } +} + +export interface MediaLoadResult { + success: boolean; + fromCache: boolean; + objectUrl: string | null; + error?: string; +} + +/** + * Provides functionality for rendering binary media data in Blazor components. + */ +export class BinaryMedia { + private static readonly CACHE_NAME = 'blazor-media-cache'; + + private static cachePromise?: Promise = undefined; + + private static logger: Logger = new ConsoleLogger(LogLevel.Warning); + + private static loadingElements: Set = new Set(); + + private static activeCacheKey: WeakMap = new WeakMap(); + + private static tracked: WeakMap = new WeakMap(); + + private static observersByParent: WeakMap = new WeakMap(); + + private static controllers: WeakMap = new WeakMap(); + + private static initializeParentObserver(parent: Element): void { + if (this.observersByParent.has(parent)) { + return; + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // Handle removed nodes within this parent subtree + if (mutation.type === 'childList') { + for (const node of Array.from(mutation.removedNodes)) { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + + // If the removed element itself is tracked, revoke + if (this.tracked.has(element)) { + this.revokeTrackedUrl(element); + } + + // Any tracked descendants (look for elements that might carry src or href) + element.querySelectorAll('[src],[href]').forEach((child) => { + const childEl = child as HTMLElement; + if (this.tracked.has(childEl)) { + this.revokeTrackedUrl(childEl); + } + }); + } + } + } + + // Attribute changes in this subtree + if (mutation.type === 'attributes') { + const attrName = (mutation as MutationRecord).attributeName; + if (attrName === 'src' || attrName === 'href') { + const element = mutation.target as HTMLElement; + const tracked = this.tracked.get(element); + if (tracked && tracked.attr === attrName) { + const current = element.getAttribute(attrName) || ''; + if (!current || current !== tracked.url) { + this.revokeTrackedUrl(element); + } + } + } + } + } + }); + + observer.observe(parent, { + childList: true, + attributes: true, + attributeFilter: ['src', 'href'], + }); + + this.observersByParent.set(parent, observer); + } + + private static revokeTrackedUrl(el: HTMLElement): void { + const tracked = this.tracked.get(el); + if (tracked) { + try { + URL.revokeObjectURL(tracked.url); + } catch { + // ignore + } + this.tracked.delete(el); + this.loadingElements.delete(el); + this.activeCacheKey.delete(el); + } + // Abort any in-flight stream tied to this element + const controller = this.controllers.get(el); + if (controller) { + try { + controller.abort(); + } catch { + // ignore + } + this.controllers.delete(el); + } + } + + /** + * Single entry point for setting media content - handles cache check and streaming. + */ + public static async setContentAsync( + element: HTMLElement, + streamRef: { stream: () => Promise> } | null, + mimeType: string, + cacheKey: string, + totalBytes: number | null, + targetAttr: 'src' | 'href' + ): Promise { + if (!element || !cacheKey) { + return { success: false, fromCache: false, objectUrl: null, error: 'Invalid parameters' }; + } + + // Ensure we are observing this element's parent + const parent = element.parentElement; + if (parent) { + this.initializeParentObserver(parent); + } + + // If there was a previous different key for this element, abort its in-flight operation + const previousKey = this.activeCacheKey.get(element); + if (previousKey && previousKey !== cacheKey) { + const prevController = this.controllers.get(element); + if (prevController) { + try { + prevController.abort(); + } catch { + // ignore + } + this.controllers.delete(element); + } + } + + this.activeCacheKey.set(element, cacheKey); + + try { + // Try cache first + try { + const cache = await this.getCache(); + if (cache) { + const cachedResponse = await cache.match(encodeURIComponent(cacheKey)); + if (cachedResponse) { + const blob = await cachedResponse.blob(); + const url = URL.createObjectURL(blob); + + this.setUrl(element, url, cacheKey, targetAttr); + return { success: true, fromCache: true, objectUrl: url }; + } + } + } catch (err) { + this.logger.log(LogLevel.Debug, `Cache lookup failed: ${err}`); + } + + if (streamRef) { + const url = await this.streamAndCreateUrl(element, streamRef, mimeType, cacheKey, totalBytes, targetAttr); + if (url) { + return { success: true, fromCache: false, objectUrl: url }; + } + } + + return { success: false, fromCache: false, objectUrl: null, error: 'No/empty stream provided and not in cache' }; + } catch (error) { + this.logger.log(LogLevel.Debug, `Error in setContentAsync: ${error}`); + return { success: false, fromCache: false, objectUrl: null, error: String(error) }; + } + } + + private static async streamAndCreateUrl( + element: HTMLElement, + streamRef: { stream: () => Promise> }, + mimeType: string, + cacheKey: string, + totalBytes: number | null, + targetAttr: 'src' | 'href' + ): Promise { + + // if (targetAttr === 'src' && element instanceof HTMLVideoElement) { + // try { + // const mediaSourceUrl = await this.tryMediaSourceVideoStreaming( + // element, + // streamRef, + // mimeType, + // cacheKey, + // totalBytes + // ); + // if (mediaSourceUrl) { + // return mediaSourceUrl; + // } + // } catch (msErr) { + // this.logger.log(LogLevel.Debug, `MediaSource video streaming path failed, falling back. Error: ${msErr}`); + // } + // } + + this.loadingElements.add(element); + + // Create and track an AbortController for this element + const controller = new AbortController(); + this.controllers.set(element, controller); + + const readable = await streamRef.stream(); + let displayStream = readable; + + if (cacheKey) { + const cache = await this.getCache(); + if (cache) { + const [display, cacheStream] = readable.tee(); + displayStream = display; + cache.put(encodeURIComponent(cacheKey), new Response(cacheStream)).catch(err => { + this.logger.log(LogLevel.Debug, `Failed to put cache entry: ${err}`); + }); + } + } + + let resultUrl: string | null = null; + try { + const { aborted, chunks, bytesRead } = await this.readAllChunks(element, displayStream, controller, totalBytes); + + if (!aborted) { + if (bytesRead === 0) { + if (typeof totalBytes === 'number' && totalBytes > 0) { + throw new Error('Stream was already consumed or at end position'); + } + resultUrl = null; + } else { + const combined = this.combineChunks(chunks); + const baseMimeType = this.extractBaseMimeType(mimeType); + const blob = new Blob([combined.slice()], { type: baseMimeType }); + const url = URL.createObjectURL(blob); + this.setUrl(element, url, cacheKey, targetAttr); + resultUrl = url; + } + } else { + resultUrl = null; + } + } finally { + if (this.controllers.get(element) === controller) { + this.controllers.delete(element); + } + this.loadingElements.delete(element); + element.style.removeProperty('--blazor-media-progress'); + } + + return resultUrl; + } + + private static async readAllChunks( + element: HTMLElement, + stream: ReadableStream, + controller: AbortController, + totalBytes: number | null + ): Promise<{ aborted: boolean; chunks: Uint8Array[]; bytesRead: number }> { + const chunks: Uint8Array[] = []; + let bytesRead = 0; + for await (const chunk of this.iterateStream(stream, controller.signal)) { + if (controller.signal.aborted) { + return { aborted: true, chunks, bytesRead }; + } + chunks.push(chunk); + bytesRead += chunk.byteLength; + if (totalBytes) { + const progress = Math.min(1, bytesRead / totalBytes); + element.style.setProperty('--blazor-media-progress', progress.toString()); + } + } + return { aborted: controller.signal.aborted, chunks, bytesRead }; + } + + private static combineChunks(chunks: Uint8Array[]): Uint8Array { + if (chunks.length === 1) { + return chunks[0]; + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; + } + + private static setUrl(element: HTMLElement, url: string, cacheKey: string, targetAttr: 'src' | 'href'): void { + const tracked = this.tracked.get(element); + if (tracked) { + try { + URL.revokeObjectURL(tracked.url); + } catch { + // ignore + } + } + + this.tracked.set(element, { url, cacheKey, attr: targetAttr }); + + this.setupEventHandlers(element, cacheKey); + + if (targetAttr === 'src') { + (element as HTMLImageElement | HTMLVideoElement).src = url; + } else { + (element as HTMLAnchorElement).href = url; + } + } + + // Streams binary content to a user-selected file when possible, + // otherwise falls back to buffering in memory and triggering a blob download via an anchor. + public static async downloadAsync( + element: HTMLElement, + streamRef: { stream: () => Promise> } | null, + mimeType: string, + totalBytes: number | null, + fileName: string, + ): Promise { + if (!element || !fileName || !streamRef) { + return false; + } + + this.loadingElements.add(element); + const controller = new AbortController(); + this.controllers.set(element, controller); + + try { + const readable = await streamRef.stream(); + + // Native picker direct-to-file streaming available + if (typeof window.showSaveFilePicker === 'function') { + try { + const handle = await window.showSaveFilePicker({ suggestedName: fileName }); + + const writer = await handle.createWritable(); + const writeResult = await this.writeStreamToFile(element, readable, writer, totalBytes, controller); + if (writeResult === 'success') { + return true; + } + if (writeResult === 'aborted') { + return false; + } + } catch (pickerErr) { + this.logger.log(LogLevel.Debug, `Native picker streaming path failed or cancelled: ${pickerErr}`); + } + } + + // In-memory fallback: read all bytes then trigger anchor download + const readResult = await this.readAllChunks(element, readable, controller, totalBytes); + if (readResult.aborted) { + return false; + } + const combined = this.combineChunks(readResult.chunks); + const baseMimeType = this.extractBaseMimeType(mimeType); + const blob = new Blob([combined.slice()], { type: baseMimeType }); + const url = URL.createObjectURL(blob); + this.triggerDownload(url, fileName); + + return true; + } catch (error) { + this.logger.log(LogLevel.Debug, `Error in downloadAsync: ${error}`); + return false; + } finally { + if (this.controllers.get(element) === controller) { + this.controllers.delete(element); + } + this.loadingElements.delete(element); + element.style.removeProperty('--blazor-media-progress'); + } + } + + private static async writeStreamToFile( + element: HTMLElement, + stream: ReadableStream, + writer: FileSystemWritableFileStream, + totalBytes: number | null, + controller?: AbortController + ): Promise<'success' | 'aborted' | 'error'> { + let written = 0; + try { + for await (const chunk of this.iterateStream(stream, controller?.signal)) { + if (controller?.signal.aborted) { + try { + await writer.abort(); + } catch { + /* ignore */ + } + element.style.removeProperty('--blazor-media-progress'); + return 'aborted'; + } + try { + await writer.write(chunk); + } catch (wErr) { + if (controller?.signal.aborted) { + try { + await writer.abort(); + } catch { + /* ignore */ + } + return 'aborted'; + } + return 'error'; + } + written += chunk.byteLength; + if (totalBytes) { + const progress = Math.min(1, written / totalBytes); + element.style.setProperty('--blazor-media-progress', progress.toString()); + } + } + + if (controller?.signal.aborted) { + try { + await writer.abort(); + } catch { + /* ignore */ + } + element.style.removeProperty('--blazor-media-progress'); + return 'aborted'; + } + + try { + await writer.close(); + } catch (closeErr) { + if (controller?.signal.aborted) { + return 'aborted'; + } + return 'error'; + } + return 'success'; + } catch (e) { + try { + await writer.abort(); + } catch { + /* ignore */ + } + return controller?.signal.aborted ? 'aborted' : 'error'; + } finally { + element.style.removeProperty('--blazor-media-progress'); + } + } + + private static triggerDownload(url: string, fileName: string): void { + try { + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + + setTimeout(() => { + try { + a.remove(); + URL.revokeObjectURL(url); + } catch { + // ignore + } + }, 0); + } catch { + // ignore + } + } + + private static async getCache(): Promise { + if (!('caches' in window)) { + this.logger.log(LogLevel.Warning, 'Cache API not supported in this browser'); + return null; + } + + if (!this.cachePromise) { + this.cachePromise = (async () => { + try { + return await caches.open(this.CACHE_NAME); + } catch (error) { + this.logger.log(LogLevel.Debug, `Failed to open cache: ${error}`); + return null; + } + })(); + } + + const cache = await this.cachePromise; + // If opening failed previously, allow retry next time + if (!cache) { + this.cachePromise = undefined; + } + return cache; + } + + private static setupEventHandlers( + element: HTMLElement, + cacheKey: string | null = null + ): void { + const clearIfActive = () => { + if (!cacheKey || BinaryMedia.activeCacheKey.get(element) === cacheKey) { + BinaryMedia.loadingElements.delete(element); + element.style.removeProperty('--blazor-media-progress'); + } + }; + + const onLoad = (_e: Event) => { + clearIfActive(); + }; + + const onError = (_e: Event) => { + if (!cacheKey || BinaryMedia.activeCacheKey.get(element) === cacheKey) { + BinaryMedia.loadingElements.delete(element); + element.style.removeProperty('--blazor-media-progress'); + element.setAttribute('data-state', 'error'); + } + }; + + element.addEventListener('load', onLoad, { once: true }); + element.addEventListener('error', onError, { once: true }); + + if (element instanceof HTMLVideoElement) { + const onLoadedData = (_e: Event) => clearIfActive(); + element.addEventListener('loadeddata', onLoadedData, { once: true }); + } + } + + private static async *iterateStream(stream: ReadableStream, signal?: AbortSignal): AsyncGenerator { + const reader = stream.getReader(); + let finished = false; + try { + while (true) { + if (signal?.aborted) { + try { + await reader.cancel(); + } catch { + // ignore + } + return; + } + const { done, value } = await reader.read(); + if (done) { + finished = true; + return; + } + if (value) { + yield value; + } + } + } finally { + if (!finished) { + try { + await reader.cancel(); + } catch { + // ignore + } + } + try { + reader.releaseLock?.(); + } catch { + // ignore + } + } + } + + /** + * Extracts the base MIME type from a MIME type that may contain codecs. + * Examples: "video/mp4; codecs=\"avc1.64001E\"" -> "video/mp4" + */ + private static extractBaseMimeType(mimeType: string): string { + const semicolonIndex = mimeType.indexOf(';'); + return semicolonIndex !== -1 ? mimeType.substring(0, semicolonIndex).trim() : mimeType; + } + + private static async tryMediaSourceVideoStreaming( + element: HTMLVideoElement, + streamRef: { stream: () => Promise> }, + mimeType: string, + cacheKey: string, + totalBytes: number | null + ): Promise { + try { + if (!('MediaSource' in window) || !MediaSource.isTypeSupported(mimeType)) { + return null; + } + } catch { + return null; + } + + this.loadingElements.add(element); + const controller = new AbortController(); + this.controllers.set(element, controller); + + const mediaSource = new MediaSource(); + const objectUrl = URL.createObjectURL(mediaSource); + + this.setUrl(element, objectUrl, cacheKey, 'src'); + + try { + await new Promise((resolve, reject) => { + const onOpen = () => resolve(); + mediaSource.addEventListener('sourceopen', onOpen, { once: true }); + mediaSource.addEventListener('error', () => reject(new Error('MediaSource error event')), { once: true }); + }); + + if (controller.signal.aborted) { + return null; + } + + const sourceBuffer: SourceBuffer = mediaSource.addSourceBuffer(mimeType); + + const originalStream = await streamRef.stream(); + let displayStream: ReadableStream = originalStream; + + if (cacheKey) { + try { + const cache = await this.getCache(); + if (cache) { + const [display, cacheStream] = originalStream.tee(); + displayStream = display; + cache.put(encodeURIComponent(cacheKey), new Response(cacheStream)) + .catch(err => this.logger.log(LogLevel.Debug, `Failed to put cache entry (MediaSource path): ${err}`)); + } + } catch (cacheErr) { + this.logger.log(LogLevel.Debug, `Cache setup failed (MediaSource path): ${cacheErr}`); + } + } + + let bytesRead = 0; + + for await (const chunk of this.iterateStream(displayStream, controller.signal)) { + if (controller.signal.aborted) { + break; + } + + // Wait until sourceBuffer ready + if (sourceBuffer.updating) { + await new Promise((resolve) => { + const handler = () => resolve(); + sourceBuffer.addEventListener('updateend', handler, { once: true }); + }); + if (controller.signal.aborted) { + break; + } + } + + try { + const copy = new Uint8Array(chunk.byteLength); + copy.set(chunk); + sourceBuffer.appendBuffer(copy); + } catch (appendErr) { + this.logger.log(LogLevel.Debug, `SourceBuffer append failed: ${appendErr}`); + try { + mediaSource.endOfStream(); + } catch { + // ignore + } + break; + } + + bytesRead += chunk.byteLength; + if (totalBytes) { + const progress = Math.min(1, bytesRead / totalBytes); + element.style.setProperty('--blazor-media-progress', progress.toString()); + } + } + + if (controller.signal.aborted) { + try { + URL.revokeObjectURL(objectUrl); + } catch { + // ignore + } + return null; + } + + // Wait for any pending update to finish before ending stream + if (sourceBuffer.updating) { + await new Promise((resolve) => { + const handler = () => resolve(); + sourceBuffer.addEventListener('updateend', handler, { once: true }); + }); + } + try { + mediaSource.endOfStream(); + } catch { + // ignore + } + + this.loadingElements.delete(element); + element.style.removeProperty('--blazor-media-progress'); + + return objectUrl; + } catch (err) { + try { + URL.revokeObjectURL(objectUrl); + } catch { + // ignore + } + // Remove tracking so fallback can safely set a new URL + this.revokeTrackedUrl(element); + if (controller.signal.aborted) { + return null; + } + return null; + } finally { + if (this.controllers.get(element) === controller) { + this.controllers.delete(element); + } + if (controller.signal.aborted) { + this.loadingElements.delete(element); + element.style.removeProperty('--blazor-media-progress'); + } + } + } +} diff --git a/src/Components/Web/src/Media/FileDownload.cs b/src/Components/Web/src/Media/FileDownload.cs new file mode 100644 index 000000000000..8ea054d55ca6 --- /dev/null +++ b/src/Components/Web/src/Media/FileDownload.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Web.Media; + +/* This is equivalent to a .razor file containing: + * + * @(Text ?? "Download") + * + */ +/// +/// A component that provides an anchor element to download the provided media source. +/// +public sealed class FileDownload : MediaComponentBase +{ + /// + /// File name to suggest to the browser for the download. Must be provided. + /// + [Parameter, EditorRequired] public string FileName { get; set; } = default!; + + /// + /// Provides custom link text. Defaults to "Download". + /// + [Parameter] public string? Text { get; set; } + + internal override string TargetAttributeName => string.Empty; // Not used – object URL not tracked for downloads. + + /// + internal override bool ShouldAutoLoad => false; + + /// + /// Allows customizing the rendering of the file download component. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + private protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ChildContent is not null) + { + var context = new FileDownloadContext + { + IsLoading = IsLoading, + HasError = _hasError, + FileName = FileName, + }; + context.Initialize(r => Element = r, EventCallback.Factory.Create(this, OnClickAsync)); + builder.AddContent(0, ChildContent, context); + return; + } + + // Default rendering + builder.OpenElement(0, "a"); + + builder.AddAttribute(1, "data-blazor-file-download", ""); + + if (IsLoading) + { + builder.AddAttribute(2, "data-state", "loading"); + } + else if (_hasError) + { + builder.AddAttribute(2, "data-state", "error"); + } + + builder.AddAttribute(3, "href", "javascript:void(0)"); + builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, OnClickAsync)); + + IEnumerable>? attributesToRender = AdditionalAttributes; + if (AdditionalAttributes is not null && AdditionalAttributes.ContainsKey("href")) + { + var copy = new Dictionary(AdditionalAttributes.Count); + foreach (var kvp in AdditionalAttributes) + { + if (kvp.Key == "href") + { + continue; + } + copy.Add(kvp.Key, kvp.Value!); + } + attributesToRender = copy; + } + builder.AddMultipleAttributes(6, attributesToRender); + + builder.AddElementReferenceCapture(7, elementReference => Element = elementReference); + + builder.AddContent(8, Text ?? "Download"); + + builder.CloseElement(); + } + + private async Task OnClickAsync() + { + if (Source is null || !IsInteractive || string.IsNullOrWhiteSpace(FileName)) + { + return; + } + + CancelPreviousLoad(); + var token = ResetCancellationToken(); + _hasError = false; + _currentSource = Source; + Render(); + + var source = Source; + + using var streamRef = new DotNetStreamReference(source.Stream, leaveOpen: true); + + try + { + var result = await JSRuntime.InvokeAsync( + "Blazor._internal.BinaryMedia.downloadAsync", + token, + Element, + streamRef, + source.MimeType, + source.Length, + FileName); + + if (!token.IsCancellationRequested) + { + _currentSource = null; + if (!result) + { + _hasError = true; + } + Render(); + } + } + catch (OperationCanceledException) + { + _currentSource = null; + Render(); + } + catch + { + _currentSource = null; + _hasError = true; + Render(); + } + } +} + +/// +/// Extended media context for the FileDownload component providing click invocation and filename. +/// +public sealed class FileDownloadContext : MediaContext +{ + /// + /// Gets the file name suggested to the browser when initiating the download. + /// + public string FileName { get; internal set; } = string.Empty; + private EventCallback _onClick; + internal void Initialize(Action capture, EventCallback onClick) + { + base.Initialize(capture); + _onClick = onClick; + } + /// + /// Initiates the download by invoking the underlying click handler of the parent. + /// + public Task InvokeAsync() => _onClick.InvokeAsync(); +} diff --git a/src/Components/Web/src/Media/Image.cs b/src/Components/Web/src/Media/Image.cs new file mode 100644 index 000000000000..752f4452ea05 --- /dev/null +++ b/src/Components/Web/src/Media/Image.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Web.Media; + +/* This is equivalent to a .razor file containing: + * + * + * + */ +/// +/// A component that efficiently renders images from non-HTTP sources like byte arrays. +/// +public sealed class Image : MediaComponentBase +{ + internal override string TargetAttributeName => "src"; + + /// + /// Allows customizing the rendering of the image component. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + private protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ChildContent is not null) + { + var showInitial = Source != null && _currentSource == null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + var context = new MediaContext + { + ObjectUrl = _currentObjectUrl, + IsLoading = IsLoading || showInitial, + HasError = _hasError, + }; + context.Initialize(r => Element = r); + builder.AddContent(0, ChildContent, context); + return; + } + + // Default rendering + builder.OpenElement(0, "img"); + + if (!string.IsNullOrEmpty(_currentObjectUrl)) + { + builder.AddAttribute(1, TargetAttributeName, _currentObjectUrl); + } + + builder.AddAttribute(2, "data-blazor-image", ""); + + var defaultShowInitial = Source != null && _currentSource == null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + if (IsLoading || defaultShowInitial) + { + builder.AddAttribute(3, "data-state", "loading"); + } + else if (_hasError) + { + builder.AddAttribute(3, "data-state", "error"); + } + + builder.AddMultipleAttributes(4, AdditionalAttributes); + builder.AddElementReferenceCapture(5, r => Element = r); + builder.CloseElement(); + } +} diff --git a/src/Components/Web/src/Image/Image.cs b/src/Components/Web/src/Media/MediaComponentBase.cs similarity index 51% rename from src/Components/Web/src/Image/Image.cs rename to src/Components/Web/src/Media/MediaComponentBase.cs index 7c25117bfa0b..5ea37e4b259a 100644 --- a/src/Components/Web/src/Image/Image.cs +++ b/src/Components/Web/src/Media/MediaComponentBase.cs @@ -3,52 +3,94 @@ using System.Diagnostics; using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.JSInterop; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Web.Media; -namespace Microsoft.AspNetCore.Components.Web.Image; - -/* This is equivalent to a .razor file containing: - * - * - * - */ /// -/// A component that efficiently renders images from non-HTTP sources like byte arrays. +/// Base component that handles turning a media stream into an object URL plus caching and lifetime management. +/// Subclasses implement their own rendering and provide the target attribute (e.g., src or href) used /// -public partial class Image : IComponent, IHandleAfterRender, IAsyncDisposable +public abstract partial class MediaComponentBase : IComponent, IHandleAfterRender, IAsyncDisposable { private RenderHandle _renderHandle; - private string? _currentObjectUrl; - private bool _hasError; + + /// + /// The current object URL (blob URL) assigned to the underlying element, or null if not yet loaded + /// or if a previous load failed/was cancelled. + /// + internal string? _currentObjectUrl; + + /// + /// Indicates whether the last load attempt ended in an error state for the active cache key. + /// + internal bool _hasError; + private bool _isDisposed; private bool _initialized; private bool _hasPendingRender; - private string? _activeCacheKey; - private ImageSource? _currentSource; + + /// + /// The cache key associated with the currently active/most recent load operation. Used to ignore + /// out-of-order JS interop responses belonging to stale operations. + /// + internal string? _activeCacheKey; + + /// + /// The instance currently being processed (or null if none). + /// + internal MediaSource? _currentSource; private CancellationTokenSource? _loadCts; - private bool IsLoading => _currentSource != null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; - private bool IsInteractive => _renderHandle.IsInitialized && - _renderHandle.RendererInfo.IsInteractive; + /// + /// Gets a value indicating whether the component is currently loading the media content. + /// True when a source has been provided, no object URL is available yet, and there is no error. + /// + internal bool IsLoading => _currentSource != null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + + /// + /// Gets a value indicating whether the renderer is interactive so client-side JS interop can be performed. + /// + internal bool IsInteractive => _renderHandle.IsInitialized && _renderHandle.RendererInfo.IsInteractive; - private ElementReference? Element { get; set; } + /// + /// Gets the reference to the rendered HTML element for this media component. + /// + internal ElementReference? Element { get; set; } - [Inject] private IJSRuntime JSRuntime { get; set; } = default!; + /// + /// Gets or sets the JS runtime used for interop with the browser to materialize media object URLs. + /// + [Inject] internal IJSRuntime JSRuntime { get; set; } = default!; + + /// + /// Gets or sets the logger factory used to create the instance. + /// + [Inject] internal ILoggerFactory LoggerFactory { get; set; } = default!; + + /// + /// Logger for media operations. + /// + private ILogger Logger => _logger ??= LoggerFactory.CreateLogger(GetType()); + private ILogger? _logger; - [Inject] private ILogger Logger { get; set; } = default!; + internal abstract string TargetAttributeName { get; } /// - /// Gets or sets the source for the image. + /// Determines whether the component should automatically invoke a media load after the first render + /// and whenever the changes. Override and return false for components + /// (such as download buttons) that defer loading until an explicit user action. /// - [Parameter, EditorRequired] public required ImageSource Source { get; set; } + internal virtual bool ShouldAutoLoad => true; /// - /// Gets or sets the attributes for the image. + /// Gets or sets the media source. + /// + [Parameter, EditorRequired] public required MediaSource Source { get; set; } + + /// + /// Unmatched attributes applied to the rendered element. /// [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } @@ -66,12 +108,12 @@ Task IComponent.SetParametersAsync(ParameterView parameters) var previousSource = Source; parameters.SetParameterProperties(this); + if (Source is null) { - throw new InvalidOperationException("Image.Source is required."); + throw new InvalidOperationException($"{nameof(MediaComponentBase)}.{nameof(Source)} is required."); } - // Initialize on first parameters set if (!_initialized) { Render(); @@ -90,7 +132,7 @@ Task IComponent.SetParametersAsync(ParameterView parameters) async Task IHandleAfterRender.OnAfterRenderAsync() { var source = Source; - if (!IsInteractive || source is null) + if (!IsInteractive || source is null || !ShouldAutoLoad) { return; } @@ -104,17 +146,20 @@ async Task IHandleAfterRender.OnAfterRenderAsync() var token = ResetCancellationToken(); _currentSource = source; - try { - await LoadImage(source, token); + await LoadMediaAsync(source, token); } catch (OperationCanceledException) { } } - private void Render() + /// + /// Triggers a render of the component by invoking the method. + /// Ensures that only one render operation is pending at a time to prevent redundant renders. + /// + internal void Render() { Debug.Assert(_renderHandle.IsInitialized); @@ -126,35 +171,9 @@ private void Render() } } - private void BuildRenderTree(RenderTreeBuilder builder) - { - builder.OpenElement(0, "img"); - - if (!string.IsNullOrEmpty(_currentObjectUrl)) - { - builder.AddAttribute(1, "src", _currentObjectUrl); - } - - builder.AddAttribute(2, "data-blazor-image", ""); - - var showInitialLoad = Source != null && _currentSource == null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; - - if (IsLoading || showInitialLoad) - { - builder.AddAttribute(3, "data-state", "loading"); - } - else if (_hasError) - { - builder.AddAttribute(3, "data-state", "error"); - } - - builder.AddMultipleAttributes(4, AdditionalAttributes); - builder.AddElementReferenceCapture(5, elementReference => Element = elementReference); + private protected virtual void BuildRenderTree(RenderTreeBuilder builder) { } - builder.CloseElement(); - } - - private struct ImageLoadResult + private sealed class MediaLoadResult { public bool Success { get; set; } public bool FromCache { get; set; } @@ -162,9 +181,9 @@ private struct ImageLoadResult public string? Error { get; set; } } - private async Task LoadImage(ImageSource source, CancellationToken cancellationToken) + private async Task LoadMediaAsync(MediaSource? source, CancellationToken cancellationToken) { - if (!IsInteractive) + if (source == null || !IsInteractive) { return; } @@ -179,14 +198,15 @@ private async Task LoadImage(ImageSource source, CancellationToken cancellationT using var streamRef = new DotNetStreamReference(source.Stream, leaveOpen: true); - var result = await JSRuntime.InvokeAsync( - "Blazor._internal.BinaryImageComponent.setImageAsync", + var result = await JSRuntime.InvokeAsync( + "Blazor._internal.BinaryMedia.setContentAsync", cancellationToken, Element, streamRef, source.MimeType, source.CacheKey, - source.Length); + source.Length, + TargetAttributeName); if (_activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested) { @@ -209,7 +229,7 @@ private async Task LoadImage(ImageSource source, CancellationToken cancellationT else { _hasError = true; - Log.LoadFailed(Logger, source.CacheKey, new InvalidOperationException(result.Error ?? "Image load failed")); + Log.LoadFailed(Logger, source.CacheKey, new InvalidOperationException(result.Error ?? "Unknown error")); } Render(); @@ -220,8 +240,8 @@ private async Task LoadImage(ImageSource source, CancellationToken cancellationT } catch (Exception ex) { - Log.LoadFailed(Logger, source.CacheKey, ex); - if (_activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested) + Log.LoadFailed(Logger, source?.CacheKey ?? "(null)", ex); + if (source != null && _activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested) { _currentObjectUrl = null; _hasError = true; @@ -236,15 +256,15 @@ public ValueTask DisposeAsync() if (!_isDisposed) { _isDisposed = true; - - // Cancel any pending operations CancelPreviousLoad(); } - return new ValueTask(); } - private void CancelPreviousLoad() + /// + /// Cancels any in-flight media load operation, if one is active, by signalling its . + /// + internal void CancelPreviousLoad() { try { @@ -253,18 +273,20 @@ private void CancelPreviousLoad() catch { } - _loadCts?.Dispose(); _loadCts = null; } - private CancellationToken ResetCancellationToken() + /// + /// Creates a new for an upcoming load operation and returns its token. + /// + internal CancellationToken ResetCancellationToken() { _loadCts = new CancellationTokenSource(); return _loadCts.Token; } - private static bool HasSameKey(ImageSource? a, ImageSource? b) + private static bool HasSameKey(MediaSource? a, MediaSource? b) { return a is not null && b is not null && string.Equals(a.CacheKey, b.CacheKey, StringComparison.Ordinal); } @@ -274,19 +296,16 @@ private static partial class Log [LoggerMessage(1, LogLevel.Debug, "Begin load for key '{CacheKey}'", EventName = "BeginLoad")] public static partial void BeginLoad(ILogger logger, string cacheKey); - [LoggerMessage(2, LogLevel.Debug, "Loaded image from cache for key '{CacheKey}'", EventName = "CacheHit")] + [LoggerMessage(2, LogLevel.Debug, "Loaded media from cache for key '{CacheKey}'", EventName = "CacheHit")] public static partial void CacheHit(ILogger logger, string cacheKey); - [LoggerMessage(3, LogLevel.Debug, "Streaming image for key '{CacheKey}'", EventName = "StreamStart")] + [LoggerMessage(3, LogLevel.Debug, "Streaming media for key '{CacheKey}'", EventName = "StreamStart")] public static partial void StreamStart(ILogger logger, string cacheKey); - [LoggerMessage(4, LogLevel.Debug, "Image load succeeded for key '{CacheKey}'", EventName = "LoadSuccess")] + [LoggerMessage(4, LogLevel.Debug, "Media load succeeded for key '{CacheKey}'", EventName = "LoadSuccess")] public static partial void LoadSuccess(ILogger logger, string cacheKey); - [LoggerMessage(5, LogLevel.Debug, "Image load failed for key '{CacheKey}'", EventName = "LoadFailed")] + [LoggerMessage(5, LogLevel.Debug, "Media load failed for key '{CacheKey}'", EventName = "LoadFailed")] public static partial void LoadFailed(ILogger logger, string cacheKey, Exception exception); - - [LoggerMessage(6, LogLevel.Debug, "Revoked image URL on dispose", EventName = "RevokedUrl")] - public static partial void RevokedUrl(ILogger logger); } } diff --git a/src/Components/Web/src/Media/MediaContext.cs b/src/Components/Web/src/Media/MediaContext.cs new file mode 100644 index 000000000000..3e29e4ad9994 --- /dev/null +++ b/src/Components/Web/src/Media/MediaContext.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web.Media; + +/// +/// Base context supplied to media component custom content. +/// Used by and , and as the base for . +/// +public class MediaContext +{ + /// + /// The object URL for the media (image/video) or null if not loaded yet (not used for FileDownload). + /// + public string? ObjectUrl { get; internal set; } + + /// + /// Indicates whether the media is currently loading. + /// + public bool IsLoading { get; internal set; } + + /// + /// Indicates whether the last load attempt failed. + /// + public bool HasError { get; internal set; } + + private Action? _capture; + private ElementReference _element; + + internal void Initialize(Action capture) => _capture = capture; + + /// + /// Element reference for use with @ref. Assigning this propagates the DOM element to the component. + /// + public ElementReference Element + { + get => _element; + set + { + _element = value; + _capture?.Invoke(value); + } + } +} diff --git a/src/Components/Web/src/Image/ImageSource.cs b/src/Components/Web/src/Media/MediaSource.cs similarity index 62% rename from src/Components/Web/src/Image/ImageSource.cs rename to src/Components/Web/src/Media/MediaSource.cs index d5848494374f..8d51cbc771dc 100644 --- a/src/Components/Web/src/Image/ImageSource.cs +++ b/src/Components/Web/src/Media/MediaSource.cs @@ -1,23 +1,23 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.AspNetCore.Components.Web.Image; +namespace Microsoft.AspNetCore.Components.Web.Media; /// -/// Represents a single-use source for image data. An corresponds to -/// exactly one image load. It holds a single underlying that will be -/// consumed by the image component. Reuse of an instance for multiple components or multiple +/// Represents a single-use source for media data. A corresponds to +/// exactly one load operation. It holds a single underlying that will be +/// consumed by a media component. Reuse of an instance for multiple components or multiple /// loads is not supported. /// -public class ImageSource +public class MediaSource { /// - /// Gets the MIME type of the image. + /// Gets the MIME type of the media. /// public string MimeType { get; } /// - /// Gets the cache key for the image. Always non-null. + /// Gets the cache key for the media. Always non-null. /// public string CacheKey { get; } @@ -27,16 +27,19 @@ public class ImageSource public Stream Stream { get; } /// - /// Gets the length of the image data in bytes if known. + /// Gets the length of the media data in bytes if known. /// public long? Length { get; } /// - /// Initializes a new instance of with byte array data. + /// Initializes a new instance of with byte array data. /// A non-writable is created over the provided data. The byte /// array reference is not copied, so callers should not mutate it afterwards. /// - public ImageSource(byte[] data, string mimeType, string cacheKey) + /// The media data as a byte array. + /// The media MIME type. + /// The cache key used for caching and re-use. + public MediaSource(byte[] data, string mimeType, string cacheKey) { ArgumentNullException.ThrowIfNull(data); ArgumentNullException.ThrowIfNull(mimeType); @@ -49,15 +52,15 @@ public ImageSource(byte[] data, string mimeType, string cacheKey) } /// - /// Initializes a new instance of from an existing stream. + /// Initializes a new instance of from an existing stream. /// The stream reference is retained (not copied). The caller retains ownership and is - /// responsible for disposal after the image has loaded. The stream must remain readable + /// responsible for disposal after the media has loaded. The stream must remain readable /// for the duration of the load. /// /// The readable stream positioned at the beginning. - /// The image MIME type. + /// The media MIME type. /// The cache key. - public ImageSource(Stream stream, string mimeType, string cacheKey) + public MediaSource(Stream stream, string mimeType, string cacheKey) { ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(mimeType); diff --git a/src/Components/Web/src/Media/Video.cs b/src/Components/Web/src/Media/Video.cs new file mode 100644 index 000000000000..feda4e6177a9 --- /dev/null +++ b/src/Components/Web/src/Media/Video.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Web.Media; + +/* This is equivalent to a .razor file containing: + * + * + * + */ +/// +/// A component that efficiently renders video content from non-HTTP sources like byte arrays. +/// +public sealed class Video : MediaComponentBase +{ + internal override string TargetAttributeName => "src"; + + /// + /// Allows customizing the rendering of the video component. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + private protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ChildContent is not null) + { + var showInitial = Source != null && _currentSource == null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + var context = new MediaContext + { + ObjectUrl = _currentObjectUrl, + IsLoading = IsLoading || showInitial, + HasError = _hasError, + }; + context.Initialize(r => Element = r); + builder.AddContent(0, ChildContent, context); + return; + } + + // Default rendering + builder.OpenElement(0, "video"); + + if (!string.IsNullOrEmpty(_currentObjectUrl)) + { + builder.AddAttribute(1, TargetAttributeName, _currentObjectUrl); + } + + builder.AddAttribute(2, "data-blazor-video", ""); + + var defaultShowInitial = Source != null && _currentSource == null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + if (IsLoading || defaultShowInitial) + { + builder.AddAttribute(3, "data-state", "loading"); + } + else if (_hasError) + { + builder.AddAttribute(3, "data-state", "error"); + } + + builder.AddMultipleAttributes(4, AdditionalAttributes); + builder.AddElementReferenceCapture(5, r => Element = r); + builder.CloseElement(); + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 94b8278a4c7c..655300f8a8e8 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -3,21 +3,48 @@ Microsoft.AspNetCore.Components.Forms.InputHidden Microsoft.AspNetCore.Components.Forms.InputHidden.Element.get -> Microsoft.AspNetCore.Components.ElementReference? Microsoft.AspNetCore.Components.Forms.InputHidden.Element.set -> void Microsoft.AspNetCore.Components.Forms.InputHidden.InputHidden() -> void -Microsoft.AspNetCore.Components.Web.Image.Image -Microsoft.AspNetCore.Components.Web.Image.Image.AdditionalAttributes.get -> System.Collections.Generic.Dictionary? -Microsoft.AspNetCore.Components.Web.Image.Image.AdditionalAttributes.set -> void -Microsoft.AspNetCore.Components.Web.Image.Image.DisposeAsync() -> System.Threading.Tasks.ValueTask -Microsoft.AspNetCore.Components.Web.Image.Image.Image() -> void -Microsoft.AspNetCore.Components.Web.Image.Image.Source.get -> Microsoft.AspNetCore.Components.Web.Image.ImageSource! -Microsoft.AspNetCore.Components.Web.Image.Image.Source.set -> void -Microsoft.AspNetCore.Components.Web.Image.ImageSource -Microsoft.AspNetCore.Components.Web.Image.ImageSource.CacheKey.get -> string! -Microsoft.AspNetCore.Components.Web.Image.ImageSource.ImageSource(byte[]! data, string! mimeType, string! cacheKey) -> void -Microsoft.AspNetCore.Components.Web.Image.ImageSource.ImageSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void -Microsoft.AspNetCore.Components.Web.Image.ImageSource.Length.get -> long? -Microsoft.AspNetCore.Components.Web.Image.ImageSource.MimeType.get -> string! -Microsoft.AspNetCore.Components.Web.Image.ImageSource.Stream.get -> System.IO.Stream! Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! +Microsoft.AspNetCore.Components.Web.Media.FileDownload +Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.set -> void +Microsoft.AspNetCore.Components.Web.Media.FileDownload.FileDownload() -> void +Microsoft.AspNetCore.Components.Web.Media.FileDownload.FileName.get -> string! +Microsoft.AspNetCore.Components.Web.Media.FileDownload.FileName.set -> void +Microsoft.AspNetCore.Components.Web.Media.FileDownload.Text.get -> string? +Microsoft.AspNetCore.Components.Web.Media.FileDownload.Text.set -> void +Microsoft.AspNetCore.Components.Web.Media.FileDownloadContext +Microsoft.AspNetCore.Components.Web.Media.FileDownloadContext.FileDownloadContext() -> void +Microsoft.AspNetCore.Components.Web.Media.FileDownloadContext.FileName.get -> string! +Microsoft.AspNetCore.Components.Web.Media.FileDownloadContext.InvokeAsync() -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.Web.Media.Image +Microsoft.AspNetCore.Components.Web.Media.Image.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Web.Media.Image.ChildContent.set -> void +Microsoft.AspNetCore.Components.Web.Media.Image.Image() -> void +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.Source.get -> Microsoft.AspNetCore.Components.Web.Media.MediaSource! +Microsoft.AspNetCore.Components.Web.Media.MediaContext +Microsoft.AspNetCore.Components.Web.Media.MediaContext.Element.get -> Microsoft.AspNetCore.Components.ElementReference +Microsoft.AspNetCore.Components.Web.Media.MediaContext.Element.set -> void +Microsoft.AspNetCore.Components.Web.Media.MediaContext.HasError.get -> bool +Microsoft.AspNetCore.Components.Web.Media.MediaContext.IsLoading.get -> bool +Microsoft.AspNetCore.Components.Web.Media.MediaContext.MediaContext() -> void +Microsoft.AspNetCore.Components.Web.Media.MediaContext.ObjectUrl.get -> string? +Microsoft.AspNetCore.Components.Web.Media.Video +Microsoft.AspNetCore.Components.Web.Media.Video.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Web.Media.Video.ChildContent.set -> void +Microsoft.AspNetCore.Components.Web.Media.Video.Video() -> void +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.AdditionalAttributes.get -> System.Collections.Generic.Dictionary? +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.AdditionalAttributes.set -> void +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.DisposeAsync() -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.MediaComponentBase() -> void +Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.Source.set -> void +Microsoft.AspNetCore.Components.Web.Media.MediaSource +Microsoft.AspNetCore.Components.Web.Media.MediaSource.CacheKey.get -> string! +Microsoft.AspNetCore.Components.Web.Media.MediaSource.Length.get -> long? +Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data, string! mimeType, string! cacheKey) -> void +Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void +Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string! +Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream! override Microsoft.AspNetCore.Components.Forms.InputHidden.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void override Microsoft.AspNetCore.Components.Forms.InputHidden.TryParseValueFromString(string? value, out string? result, out string? validationErrorMessage) -> bool virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool diff --git a/src/Components/Web/test/Media/FileDownloadTest.cs b/src/Components/Web/test/Media/FileDownloadTest.cs new file mode 100644 index 000000000000..cc4f95964ed9 --- /dev/null +++ b/src/Components/Web/test/Media/FileDownloadTest.cs @@ -0,0 +1,276 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.Media; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Web.Media.Tests; + +/// +/// Unit tests for focusing only on behaviors not covered by Image/Video tests. +/// +public class FileDownloadTest +{ + private static readonly byte[] SampleBytes = new byte[] { 1, 2, 3, 4, 5 }; + + [Fact] + public async Task InitialRender_DoesNotInvokeJs() + { + var js = new FakeDownloadJsRuntime(); + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(FileDownload.Source)] = new MediaSource(SampleBytes, "application/octet-stream", "file-init"), + [nameof(FileDownload.FileName)] = "first.bin" + })); + + Assert.Equal(0, js.Count("Blazor._internal.BinaryMedia.downloadAsync")); + } + + [Fact] + public async Task Click_InvokesDownloadOnce() + { + var js = new FakeDownloadJsRuntime { Result = true }; + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + await renderer.RenderRootComponentAsync(id, Params("file-click", "ok.bin")); + + await ClickAnchorAsync(renderer, id); + + Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.downloadAsync")); + Assert.False(HasDataState(renderer, id, "error")); + } + + [Fact] + public async Task BlankFileName_SuppressesDownload() + { + var js = new FakeDownloadJsRuntime { Result = true }; + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + await renderer.RenderRootComponentAsync(id, Params("file-noname", " ")); + + await ClickAnchorAsync(renderer, id); + + Assert.Equal(0, js.Count("Blazor._internal.BinaryMedia.downloadAsync")); + } + + [Fact] + public async Task JsReturnsFalse_SetsErrorState() + { + var js = new FakeDownloadJsRuntime { Result = false }; + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + await renderer.RenderRootComponentAsync(id, Params("file-false", "fail.bin")); + + await ClickAnchorAsync(renderer, id); + + Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.downloadAsync")); + Assert.True(HasDataState(renderer, id, "error")); + } + + [Fact] + public async Task JsThrows_SetsErrorState() + { + var js = new FakeDownloadJsRuntime { Throw = true }; + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + await renderer.RenderRootComponentAsync(id, Params("file-throw", "throws.bin")); + + await ClickAnchorAsync(renderer, id); + + Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.downloadAsync")); + Assert.True(HasDataState(renderer, id, "error")); + } + + [Fact] + public async Task SecondClick_CancelsFirst() + { + var js = new FakeDownloadJsRuntime { DelayOnFirst = true }; + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + await renderer.RenderRootComponentAsync(id, Params("file-cancel", "cancel.bin")); + + var first = ClickAnchorAsync(renderer, id); // starts first (will delay) + await ClickAnchorAsync(renderer, id); // second click immediately + await first; // allow completion + + Assert.Equal(2, js.Count("Blazor._internal.BinaryMedia.downloadAsync")); + Assert.True(js.CapturedTokens.First().IsCancellationRequested); + Assert.False(js.CapturedTokens.Last().IsCancellationRequested); + } + + [Fact] + public async Task ProvidedHref_IsRemoved_InertHrefUsed() + { + var js = new FakeDownloadJsRuntime(); + using var renderer = CreateRenderer(js); + var comp = (FileDownload)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + + var attrs = new Dictionary { ["href"] = "https://example.org/real", ["class"] = "btn" }; + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(FileDownload.Source)] = new MediaSource(SampleBytes, "application/octet-stream", "file-href"), + [nameof(FileDownload.FileName)] = "href.bin", + [nameof(FileDownload.AdditionalAttributes)] = attrs + })); + + var frames = renderer.GetCurrentRenderTreeFrames(id); + var anchorIndex = FindAnchorIndex(frames); + Assert.True(anchorIndex >= 0, "anchor not found"); + var href = GetAttributeValue(frames, anchorIndex, "href"); + var @class = GetAttributeValue(frames, anchorIndex, "class"); + Assert.Equal("javascript:void(0)", href); + Assert.Equal("btn", @class); + } + + // Helpers + private static ParameterView Params(string key, string fileName) => ParameterView.FromDictionary(new Dictionary + { + [nameof(FileDownload.Source)] = new MediaSource(SampleBytes, "application/octet-stream", key), + [nameof(FileDownload.FileName)] = fileName + }); + + private static async Task ClickAnchorAsync(TestRenderer renderer, int componentId) + { + var frames = renderer.GetCurrentRenderTreeFrames(componentId); + var anchorIndex = FindAnchorIndex(frames); + Assert.True(anchorIndex >= 0, "anchor not found"); + ulong? handlerId = null; + for (var i = anchorIndex + 1; i < frames.Count; i++) + { + ref readonly var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Attribute) + { + if (frame.AttributeName == "onclick") + { + handlerId = frame.AttributeEventHandlerId; + } + + continue; + } + break; + } + Assert.True(handlerId.HasValue, "onclick handler not found"); + await renderer.DispatchEventAsync(handlerId.Value, new MouseEventArgs()); + } + + private static bool HasDataState(TestRenderer renderer, int componentId, string state) + { + var frames = renderer.GetCurrentRenderTreeFrames(componentId); + var anchorIndex = FindAnchorIndex(frames); + if (anchorIndex < 0) + { + return false; + } + + var value = GetAttributeValue(frames, anchorIndex, "data-state"); + return string.Equals(value, state, StringComparison.Ordinal); + } + + private static int FindAnchorIndex(ArrayRange frames) + { + for (var i = 0; i < frames.Count; i++) + { + ref readonly var f = ref frames.Array[i]; + if (f.FrameType == RenderTreeFrameType.Element && string.Equals(f.ElementName, "a", StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + return -1; + } + + private static string? GetAttributeValue(ArrayRange frames, int elementIndex, string name) + { + for (var i = elementIndex + 1; i < frames.Count; i++) + { + ref readonly var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Attribute) + { + if (string.Equals(frame.AttributeName, name, StringComparison.Ordinal)) + { + return frame.AttributeValue?.ToString(); + } + continue; + } + break; + } + return null; + } + + private static TestRenderer CreateRenderer(IJSRuntime js) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(js); + return new InteractiveTestRenderer(services.BuildServiceProvider()); + } + + private sealed class InteractiveTestRenderer : TestRenderer + { + public InteractiveTestRenderer(IServiceProvider services) : base(services) { } + protected internal override RendererInfo RendererInfo => new RendererInfo("Test", isInteractive: true); + } + + private sealed class FakeDownloadJsRuntime : IJSRuntime + { + private readonly ConcurrentQueue _invocations = new(); + public bool Result { get; set; } = true; + public bool Throw { get; set; } + public bool DelayOnFirst { get; set; } + private int _calls; + + public IReadOnlyList CapturedTokens => _invocations.Select(i => i.Token).ToList(); + public int Count(string id) => _invocations.Count(i => i.Identifier == id); + + public ValueTask InvokeAsync(string identifier, object?[]? args) => InvokeAsync(identifier, CancellationToken.None, args ?? Array.Empty()); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + _invocations.Enqueue(new Invocation(identifier, cancellationToken)); + if (identifier == "Blazor._internal.BinaryMedia.downloadAsync") + { + if (Throw) + { + return ValueTask.FromException(new InvalidOperationException("Download failed")); + } + if (DelayOnFirst && _calls == 0) + { + _calls++; + return new ValueTask(DelayAsync(cancellationToken)); + } + _calls++; + object boxed = Result; + return new ValueTask((TValue)boxed); + } + return ValueTask.FromException(new InvalidOperationException("Unexpected identifier: " + identifier)); + } + + private async Task DelayAsync(CancellationToken token) + { + try { await Task.Delay(50, token); } catch { } + object boxed = Result; + return (TValue)boxed; + } + + private record struct Invocation(string Identifier, CancellationToken Token); + } +} diff --git a/src/Components/Web/test/Image/ImageTest.cs b/src/Components/Web/test/Media/ImageTest.cs similarity index 82% rename from src/Components/Web/test/Image/ImageTest.cs rename to src/Components/Web/test/Media/ImageTest.cs index 69bab977f8f1..20c7d2e35d93 100644 --- a/src/Components/Web/test/Image/ImageTest.cs +++ b/src/Components/Web/test/Media/ImageTest.cs @@ -18,61 +18,61 @@ using Xunit; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Web.Media; -namespace Microsoft.AspNetCore.Components.Web.Image.Tests; +namespace Microsoft.AspNetCore.Components.Web.Media.Tests; /// -/// Unit tests for the Image component +/// Unit tests for the new Media.Image component /// public class ImageTest { private static readonly byte[] PngBytes = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjqK6u/g8ABVcCcYoGhmwAAAAASUVORK5CYII="); [Fact] - public async Task LoadsImage_InvokesSetImageAsync_WhenSourceProvided() + public async Task LoadsImage_InvokesSetContentAsync_WhenSourceProvided() { - var js = new FakeImageJsRuntime(cacheHit: false); + var js = new FakeMediaJsRuntime(cacheHit: false); using var renderer = CreateRenderer(js); var comp = (Image)renderer.InstantiateComponent(); var id = renderer.AssignRootComponentId(comp); - var source = new ImageSource(PngBytes, "image/png", cacheKey: "png-1"); + var source = new MediaSource(PngBytes, "image/png", cacheKey: "png-1"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = source, })); - Assert.Equal(1, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.setContentAsync")); } [Fact] public async Task SkipsReload_OnSameCacheKey() { - var js = new FakeImageJsRuntime(cacheHit: false); + var js = new FakeMediaJsRuntime(cacheHit: false); using var renderer = CreateRenderer(js); var comp = (Image)renderer.InstantiateComponent(); var id = renderer.AssignRootComponentId(comp); - var s1 = new ImageSource(new byte[10], "image/png", cacheKey: "same"); + var s1 = new MediaSource(new byte[10], "image/png", cacheKey: "same"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = s1, })); - var s2 = new ImageSource(new byte[20], "image/png", cacheKey: "same"); + var s2 = new MediaSource(new byte[20], "image/png", cacheKey: "same"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = s2, })); - // Implementation skips reloading when cache key unchanged. - Assert.Equal(1, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.setContentAsync")); } [Fact] public async Task NullSource_Throws() { - var js = new FakeImageJsRuntime(cacheHit: false); + var js = new FakeMediaJsRuntime(cacheHit: false); using var renderer = CreateRenderer(js); var comp = (Image)renderer.InstantiateComponent(); var id = renderer.AssignRootComponentId(comp); @@ -92,44 +92,43 @@ await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dic [Fact] public async Task ParameterChange_DifferentCacheKey_Reloads() { - var js = new FakeImageJsRuntime(cacheHit: false); + var js = new FakeMediaJsRuntime(cacheHit: false); using var renderer = CreateRenderer(js); var comp = (Image)renderer.InstantiateComponent(); var id = renderer.AssignRootComponentId(comp); - var s1 = new ImageSource(new byte[4], "image/png", "key-a"); + var s1 = new MediaSource(new byte[4], "image/png", "key-a"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = s1, })); - var s2 = new ImageSource(new byte[6], "image/png", "key-b"); + var s2 = new MediaSource(new byte[6], "image/png", "key-b"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = s2, })); - Assert.Equal(2, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + Assert.Equal(2, js.Count("Blazor._internal.BinaryMedia.setContentAsync")); } [Fact] public async Task ChangingSource_CancelsPreviousLoad() { - var js = new FakeImageJsRuntime(cacheHit: false) { DelayOnFirstSetCall = true }; + var js = new FakeMediaJsRuntime(cacheHit: false) { DelayOnFirstSetCall = true }; using var renderer = CreateRenderer(js); var comp = (Image)renderer.InstantiateComponent(); var id = renderer.AssignRootComponentId(comp); - var s1 = new ImageSource(new byte[10], "image/png", "k1"); + var s1 = new MediaSource(new byte[10], "image/png", "k1"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = s1, })); - var s2 = new ImageSource(new byte[10], "image/png", "k2"); + var s2 = new MediaSource(new byte[10], "image/png", "k2"); await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary { [nameof(Image.Source)] = s2, })); - // Give a tiny bit of time for cancellation to propagate for (var i = 0; i < 10 && js.CapturedTokens.Count < 2; i++) { await Task.Delay(10); @@ -137,9 +136,7 @@ await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dic Assert.NotEmpty(js.CapturedTokens); Assert.True(js.CapturedTokens.First().IsCancellationRequested); - - // Two invocations total (first canceled, second completes) - Assert.Equal(2, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + Assert.Equal(2, js.Count("Blazor._internal.BinaryMedia.setContentAsync")); } private static TestRenderer CreateRenderer(IJSRuntime js) @@ -156,21 +153,19 @@ public InteractiveTestRenderer(IServiceProvider serviceProvider) : base(serviceP protected internal override RendererInfo RendererInfo => new RendererInfo("Test", isInteractive: true); } - private sealed class FakeImageJsRuntime : IJSRuntime + private sealed class FakeMediaJsRuntime : IJSRuntime { public sealed record Invocation(string Identifier, object?[] Args, CancellationToken Token); private readonly ConcurrentQueue _invocations = new(); private readonly ConcurrentDictionary _memoryCache = new(); private readonly bool _forceCacheHit; - public FakeImageJsRuntime(bool cacheHit) { _forceCacheHit = cacheHit; } + public FakeMediaJsRuntime(bool cacheHit) { _forceCacheHit = cacheHit; } public int TotalInvocationCount => _invocations.Count; public int Count(string id) => _invocations.Count(i => i.Identifier == id); public IReadOnlyList CapturedTokens => _invocations.Select(i => i.Token).ToList(); - public void MarkCached(string cacheKey) => _memoryCache[cacheKey] = true; - // Simulation flags public bool DelayOnFirstSetCall { get; set; } public bool ForceFail { get; set; } public bool FailOnce { get; set; } = true; @@ -186,7 +181,7 @@ public ValueTask InvokeAsync(string identifier, CancellationToke args ??= Array.Empty(); _invocations.Enqueue(new Invocation(identifier, args, cancellationToken)); - if (identifier == "Blazor._internal.BinaryImageComponent.setImageAsync") + if (identifier == "Blazor._internal.BinaryMedia.setContentAsync") { _setCalls++; var cacheKey = args.Length >= 4 ? args[3] as string : null; diff --git a/src/Components/test/E2ETest/Tests/FileDownloadTest.cs b/src/Components/test/E2ETest/Tests/FileDownloadTest.cs new file mode 100644 index 000000000000..6307be02f5f5 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/FileDownloadTest.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using BasicTestApp; +using BasicTestApp.MediaTest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; +using Xunit; +using Xunit.Abstractions; +using System.Globalization; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class FileDownloadTest : ServerTestBase> +{ + public FileDownloadTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase); + Browser.MountTestComponent(); + } + + private void InstrumentDownload() + { + var success = ((IJavaScriptExecutor)Browser).ExecuteAsyncScript(@" + var callback = arguments[arguments.length - 1]; + (function(){ + if (window.__downloadInstrumentationStarted){ callback(true); return; } + window.__downloadInstrumentationStarted = true; + function tryPatch(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; + if (!root || !root.downloadAsync){ setTimeout(tryPatch, 50); return; } + if (!window.__origDownloadAsync){ + window.__origDownloadAsync = root.downloadAsync; + window.__downloadCalls = 0; + window.__lastFileName = null; + root.downloadAsync = async function(...a){ + window.__downloadCalls++; + // downloadAsync(element, streamRef, mimeType, totalBytes, fileName) + window.__lastFileName = a[4]; // fileName index + if (window.__forceErrorFileName && a[4] === window.__forceErrorFileName){ + return false; // simulate failure + } + return window.__origDownloadAsync.apply(this, a); + }; + } + callback(true); + } + tryPatch(); + })(); + ") is true; + Assert.True(success, "Failed to instrument downloadAsync"); + Thread.Sleep(100); + } + + private int GetDownloadCallCount() => Convert.ToInt32(((IJavaScriptExecutor)Browser).ExecuteScript("return window.__downloadCalls || 0;"), CultureInfo.InvariantCulture); + + private string? GetLastFileName() => (string?)((IJavaScriptExecutor)Browser).ExecuteScript("return window.__lastFileName || null;"); + + [Fact] + public void InitialRender_DoesNotStartDownload() + { + InstrumentDownload(); + // Component rendered but no download link shown until button clicked + Assert.Equal(0, GetDownloadCallCount()); + } + + [Fact] + public void Click_InitiatesDownload() + { + InstrumentDownload(); + Browser.FindElement(By.Id("show-download")).Click(); + var link = Browser.FindElement(By.Id("download-link")); + link.Click(); + Browser.True(() => GetDownloadCallCount() >= 1); + Browser.True(() => GetLastFileName() == "test.png"); + Assert.Null(link.GetAttribute("data-state")); // no error or loading after completion + } + + [Fact] + public void BlankFileName_SuppressesDownload() + { + InstrumentDownload(); + Browser.FindElement(By.Id("show-blank-filename")).Click(); + var link = Browser.FindElement(By.Id("blank-download-link")); + link.Click(); + // Should not invoke JS because filename blank. Wait briefly to ensure no async call occurs. + var start = DateTime.UtcNow; + while (DateTime.UtcNow - start < TimeSpan.FromMilliseconds(200)) + { + Assert.True(GetDownloadCallCount() == 0, "Download should not have started for blank filename."); + Thread.Sleep(20); + } + Assert.Equal(0, GetDownloadCallCount()); + } + + [Fact] + public void ErrorDownload_SetsErrorState() + { + InstrumentDownload(); + // Force simulated failure via instrumentation hook + ((IJavaScriptExecutor)Browser).ExecuteScript("window.__forceErrorFileName='error.txt';"); + Browser.FindElement(By.Id("show-error-download")).Click(); + var link = Browser.FindElement(By.Id("error-download-link")); + link.Click(); + Browser.Equal("error", () => link.GetAttribute("data-state")); + } + + [Fact] + public void ProvidedHref_IsRemoved_AndInertHrefUsed() + { + Browser.FindElement(By.Id("show-custom-href")).Click(); + var link = Browser.FindElement(By.Id("custom-href-download-link")); + var href = link.GetAttribute("href"); + Assert.Equal("javascript:void(0)", href); + } + + [Fact] + public void RapidClicks_CancelsFirstAndStartsSecond() + { + // Instrument with controllable delay on first call for cancellation scenario + var success = ((IJavaScriptExecutor)Browser).ExecuteAsyncScript(@" + var callback = arguments[arguments.length - 1]; + (function(){ + function patch(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; + if (!root || !root.downloadAsync){ requestAnimationFrame(patch); return; } + if (!window.__origDownloadAsyncDelay){ + window.__origDownloadAsyncDelay = root.downloadAsync; + window.__downloadCalls = 0; + window.__downloadDelayResolvers = null; + root.downloadAsync = async function(...a){ + window.__downloadCalls++; + if (window.__downloadCalls === 1){ + const getResolvers = () => { + if (Promise.fromResolvers) return Promise.fromResolvers(); + let resolve, reject; const p = new Promise((r,j)=>{ resolve=r; reject=j; }); + return { promise: p, resolve, reject }; + }; + if (!window.__downloadDelayResolvers){ + window.__downloadDelayResolvers = getResolvers(); + } + await window.__downloadDelayResolvers.promise; + } + return window.__origDownloadAsyncDelay.apply(this, a); + }; + } + callback(true); + } + patch(); + })(); + ") is true; + Assert.True(success, "Failed to instrument for rapid clicks test"); + + Browser.FindElement(By.Id("show-download")).Click(); + var link = Browser.FindElement(By.Id("download-link")); + link.Click(); // first (delayed) + link.Click(); // second should cancel first + + ((IJavaScriptExecutor)Browser).ExecuteScript("if (window.__downloadDelayResolvers) { window.__downloadDelayResolvers.resolve(); }"); + + Browser.True(() => Convert.ToInt32(((IJavaScriptExecutor)Browser).ExecuteScript("return window.__downloadCalls || 0;"), CultureInfo.InvariantCulture) >= 2); + Browser.True(() => string.IsNullOrEmpty(link.GetAttribute("data-state")) || link.GetAttribute("data-state") == null); + + // Cleanup instrumentation + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + (function(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; + if (root && window.__origDownloadAsyncDelay){ root.downloadAsync = window.__origDownloadAsyncDelay; delete window.__origDownloadAsyncDelay; } + delete window.__downloadDelayResolvers; + })();"); + } + + [Fact] + public void TemplatedFileDownload_Works() + { + InstrumentDownload(); + Browser.FindElement(By.Id("show-templated-download")).Click(); + var link = Browser.FindElement(By.Id("templated-download-link")); + Assert.NotNull(link); + link.Click(); + Browser.True(() => GetDownloadCallCount() >= 1); + Browser.True(() => GetLastFileName() == "templated.png"); + var status = Browser.FindElement(By.Id("templated-download-status")).Text; + Assert.True(status == "Idle/Done"); + } +} diff --git a/src/Components/test/E2ETest/Tests/ImageTest.cs b/src/Components/test/E2ETest/Tests/ImageTest.cs index 5edc3c8f5fd9..18a24255857f 100644 --- a/src/Components/test/E2ETest/Tests/ImageTest.cs +++ b/src/Components/test/E2ETest/Tests/ImageTest.cs @@ -3,7 +3,7 @@ using System.Globalization; using BasicTestApp; -using BasicTestApp.ImageTest; +using BasicTestApp.MediaTest; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.E2ETesting; @@ -29,18 +29,18 @@ protected override void InitializeAsyncCore() Browser.MountTestComponent(); } - private void ClearImageCache() + private void ClearMediaCache() { var ok = (bool)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(@" var done = arguments[0]; (async () => { try { if ('caches' in window) { - await caches.delete('blazor-image-cache'); + await caches.delete('blazor-media-cache'); } // Reset memoized cache promise if present try { - const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; if (root && 'cachePromise' in root) { root.cachePromise = undefined; } @@ -51,7 +51,7 @@ private void ClearImageCache() } })(); "); - Assert.True(ok, "Failed to clear image cache"); + Assert.True(ok, "Failed to clear media cache"); } [Fact] @@ -166,23 +166,25 @@ public void ImageRenders_WithCorrectDimensions() [Fact] public void Image_CompletesLoad_AfterArtificialDelay() { + // Instrument setContentAsync to pause before fulfilling first image load until explicitly resolved. ((IJavaScriptExecutor)Browser).ExecuteScript(@" (function(){ - const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; if (!root) return; - if (!window.__origSetImageAsync) { - window.__origSetImageAsync = root.setImageAsync; - root.setImageAsync = async function(...args){ + if (!window.__origSetContentAsync) { + window.__origSetContentAsync = root.setContentAsync; + root.setContentAsync = async function(...args){ const getResolvers = () => { if (Promise.fromResolvers) return Promise.fromResolvers(); - let resolve, reject; - const promise = new Promise((r,j)=>{ resolve=r; reject=j; }); + let resolve, reject; const promise = new Promise((r,j)=>{ resolve=r; reject=j; }); return { promise, resolve, reject }; }; - const resolvers = getResolvers(); - window.__imagePromiseResolvers = resolvers; - await resolvers.promise; - return window.__origSetImageAsync.apply(this, args); + if (!window.__imageContentDelay){ + const resolvers = getResolvers(); + window.__imageContentDelay = resolvers; // first invocation delayed + await resolvers.promise; + } + return window.__origSetContentAsync.apply(this, args); }; } })();"); @@ -192,7 +194,8 @@ public void Image_CompletesLoad_AfterArtificialDelay() var imageElement = Browser.FindElement(By.Id("png-basic")); Assert.NotNull(imageElement); - ((IJavaScriptExecutor)Browser).ExecuteScript("if (window.__imagePromiseResolvers) { window.__imagePromiseResolvers.resolve(); }"); + // Release the delayed promise so load can complete. + ((IJavaScriptExecutor)Browser).ExecuteScript("if (window.__imageContentDelay) { window.__imageContentDelay.resolve(); }"); Browser.True(() => { var src = imageElement.GetAttribute("src"); @@ -203,19 +206,19 @@ public void Image_CompletesLoad_AfterArtificialDelay() // Restore original function and clean up instrumentation ((IJavaScriptExecutor)Browser).ExecuteScript(@" (function(){ - const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; - if (root && window.__origSetImageAsync) { - root.setImageAsync = window.__origSetImageAsync; - delete window.__origSetImageAsync; + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; + if (root && window.__origSetContentAsync) { + root.setContentAsync = window.__origSetContentAsync; + delete window.__origSetContentAsync; } - delete window.__imagePromiseResolvers; + delete window.__imageContentDelay; })();"); } [Fact] public void ImageCache_PersistsAcrossPageReloads() { - ClearImageCache(); + ClearMediaCache(); Browser.FindElement(By.Id("load-cached-jpg")).Click(); Browser.Equal("Cached JPG loaded", () => Browser.FindElement(By.Id("current-status")).Text); @@ -231,14 +234,14 @@ public void ImageCache_PersistsAcrossPageReloads() // Re‑instrument after refresh so we see cache vs stream on the second load ((IJavaScriptExecutor)Browser).ExecuteScript(@" (function(){ - const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; if (!root) return; window.__cacheHits = 0; window.__streamCalls = 0; - if (!window.__origSetImageAsync){ - window.__origSetImageAsync = root.setImageAsync; - root.setImageAsync = async function(...a){ - const result = await window.__origSetImageAsync.apply(this, a); + if (!window.__origSetContentAsync){ + window.__origSetContentAsync = root.setContentAsync; + root.setContentAsync = async function(...a){ + const result = await window.__origSetContentAsync.apply(this, a); if (result && result.fromCache) window.__cacheHits++; if (result && result.success && !result.fromCache) window.__streamCalls++; return result; @@ -264,8 +267,8 @@ public void ImageCache_PersistsAcrossPageReloads() // Restore ((IJavaScriptExecutor)Browser).ExecuteScript(@" (function(){ - const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; - if (root && window.__origSetImageAsync){ root.setImageAsync = window.__origSetImageAsync; delete window.__origSetImageAsync; } + const root = Blazor && Blazor._internal && Blazor._internal.BinaryMedia; + if (root && window.__origSetContentAsync){ root.setContentAsync = window.__origSetContentAsync; delete window.__origSetContentAsync; } delete window.__cacheHits; delete window.__streamCalls; })();"); @@ -367,4 +370,30 @@ public void InvalidMimeImage_SetsErrorState() var src = img.GetAttribute("src"); Assert.True(string.IsNullOrEmpty(src) || src.StartsWith("blob:", StringComparison.Ordinal)); } + + [Fact] + public void TemplatedImage_Loads_WithContextStates() + { + Browser.FindElement(By.Id("load-templated-image")).Click(); + Browser.Equal("Templated image loaded", () => Browser.FindElement(By.Id("current-status")).Text); + + var wrapper = Browser.FindElement(By.Id("templated-image-wrapper")); + Assert.NotNull(wrapper); + + var img = Browser.FindElement(By.Id("templated-image")); + Browser.True(() => + { + var src = img.GetAttribute("src"); + return !string.IsNullOrEmpty(src) && src.StartsWith("blob:", StringComparison.Ordinal); + }); + + var status = Browser.FindElement(By.Id("templated-image-status")).Text; + Assert.Equal("Loaded", status); + + var cls = wrapper.GetAttribute("class"); + Assert.Contains("templated-image", cls); + Assert.Contains("ready", cls); + Assert.DoesNotContain("loading", cls); + Assert.DoesNotContain("error", cls); + } } diff --git a/src/Components/test/E2ETest/Tests/VideoTest.cs b/src/Components/test/E2ETest/Tests/VideoTest.cs new file mode 100644 index 000000000000..37f416a14791 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/VideoTest.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// NOTE: Most shared media component behaviors (caching, error handling and URL revocation) +// are validated in ImageTest. To avoid duplication, this suite intentionally contains only +// tests that exercise