CommunityToolkit / CommunityToolkit/Maui
[BUG] iOS/macOS MediaElement.MediaOpened fires before AVPlayerItem is actually ReadyToPlay
- Dominant language
- C#
- Stars
- 2.7k
- Forks
- 500
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 7
Description
### Is there an existing issue for this?
- [x] I have searched the existing issues
### Did you read the "Reporting a bug" section on Contributing file?
- [x] I have read the "Reporting a bug" section on Contributing file: https://github.com/CommunityToolkit/Maui/blob/main/CONTRIBUTING.md#reporting-a-bug
### Current Behavior
## Summary
On iOS/macOS, `CommunityToolkit.Maui.MediaElement` appears to raise `MediaOpened` too early.
The current iOS/macOS `MediaManager.PlatformUpdateSource()` raises `MediaElement.MediaOpened()` after creating an `AVPlayerItem` and calling `Player.ReplaceCurrentItemWithPlayerItem(PlayerItem)`, as long as `PlayerItem is not null && PlayerItem.Error is null`.
That is not equivalent to the native media item being ready.
For AVFoundation, the correct readiness signal is:
```csharp
AVPlayerItem.Status == AVPlayerItemStatus.ReadyToPlay
```
`new AVPlayerItem(asset)` and `PlayerItem.Error is null` only mean that an item object exists and has not failed yet. They do not mean the media has loaded, has a valid duration, or is ready to play.
## Observed behavior
In a .NET MAUI app using multiple `MediaElement` instances with local embedded resource videos, where I was debugging the AVPlayer state via Reflection:
```csharp
ShouldAutoPlay = false;
ShouldShowPlaybackControls = false;
Source = MediaSource.FromResource(...);
```
all six `MediaOpened` events fired, causing app-level readiness to become true:
```text
MEDIA OPENED PT1 0
MEDIA OPENED PT2 0
MEDIA OPENED PT2 2
MEDIA OPENED PT1 2
MEDIA OPENED PT2 1
MEDIA OPENED PT1 1
READY ready=True opened=111/111
```
But at the moment `READY` was reached, every `MediaElement` was still reported as `Opening` with zero duration:
```text
pt1=0:Opening@0/0,1:Opening@0/0,2:Opening@0/0
pt2=0:Opening@0/0,1:Opening@0/0,2:Opening@0/0
```
Calling `Play()` or `Stop()` after this false readiness caused the players to fail:
```text
pt1=0:Failed@0/0,1:Failed@0/0,2:Failed@0/0
pt2=0:Failed@0/0,1:Failed@0/0,2:Failed@0/0
```
So `MediaOpened` was raised while the media was not actually opened/ready.
## Expected behavior
`MediaOpened` should fire only when the native `AVPlayerItem` reaches:
```csharp
AVPlayerItemStatus.ReadyToPlay
```
At that point, application code should be able to safely treat the media as opened and ready for playback operations such as:
```csharp
Play();
Pause();
SeekTo(...);
```
It should not fire just because an `AVPlayerItem` object was created.
## Affected file / class
File:
```text
src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.ios.cs
```
Class:
```csharp
CommunityToolkit.Maui.Core.Views.MediaManager
```
Relevant current logic:
```csharp
PlayerItem = asset is not null
? new AVPlayerItem(asset)
: null;
metaData.SetMetadata(PlayerItem, MediaElement);
CurrentItemErrorObserver?.Dispose();
Player.ReplaceCurrentItemWithPlayerItem(PlayerItem);
...
if (PlayerItem is not null && PlayerItem.Error is null)
{
MediaElement.MediaOpened();
(MediaElement.MediaWidth, MediaElement.MediaHeight) = await GetVideoDimensions(PlayerItem);
if (MediaElement.ShouldAutoPlay)
{
Player.Play();
}
await SetPoster();
}
```
The problem is this line:
```csharp
MediaElement.MediaOpened();
```
It is called before observing `PlayerItem.Status == AVPlayerItemStatus.ReadyToPlay`.
## Root cause
`PlayerItem.Error is null` is not a readiness check.
Immediately after creating an `AVPlayerItem`, the item can still be in an unknown/opening state. AVFoundation exposes readiness through `AVPlayerItem.Status`, and the item should be observed until it reaches `ReadyToPlay` or `Failed`.
The Toolkit currently has player-level status observers:
```csharp
StatusObserver = Player.AddObserver("status", ...);
TimeControlStatusObserver = Player.AddObserver("timeControlStatus", ...);
```
but `MediaOpened` should be driven by the current item’s status, not by item creation.
## Proposed fix
Move `MediaElement.MediaOpened()` out of `PlatformUpdateSource()` and into an observer for the current `AVPlayerItem.Status`.
Add fields:
```csharp
bool hasMediaOpened;
IDisposable? CurrentItemStatusObserver { get; set; }
```
Reset them when a new source is assigned:
```csharp
hasMediaOpened = false;
CurrentItemStatusObserver?.Dispose();
CurrentItemStatusObserver = null;
```
After creating and replacing the current item, observe the item status:
```csharp
PlayerItem = asset is not null
? new AVPlayerItem(asset)
: null;
metaData.SetMetadata(PlayerItem, MediaElement);
CurrentItemErrorObserver?.Dispose();
Player.ReplaceCurrentItemWithPlayerItem(PlayerItem);
CurrentItemStatusObserver = PlayerItem?.AddObserver(
"status",
ValueObserverOptions,
PlayerItemStatusChanged
);
```
Do not call `MediaOpened()` immediately after `ReplaceCurrentItemWithPlayerItem`.
Instead:
```csharp
async void PlayerItemStatusChanged(NSObservedChange obj)
{
if (PlayerItem is null)
{
return;
}
switch (PlayerItem.Status)
{
case AVPlayerItemStatus.ReadyToPlay:
if (hasMediaOpened)
{
return;
}
hasMediaOpened = true;
MediaElement.Duration = ConvertTime(PlayerItem.Duration);
MediaElement.Position = ConvertTime(PlayerItem.CurrentTime);
MediaElement.CurrentStateChanged(
Player?.Rate > 0
? MediaElementState.Playing
: MediaElementState.Paused
);
(MediaElement.MediaWidth, MediaElement.MediaHeight) = await GetVideoDimensions(PlayerItem);
MediaElement.MediaOpened();
if (MediaElement.ShouldAutoPlay)
{
Player?.Play();
}
await SetPoster();
break;
case AVPlayerItemStatus.Failed:
var message = PlayerItem.Error is not null
? $"{PlayerItem.Error.LocalizedDescription} - {PlayerItem.Error.LocalizedFailureReason}"
: "AVPlayerItem failed.";
MediaElement.CurrentStateChanged(MediaElementState.Failed);
MediaElement.MediaFailed(new MediaFailedEventArgs(message));
Logger.LogError("{LogMessage}", message);
break;
}
}
```
Also dispose the observer:
```csharp
CurrentItemStatusObserver?.Dispose();
CurrentItemStatusObserver = null;
```
inside `Dispose(bool disposing)`.
## Why this fixes the issue
This makes `MediaOpened` match its documented meaning: the media is loaded and ready to play.
It also aligns the Toolkit with AVFoundation’s native readiness model:
```csharp
AVPlayerItemStatus.ReadyToPlay
```
After applying an app-side reflection workaround that ignored Toolkit `MediaOpened` on iOS and instead watched the underlying `AVPlayer.CurrentItem.Status`, the same media elements loaded correctly and playback worked. All behavior became predictable and normal while watching and reacting to the true AVPlayer events.
The workaround treated only this native state as opened:
```csharp
player.CurrentItem.Status == AVPlayerItemStatus.ReadyToPlay
```
and used `AVPlayerItem.Notifications.ObserveDidPlayToEndTime` for ended playback. That resolved the false-ready / `Opening@0/0` / `Failed@0/0` behavior.
## Minimal reproduction outline
1. Create a .NET MAUI app using `CommunityToolkit.Maui.MediaElement`.
2. Add one or more `MediaElement` instances on iOS.
3. Configure:
```csharp
ShouldAutoPlay = false;
ShouldShowPlaybackControls = false;
Aspect = Aspect.Fill;
```
4. Attach handlers before setting `Source`:
```csharp
mediaElement.MediaOpened += ...;
mediaElement.MediaFailed += ...;
```
5. Set a local embedded video resource:
```csharp
mediaElement.Source = MediaSource.FromResource("video.mp4");
```
6. In the `MediaOpened` handler, log by reflection the AVPlayer state vs the MediaElement state. You can access the AVPlayer by running on `mediaElement.HandlerChanged`:
```csharp
static AVPlayer? getAVPlayer(MediaElement mediaElement) {
var handler = mediaElement.Handler;
if (handler is null) {
return null;
}
object? mediaManager = getPropertyOrFieldFromTypeTree(
handler,
"MediaManager"
);
if (mediaManager is null) {
mediaManager = getPropertyOrFieldFromTypeTree(
handler,
"mediaManager"
);
}
if (mediaManager is null) {
return null;
}
object? player = getPropertyOrFieldFromTypeTree(
mediaManager,
"Player"
);
return player as AVPlayer;
}
static object? getPropertyOrFieldFromTypeTree(object target, string name) {
Type? type = target.GetType();
while (type != null) {
PropertyInfo? property = type.GetProperty(
name,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
);
if (property != null) {
return property.GetValue(target);
}
FieldInfo? field = type.GetField(
name,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
);
if (field != null) {
return field.GetValue(target);
}
type = type.BaseType;
}
return null;
}
```
7. Observe that `MediaOpened` can fire while the element is still:
```text
Opening@0/0
```
and subsequent playback operations can fail.
## Requested change
Please update iOS/macOS `MediaManager.ios.cs` so that:
1. `MediaOpened()` is not raised immediately after `AVPlayerItem` creation.
2. `MediaOpened()` is raised only once the current item reaches `AVPlayerItemStatus.ReadyToPlay`.
3. `Duration`, `Position`, and media dimensions are updated around the actual ready transition.
4. `AVPlayerItemStatus.Failed` triggers `MediaFailed`.
5. Observers are reset/disposed correctly when changing source or disposing the player.
This would make iOS/macOS `MediaOpened` correctly represent native media readiness and match the documented `MediaOpened` contract.
### Environment
```markdown
- .NET MAUI CommunityToolkit:
- OS:
- .NET MAUI:
```
Contributor guide
Assessment
This issue has not been assessed yet.