Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
abdd329
feat: add HttpHeaders property to UriMediaSource and FromUri overload
Kaaybi Mar 20, 2026
667e6ab
feat(android): support HTTP headers via DefaultHttpDataSource.Factory
Kaaybi Mar 20, 2026
ff201df
feat(ios): support HTTP headers via AVUrlAsset options
Kaaybi Mar 20, 2026
4df6f10
feat(windows): support HTTP headers via AdaptiveMediaSource + HttpRan…
Kaaybi Mar 20, 2026
f438a19
test: add unit tests for UriMediaSource.HttpHeaders and MediaSource.F…
Kaaybi Mar 20, 2026
7c676e3
sample: add custom HTTP headers demo to MediaElement sample page
Kaaybi Mar 20, 2026
0b9f9ad
Merge branch 'main' into uri-media-source-http-headers
ne0rrmatrix Mar 20, 2026
ff62b10
fix(mediaelement): address PR review for HTTP headers support
Kaaybi Mar 21, 2026
c3e123b
chore: remove test files
Kaaybi Mar 21, 2026
d25acd0
fix(mediaelement): address copilot PR review for HTTP headers support
Kaaybi Mar 21, 2026
1b90aa2
Merge remote-tracking branch 'upstream/main' into uri-media-source-ht…
Kaaybi Mar 21, 2026
96cbd18
refactor(mediaelement): use ForceYielding
Kaaybi Mar 22, 2026
e4b7883
Merge branch 'main' into uri-media-source-http-headers
Kaaybi Mar 28, 2026
9d30831
Merge branch 'main' into uri-media-source-http-headers
TheCodeTraveler Apr 2, 2026
48515a8
Remove nullable MediaSource
TheCodeTraveler Apr 2, 2026
1fed6f2
Make `UriMediaSource.HttpHeaders` init-only
TheCodeTraveler Apr 2, 2026
de31ca0
Update StreamingVideoUrls
TheCodeTraveler Apr 2, 2026
c32118b
Update for ObservableDictionary
TheCodeTraveler Apr 2, 2026
d19ca70
Update formatting
TheCodeTraveler Apr 2, 2026
94e2142
Rename `StreamingVideoUrls` -> `StreamingUrls`
TheCodeTraveler Apr 2, 2026
c139f3a
Move `HttpRandomAccessStream` to `/Primatives/`
TheCodeTraveler Apr 2, 2026
cb81cbc
Use `Uri.UriSchemeFile`
TheCodeTraveler Apr 2, 2026
7c5952e
Remove `Trace.WriteLine`
TheCodeTraveler Apr 2, 2026
e10837a
Remove `Trace.WriteLine`
TheCodeTraveler Apr 2, 2026
21d093e
Update formatting
TheCodeTraveler Apr 2, 2026
c47d9d7
`dotnet format`
TheCodeTraveler Apr 2, 2026
6a89037
Add `UriMediaSource` check
TheCodeTraveler Apr 2, 2026
9f77085
Update src/CommunityToolkit.Maui.MediaElement/MediaSource/UriMediaSou…
TheCodeTraveler Apr 2, 2026
b7b0dcd
Update samples/CommunityToolkit.Maui.Sample/ViewModels/Views/MediaEle…
TheCodeTraveler Apr 2, 2026
2db4505
Update `Remove(KeyValuePair<K, V> item)`
TheCodeTraveler Apr 2, 2026
02a71ac
Update samples/CommunityToolkit.Maui.Sample/ViewModels/Views/MediaEle…
TheCodeTraveler Apr 2, 2026
ab3db0a
Update samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/…
TheCodeTraveler Apr 2, 2026
7cc7dcd
Update src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.maci…
TheCodeTraveler Apr 2, 2026
53dc656
Merge branch 'uri-media-source-http-headers' of https://github.com/Ka…
TheCodeTraveler Apr 2, 2026
44c76f4
Update UriMediaSourceTests.cs
TheCodeTraveler Apr 2, 2026
979f89c
Update src/CommunityToolkit.Maui.MediaElement/Primitives/HttpRandomAc…
TheCodeTraveler Apr 2, 2026
e7b039e
Update src/CommunityToolkit.Maui.MediaElement/MediaSource/UriMediaSou…
TheCodeTraveler Apr 2, 2026
c78d845
Merge branch 'uri-media-source-http-headers' of https://github.com/Ka…
TheCodeTraveler Apr 2, 2026
39fa22a
Update src/CommunityToolkit.Maui.MediaElement/MediaSource/UriMediaSou…
TheCodeTraveler Apr 2, 2026
9591757
Update Test Names
TheCodeTraveler Apr 2, 2026
c9792c9
Merge branch 'uri-media-source-http-headers' of https://github.com/Ka…
TheCodeTraveler Apr 2, 2026
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 };
}
Comment thread
TheCodeTraveler marked this conversation as resolved.

/// <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)
Comment thread
Kaaybi marked this conversation as resolved.
Outdated
{
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;
Comment thread
TheCodeTraveler marked this conversation as resolved.
Outdated
OnSourceChanged();
}
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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
{
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(false);
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, _) =>
{
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(false);
Comment thread
Kaaybi marked this conversation as resolved.
Outdated
response.EnsureSuccessStatusCode();

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

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;
Comment thread
TheCodeTraveler marked this conversation as resolved.
Outdated
using AVKit;
using CommunityToolkit.Maui.Views;
using CoreFoundation;
Expand Down Expand Up @@ -229,7 +230,26 @@ 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 nativeHeaders = NSDictionary.FromObjectsAndKeys(
headers.Values.ToArray(),
headers.Keys.ToArray());
var options = new NSDictionary("AVURLAssetHTTPHeaderFieldsKey", nativeHeaders);
Comment thread
TheCodeTraveler marked this conversation as resolved.
asset = new AVUrlAsset(nsUrl, new AVUrlAssetOptions(options));
}
else
{
asset = AVAsset.FromUrl(nsUrl);
}
}
}
else if (MediaElement.Source is FileMediaSource fileMediaSource)
Expand Down
Loading
Loading