File: Media\MediaComponentBase.cs
Web Access
Project: src\src\Components\Web\src\Microsoft.AspNetCore.Components.Web.csproj (Microsoft.AspNetCore.Components.Web)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
 
namespace Microsoft.AspNetCore.Components.Web.Media;
 
/// <summary>
/// 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., <c>src</c> or <c>href</c>) used
/// </summary>
public abstract partial class MediaComponentBase : IComponent, IHandleAfterRender, IAsyncDisposable
{
    private RenderHandle _renderHandle;
 
    /// <summary>
    /// The current object URL (blob URL) assigned to the underlying element, or <c>null</c> if not yet loaded
    /// or if a previous load failed/was cancelled.
    /// </summary>
    internal string? _currentObjectUrl;
 
    /// <summary>
    /// Indicates whether the last load attempt ended in an error state for the active cache key.
    /// </summary>
    internal bool _hasError;
 
    private bool _isDisposed;
    private bool _initialized;
    private bool _hasPendingRender;
 
    /// <summary>
    /// 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.
    /// </summary>
    internal string? _activeCacheKey;
 
    /// <summary>
    /// The <see cref="MediaSource"/> instance currently being processed (or <c>null</c> if none).
    /// </summary>
    internal MediaSource? _currentSource;
    private CancellationTokenSource? _loadCts;
 
    /// <summary>
    /// 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.
    /// </summary>
    internal bool IsLoading => _currentSource != null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError;
 
    /// <summary>
    /// Gets a value indicating whether the renderer is interactive so client-side JS interop can be performed.
    /// </summary>
    internal bool IsInteractive => _renderHandle.IsInitialized && _renderHandle.RendererInfo.IsInteractive;
 
    /// <summary>
    /// Gets the reference to the rendered HTML element for this media component.
    /// </summary>
    internal ElementReference? Element { get; set; }
 
    /// <summary>
    /// Gets or sets the JS runtime used for interop with the browser to materialize media object URLs.
    /// </summary>
    [Inject] internal IJSRuntime JSRuntime { get; set; } = default!;
 
    /// <summary>
    /// Gets or sets the logger factory used to create the <see cref="Logger"/> instance.
    /// </summary>
    [Inject] internal ILoggerFactory LoggerFactory { get; set; } = default!;
 
    /// <summary>
    /// Logger for media operations.
    /// </summary>
    private ILogger Logger => _logger ??= LoggerFactory.CreateLogger(GetType());
    private ILogger? _logger;
 
    internal abstract string TargetAttributeName { get; }
 
    /// <summary>
    /// Determines whether the component should automatically invoke a media load after the first render
    /// and whenever the <see cref="Source"/> changes. Override and return <c>false</c> for components
    /// (such as download buttons) that defer loading until an explicit user action.
    /// </summary>
    internal virtual bool ShouldAutoLoad => true;
 
    /// <summary>
    /// Gets or sets the media source.
    /// </summary>
    [Parameter, EditorRequired] public required MediaSource Source { get; set; }
 
    /// <summary>
    /// Unmatched attributes applied to the rendered element.
    /// </summary>
    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
 
    void IComponent.Attach(RenderHandle renderHandle)
    {
        if (_renderHandle.IsInitialized)
        {
            throw new InvalidOperationException("Component is already attached to a render handle.");
        }
        _renderHandle = renderHandle;
    }
 
    Task IComponent.SetParametersAsync(ParameterView parameters)
    {
        var previousSource = Source;
 
        parameters.SetParameterProperties(this);
 
        if (Source is null)
        {
            throw new InvalidOperationException($"{nameof(MediaComponentBase)}.{nameof(Source)} is required.");
        }
 
        if (!_initialized)
        {
            Render();
            _initialized = true;
            return Task.CompletedTask;
        }
 
        if (!HasSameKey(previousSource, Source))
        {
            Render();
        }
 
        return Task.CompletedTask;
    }
 
    async Task IHandleAfterRender.OnAfterRenderAsync()
    {
        var source = Source;
        if (!IsInteractive || source is null || !ShouldAutoLoad)
        {
            return;
        }
 
        if (_currentSource != null && HasSameKey(_currentSource, source))
        {
            return;
        }
 
        CancelPreviousLoad();
        var token = ResetCancellationToken();
 
        _currentSource = source;
        try
        {
            await LoadMediaAsync(source, token);
        }
        catch (OperationCanceledException)
        {
        }
    }
 
    /// <summary>
    /// Triggers a render of the component by invoking the <see cref="BuildRenderTree"/> method.
    /// Ensures that only one render operation is pending at a time to prevent redundant renders.
    /// </summary>
    internal void Render()
    {
        Debug.Assert(_renderHandle.IsInitialized);
 
        if (!_hasPendingRender)
        {
            _hasPendingRender = true;
            _renderHandle.Render(BuildRenderTree);
            _hasPendingRender = false;
        }
    }
 
    private protected virtual void BuildRenderTree(RenderTreeBuilder builder) { }
 
    private sealed class MediaLoadResult
    {
        public bool Success { get; set; }
        public bool FromCache { get; set; }
        public string? ObjectUrl { get; set; }
        public string? Error { get; set; }
    }
 
    private async Task LoadMediaAsync(MediaSource? source, CancellationToken cancellationToken)
    {
        if (source == null || !IsInteractive)
        {
            return;
        }
 
        _activeCacheKey = source.CacheKey;
 
        try
        {
            Log.BeginLoad(Logger, source.CacheKey);
 
            cancellationToken.ThrowIfCancellationRequested();
 
            using var streamRef = new DotNetStreamReference(source.Stream, leaveOpen: true);
 
            var result = await JSRuntime.InvokeAsync<MediaLoadResult>(
                "Blazor._internal.BinaryMedia.setContentAsync",
                cancellationToken,
                Element,
                streamRef,
                source.MimeType,
                source.CacheKey,
                source.Length,
                TargetAttributeName);
 
            if (_activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested)
            {
                if (result.Success)
                {
                    _currentObjectUrl = result.ObjectUrl;
                    _hasError = false;
 
                    if (result.FromCache)
                    {
                        Log.CacheHit(Logger, source.CacheKey);
                    }
                    else
                    {
                        Log.StreamStart(Logger, source.CacheKey);
                    }
 
                    Log.LoadSuccess(Logger, source.CacheKey);
                }
                else
                {
                    _hasError = true;
                    Log.LoadFailed(Logger, source.CacheKey, new InvalidOperationException(result.Error ?? "Unknown error"));
                }
 
                Render();
            }
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception ex)
        {
            Log.LoadFailed(Logger, source?.CacheKey ?? "(null)", ex);
            if (source != null && _activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested)
            {
                _currentObjectUrl = null;
                _hasError = true;
                Render();
            }
        }
    }
 
    /// <inheritdoc />
    public ValueTask DisposeAsync()
    {
        if (!_isDisposed)
        {
            _isDisposed = true;
            CancelPreviousLoad();
        }
        return new ValueTask();
    }
 
    /// <summary>
    /// Cancels any in-flight media load operation, if one is active, by signalling its <see cref="CancellationTokenSource"/>.
    /// </summary>
    internal void CancelPreviousLoad()
    {
        try
        {
            _loadCts?.Cancel();
        }
        catch
        {
        }
        _loadCts?.Dispose();
        _loadCts = null;
    }
 
    /// <summary>
    /// Creates a new <see cref="CancellationTokenSource"/> for an upcoming load operation and returns its token.
    /// </summary>
    internal CancellationToken ResetCancellationToken()
    {
        _loadCts = new CancellationTokenSource();
        return _loadCts.Token;
    }
 
    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);
    }
 
    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 media from cache for key '{CacheKey}'", EventName = "CacheHit")]
        public static partial void CacheHit(ILogger logger, string cacheKey);
 
        [LoggerMessage(3, LogLevel.Debug, "Streaming media for key '{CacheKey}'", EventName = "StreamStart")]
        public static partial void StreamStart(ILogger logger, string cacheKey);
 
        [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, "Media load failed for key '{CacheKey}'", EventName = "LoadFailed")]
        public static partial void LoadFailed(ILogger logger, string cacheKey, Exception exception);
    }
}