Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@
VerticalOptions="Center"
Text="Keep screen on" />
</HorizontalStackLayout>
<HorizontalStackLayout Padding="0,10,0,0">
<Switch
x:Name="CustomHeadersSwitch"
Margin="0,0,5,0"
ThumbColor="White"
OnColor="LimeGreen"
Toggled="CustomHeadersToggled" />
<Label
VerticalOptions="Center"
Text="Custom HTTP headers" />
</HorizontalStackLayout>
<VerticalStackLayout x:Name="HeadersPanel" IsVisible="false" Padding="0,5,0,0" Spacing="5">
<Grid ColumnDefinitions="*, *, 40" ColumnSpacing="5">
<Entry x:Name="HeaderNameEntry" Placeholder="Header name" HorizontalOptions="Fill" />
<Entry Grid.Column="1" x:Name="HeaderValueEntry" Placeholder="Header value" HorizontalOptions="Fill" />
<Button Grid.Column="2" Text="+" Clicked="AddHeaderClicked" />
</Grid>
<Label x:Name="HeadersSummaryLabel" Text="No headers defined" FontSize="12" TextColor="Gray" />
<Button Text="Clear All Headers" Clicked="ClearHeadersClicked" />
</VerticalStackLayout>
</VerticalStackLayout>
</Grid>
</ScrollView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public partial class MediaElementPage : BasePage<MediaElementViewModel>
const string resetSource = "Reset Source to null";
const string loadMusic = "Load Music";

Dictionary<string, string>? customHeaders;

const string botImageUrl = "https://lh3.googleusercontent.com/pw/AP1GczNRrebWCJvfdIau1EbsyyYiwAfwHS0JXjbioXvHqEwYIIdCzuLodQCZmA57GADIo5iB3yMMx3t_vsefbfoHwSg0jfUjIXaI83xpiih6d-oT7qD_slR0VgNtfAwJhDBU09kS5V2T5ZML-WWZn8IrjD4J-g=w1792-h1024-s-no-gm";
const string hlsStreamTestUrl = "https://mtoczko.github.io/hls-test-streams/test-gap/playlist.m3u8";
const string hal9000AudioUrl = "https://github.com/prof3ssorSt3v3/media-sample-files/raw/master/hal-9000.mp3";
Expand Down Expand Up @@ -160,7 +162,69 @@ await DisplayAlertAsync("Error Loading URL Source", "No value was found to load
return;
}

MediaElement.Source = MediaSource.FromUri(CustomSourceEntry.Text);
var customSource = new UriMediaSource { Uri = new Uri(CustomSourceEntry.Text) };
ApplyCustomHeaders(customSource);
MediaElement.Source = customSource;
}

void AddHeaderClicked(object? sender, EventArgs? e)
{
var name = HeaderNameEntry.Text?.Trim();
var value = HeaderValueEntry.Text?.Trim();

if (string.IsNullOrWhiteSpace(name))
{
return;
}

customHeaders ??= new Dictionary<string, string>();
customHeaders[name] = value ?? string.Empty;

HeaderNameEntry.Text = string.Empty;
HeaderValueEntry.Text = string.Empty;
UpdateHeadersSummary();
logger.LogInformation("Custom HTTP header added: {HeaderName}", name);
}

void ClearHeadersClicked(object? sender, EventArgs? e)
{
customHeaders = null;
UpdateHeadersSummary();
logger.LogInformation("Custom HTTP headers cleared.");
}

void CustomHeadersToggled(object? sender, ToggledEventArgs e)
{
HeadersPanel.IsVisible = e.Value;
if (!e.Value)
{
customHeaders = null;
UpdateHeadersSummary();
}
}

void UpdateHeadersSummary()
{
if (customHeaders is not { Count: > 0 })
{
HeadersSummaryLabel.Text = "No headers defined";
return;
}

HeadersSummaryLabel.Text = string.Join(", ", customHeaders.Keys);
}

void ApplyCustomHeaders(UriMediaSource source)
{
if (customHeaders is not { Count: > 0 })
{
return;
}

foreach (var header in customHeaders)
{
source.HttpHeaders[header.Key] = header.Value;
}
}

async void ChangeSourceClicked(object? sender, EventArgs? e)
Expand All @@ -177,15 +241,18 @@ async void ChangeSourceClicked(object? sender, EventArgs? e)
MediaElement.MetadataTitle = "Big Buck Bunny";
MediaElement.MetadataArtworkUrl = botImageUrl;
MediaElement.MetadataArtist = "Big Buck Bunny Album";
MediaElement.Source =
MediaSource.FromUri(StreamingVideoUrls.BuckBunny);
var mp4Source = new UriMediaSource { Uri = new Uri(StreamingVideoUrls.BuckBunny) };
ApplyCustomHeaders(mp4Source);
MediaElement.Source = mp4Source;
return;

case loadHls:
MediaElement.MetadataArtist = "HLS Album";
MediaElement.MetadataArtworkUrl = botImageUrl;
MediaElement.MetadataTitle = "HLS Title";
MediaElement.Source = MediaSource.FromUri(hlsStreamTestUrl);
var hlsSource = new UriMediaSource { Uri = new Uri(hlsStreamTestUrl) };
ApplyCustomHeaders(hlsSource);
MediaElement.Source = hlsSource;
return;

case resetSource:
Expand Down Expand Up @@ -219,7 +286,9 @@ async void ChangeSourceClicked(object? sender, EventArgs? e)
MediaElement.MetadataTitle = "HAL 9000";
MediaElement.MetadataArtist = "HAL 9000 Album";
MediaElement.MetadataArtworkUrl = botImageUrl;
MediaElement.Source = MediaSource.FromUri(hal9000AudioUrl);
var musicSource = new UriMediaSource { Uri = new Uri(hal9000AudioUrl) };
ApplyCustomHeaders(musicSource);
MediaElement.Source = musicSource;
return;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ internal event EventHandler SourceChanged
return new UriMediaSource { Uri = uri };
}

/// <summary>
/// Creates a <see cref="UriMediaSource"/> from an absolute URI with custom HTTP headers.
/// </summary>
/// <param name="uri">Absolute URI to load.</param>
/// <param name="httpHeaders">HTTP headers to include in the request (e.g. Authorization).</param>
/// <returns>A <see cref="UriMediaSource"/> instance.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="uri"/> is not an absolute URI.</exception>
public static MediaSource? FromUri(Uri? uri, IDictionary<string, string>? httpHeaders)
{
if (uri is null)
{
return null;
}

if (!uri.IsAbsoluteUri)
{
throw new ArgumentException("Uri must be absolute", nameof(uri));
}

return new UriMediaSource { Uri = uri, HttpHeaders = httpHeaders ?? new Dictionary<string, string>() };
}

/// <summary>
/// Triggers the <see cref="SourceChanged"/> event.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ public Uri? Uri
set => SetValue(UriProperty, value);
}

/// <summary>
/// Gets or sets the HTTP headers to include in the request when loading the media from <see cref="Uri"/>.
/// </summary>
/// <remarks>
/// Use this to provide authentication tokens (e.g. <c>Authorization: Bearer &lt;token&gt;</c>) or other custom HTTP headers.
/// Setting this property triggers a source update on the underlying platform player.
/// Not supported on Tizen.
/// </remarks>
public IDictionary<string, string> HttpHeaders
{
get => field ??= new Dictionary<string, string>();
set
{
field = value ?? new Dictionary<string, string>();
OnSourceChanged();
}
}

/// <inheritdoc/>
public override string ToString() => $"Uri: {Uri}";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Storage.Streams;
using HttpClient = Windows.Web.Http.HttpClient;
using HttpRequestMessage = Windows.Web.Http.HttpRequestMessage;
using HttpMethod = Windows.Web.Http.HttpMethod;
using HttpCompletionOption = Windows.Web.Http.HttpCompletionOption;

namespace CommunityToolkit.Maui.Core.Views;

/// <summary>
/// An <see cref="IRandomAccessStream"/> implementation backed by HTTP Range requests,
/// enabling progressive streaming of media content with custom HTTP headers without buffering the entire file in memory.
/// </summary>
sealed partial class HttpRandomAccessStream : IRandomAccessStream
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this class should live inside Views

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure either.. Though it's an internal sealed class exclusively used by MediaManager.windows.cs. I can move it elsewhere if needed!

{
readonly HttpClient httpClient;
readonly Uri requestUri;
readonly ulong size;

HttpRandomAccessStream(HttpClient httpClient, Uri requestUri, ulong size)
{
this.httpClient = httpClient;
this.requestUri = requestUri;
this.size = size;
}

/// <inheritdoc/>
public ulong Size
{
get => size;
set => throw new NotSupportedException("Cannot set size on a read-only HTTP stream.");
}

/// <inheritdoc/>
public ulong Position { get; private set; }

/// <inheritdoc/>
public bool CanRead => true;

/// <inheritdoc/>
public bool CanWrite => false;

/// <summary>
/// Creates an <see cref="HttpRandomAccessStream"/> by issuing a HEAD request to determine the content length.
/// </summary>
/// <param name="httpClient">The <see cref="HttpClient"/> configured with the desired HTTP headers.</param>
/// <param name="uri">The URI of the media resource.</param>
/// <param name="cancellationToken"><see cref="CancellationToken"/>.</param>
/// <returns>A new <see cref="HttpRandomAccessStream"/> instance.</returns>
internal static async Task<HttpRandomAccessStream> CreateAsync(HttpClient httpClient, Uri uri, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(uri);

using var request = new HttpRequestMessage(HttpMethod.Head, uri);
using var response = await httpClient.SendRequestAsync(request).AsTask(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
response.EnsureSuccessStatusCode();

var contentLength = response.Content.Headers.ContentLength ?? 0;
return new HttpRandomAccessStream(httpClient, uri, contentLength);
}

/// <inheritdoc/>
public IAsyncOperationWithProgress<IBuffer, uint> ReadAsync(IBuffer buffer, uint count, InputStreamOptions options)
{
return AsyncInfo.Run<IBuffer, uint>(async (cancellationToken, _) =>
{
if (count is 0)
{
return buffer;
}

using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var rangeEnd = Position + count - 1;
request.Headers.TryAppendWithoutValidation("Range", $"bytes={Position}-{rangeEnd}");

using var response = await httpClient.SendRequestAsync(request, HttpCompletionOption.ResponseHeadersRead).AsTask(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
response.EnsureSuccessStatusCode();

var inputStream = await response.Content.ReadAsInputStreamAsync().AsTask(cancellationToken);
var result = await inputStream.ReadAsync(buffer, count, options).AsTask(cancellationToken);

Position += result.Length;
return result;
});
}

/// <inheritdoc/>
public void Seek(ulong position) => Position = position;

/// <inheritdoc/>
public IRandomAccessStream CloneStream() => throw new NotSupportedException();

/// <inheritdoc/>
public IInputStream GetInputStreamAt(ulong position)
{
Position = position;
return this;
}

/// <inheritdoc/>
public IOutputStream GetOutputStreamAt(ulong position) => throw new NotSupportedException();

/// <inheritdoc/>
public IAsyncOperationWithProgress<uint, uint> WriteAsync(IBuffer buffer) => throw new NotSupportedException();

/// <inheritdoc/>
public IAsyncOperation<bool> FlushAsync() => throw new NotSupportedException();

/// <inheritdoc/>
public void Dispose()
{
// HttpClient is owned by the caller; do not dispose it here.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
using AndroidX.Media3.Common;
using AndroidX.Media3.Common.Text;
using AndroidX.Media3.Common.Util;
using AndroidX.Media3.DataSource;
using AndroidX.Media3.ExoPlayer;
using AndroidX.Media3.ExoPlayer.Source;
using AndroidX.Media3.Session;
using AndroidX.Media3.UI;
using CommunityToolkit.Maui.Media.Services;
Expand Down Expand Up @@ -378,7 +380,28 @@ protected virtual async partial ValueTask PlatformUpdateSource()

if (item?.MediaMetadata is not null)
{
Player.SetMediaItem(item);
var headers = (MediaElement.Source as UriMediaSource)?.HttpHeaders;
if (headers is { Count: > 0 })
{
Trace.WriteLine($"MediaElement [Android]: Applying {headers.Count} custom HTTP header(s) to media source.");
foreach (var header in headers)
{
Trace.WriteLine($"MediaElement [Android]: Header '{header.Key}' set.");
}

var httpDataSourceFactory = new DefaultHttpDataSource.Factory();
httpDataSourceFactory.SetDefaultRequestProperties(headers);

var mediaSourceFactory = new DefaultMediaSourceFactory(httpDataSourceFactory);
var mediaSource = mediaSourceFactory.CreateMediaSource(item);

Player.SetMediaSource(mediaSource);
}
else
{
Player.SetMediaItem(item);
}

Player.Prepare();
hasSetSource = true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AVFoundation;
using System.Diagnostics;
using AVFoundation;
using AVKit;
using CommunityToolkit.Maui.Views;
using CoreFoundation;
Expand Down Expand Up @@ -229,7 +230,27 @@ protected virtual async partial ValueTask PlatformUpdateSource()
var uri = uriMediaSource.Uri;
if (!string.IsNullOrWhiteSpace(uri?.AbsoluteUri))
{
asset = AVAsset.FromUrl(new NSUrl(uri.AbsoluteUri));
var nsUrl = new NSUrl(uri.AbsoluteUri);
var headers = uriMediaSource.HttpHeaders;
if (headers is { Count: > 0 })
{
Trace.WriteLine($"MediaElement [iOS/macCatalyst]: Applying {headers.Count} custom HTTP header(s) to AVUrlAsset.");
foreach (var header in headers)
{
Trace.WriteLine($"MediaElement [iOS/macCatalyst]: Header '{header.Key}' set.");
}

var pairs = headers.ToArray();
var nativeHeaders = NSDictionary.FromObjectsAndKeys(
pairs.Select(p => p.Value).ToArray(),
pairs.Select(p => p.Key).ToArray());
var options = new NSDictionary("AVURLAssetHTTPHeaderFieldsKey", nativeHeaders);
asset = new AVUrlAsset(nsUrl, new AVUrlAssetOptions(options));
}
else
{
asset = AVAsset.FromUrl(nsUrl);
}
}
}
else if (MediaElement.Source is FileMediaSource fileMediaSource)
Expand Down
Loading
Loading