CommunityToolkit / CommunityToolkit/Maui
[BUG] Android MediaElement.MediaOpened is raised from Player.Prepare() instead of ExoPlayer STATE_READY, leading to unpredictable state transitions
- 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
## Description
On Android, `MediaElement.MediaOpened` is not tied to ExoPlayer’s actual prepared/ready state.
In `CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs`:
https://raw.githubusercontent.com/CommunityToolkit/Maui/refs/heads/main/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs
We see `MediaElement.MediaOpened()` is currently invoked in `PlatformUpdateSource()` immediately after calling `Player.Prepare()` and setting `hasSetSource = true`.
```csharp
Player.Prepare();
hasSetSource = true;
}
if (hasSetSource)
{
if (Player.PlayerError is null)
{
MediaElement.MediaOpened();
}
```
That means `MediaOpened` represents “a source was assigned and `Prepare()` was called,” not “the native player has reached a ready/opened state.”
For Android ExoPlayer/Media3, the correct native readiness point is `Player.STATE_READY`, delivered through `IPlayerListener.OnPlaybackStateChanged(int playbackState)` or `OnPlayerStateChanged(bool playWhenReady, int playbackState)`.
Those two handlers exist in the code above but are not invoking the `MediaOpened` as would be then expected.
`Player.Prepare()` starts asynchronous preparation; it does not mean the media is opened/ready yet. Thus the current location of `MediaOpened` causes app code that must wait for `MediaOpened` before calling `Play()`, `SeekTo()`, or marking a video as ready to behave unreliably on Android.
Requests can be dropped or abnormally processed, and behavior is unpredictable.
## Current behavior
This caused major problems for me as I was attempting to gate UI readiness and then playback commands to `MediaOpened`. This was unreliable and would only randomly succeed or fail.
Attaching a native ExoPlayer `IPlayerListener` directly using reflection and treating `STATE_READY` as opened solved the load/readiness issue.
## Expected behavior
On Android, `MediaOpened` should be raised when ExoPlayer reaches `Player.STATE_READY` for the current media item.
`MediaOpened` should mean that the media is actually prepared/opened and ready for operations such as:
```csharp
Play();
Pause();
SeekTo(...);
```
It should not be raised merely because `Player.Prepare()` was called.
## Why this matters
`MediaOpened` is the natural event for application code to wait on before treating media as ready.
If `MediaOpened` is raised before ExoPlayer is actually ready, or is not synchronized with the actual native ready state, app code has to resort to platform-specific reflection or polling to determine whether Android media has actually opened.
This also makes behavior inconsistent across platforms because consumers expect `MediaOpened` to mean the media is actually opened, not that native async preparation has merely started.
## Affected file / class
File:
```text
src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs
```
Class:
```csharp
CommunityToolkit.Maui.Core.Views.MediaManager
```
Relevant current logic:
```csharp
Player.Prepare();
hasSetSource = true;
if (hasSetSource)
{
if (Player.PlayerError is null)
{
MediaElement.MediaOpened();
}
}
```
This is in `PlatformUpdateSource()`.
The Android listener already receives actual native readiness/loaded state changes are here:
```csharp
public void OnPlaybackStateChanged(int playbackState)
{
...
case readyState:
seekToTaskCompletionSource?.TrySetResult();
break;
}
```
But `MediaOpened()` is not raised from `readyState`.
## Proposed fix
Move Android `MediaOpened()` dispatch out of `PlatformUpdateSource()` and into the ExoPlayer `STATE_READY` transition.
Add a private field to ensure the event is raised once per source:
```csharp
bool hasMediaOpened;
```
Reset it when a new source is assigned:
```csharp
MediaElement.CurrentStateChanged(MediaElementState.Opening);
hasMediaOpened = false;
Player.PlayWhenReady = MediaElement.ShouldAutoPlay;
```
Do not call `MediaElement.MediaOpened()` immediately after `Player.Prepare()`.
Instead, raise it when ExoPlayer reaches `STATE_READY`:
```csharp
public void OnPlaybackStateChanged(int playbackState)
{
if (MediaElement.Source is null || Player is null)
{
return;
}
MediaElementState newState = MediaElement.CurrentState;
switch (playbackState)
{
case bufferState:
newState = MediaElementState.Buffering;
break;
case readyState:
MediaElement.Duration = TimeSpan.FromMilliseconds(Player.Duration < 0 ? 0 : Player.Duration);
MediaElement.Position = TimeSpan.FromMilliseconds(Player.CurrentPosition < 0 ? 0 : Player.CurrentPosition);
newState = Player.PlayWhenReady
? MediaElementState.Playing
: MediaElementState.Paused;
if (!hasMediaOpened && Player.PlayerError is null)
{
hasMediaOpened = true;
MediaElement.MediaOpened();
}
seekToTaskCompletionSource?.TrySetResult();
break;
case endedState:
newState = MediaElementState.Stopped;
MediaElement.MediaEnded();
break;
}
MediaElement.CurrentStateChanged(newState);
}
```
`PlatformUpdateSource()` should still call `Player.Prepare()`, but should not raise `MediaOpened()` there:
```csharp
Player.Prepare();
hasSetSource = true;
```
Then only update notifications if needed.
## Additional related issue: OnPlayerStateChanged appears to use mismatched state constants
There is a second Android-specific issue in the same class.
`OnPlayerStateChanged(bool playWhenReady, int playbackState)` receives ExoPlayer playback-state integer values, but the code switches over a custom `PlaybackState` class whose values appear to correspond to Android media-session playback-state constants, not ExoPlayer constants.
Current local constants:
```csharp
const int bufferState = 2;
const int readyState = 3;
const int endedState = 4;
```
These correspond to ExoPlayer states:
```text
STATE_BUFFERING = 2
STATE_READY = 3
STATE_ENDED = 4
```
But `OnPlayerStateChanged()` switches over:
```csharp
PlaybackState.StatePaused = 2
PlaybackState.StatePlaying = 3
PlaybackState.StateFastForwarding = 4
```
This means ExoPlayer `STATE_BUFFERING` can be interpreted as paused, ExoPlayer `STATE_READY` can be interpreted as playing/paused depending on `playWhenReady`, and ExoPlayer `STATE_ENDED` can be interpreted as fast-forwarding.
That should be changed to use the same ExoPlayer state constants used by `OnPlaybackStateChanged()`.
Suggested simplified logic:
```csharp
public void OnPlayerStateChanged(bool playWhenReady, int playbackState)
{
if (Player is null || MediaElement.Source is null)
{
return;
}
MediaElementState newState = playbackState switch
{
bufferState => MediaElementState.Buffering,
readyState => playWhenReady ? MediaElementState.Playing : MediaElementState.Paused,
endedState => MediaElementState.Stopped,
_ => MediaElement.CurrentState
};
if (playbackState == readyState)
{
MediaElement.Duration = TimeSpan.FromMilliseconds(Player.Duration < 0 ? 0 : Player.Duration);
MediaElement.Position = TimeSpan.FromMilliseconds(Player.CurrentPosition < 0 ? 0 : Player.CurrentPosition);
if (!hasMediaOpened && Player.PlayerError is null)
{
hasMediaOpened = true;
MediaElement.MediaOpened();
}
}
MediaElement.CurrentStateChanged(newState);
}
```
Alternatively, remove duplicated state handling from `OnPlayerStateChanged()` and centralize state changes in `OnPlaybackStateChanged()`.
## Minimal reproduction outline
1. Create a .NET MAUI app using `CommunityToolkit.Maui.MediaElement`.
2. Add one or more Android `MediaElement`s.
3. Set for example as:
```csharp
AndroidViewType = AndroidViewType.TextureView;
ShouldAutoPlay = false;
ShouldShowPlaybackControls = false;
```
4. Attach `MediaOpened`, `MediaFailed`, and `CurrentStateChanged` events before assigning `Source`.
5. Assign a local resource:
```csharp
mediaElement.Source = MediaSource.FromResource("video.mp4");
```
6. Log by reflection and by the Maui event handler the exact state of the Exoplayer at the time of various events. ExoPlayer can be fetched by reflection on `mediaElement.HandlerChanged` with:
```csharp
static IExoPlayer? getExoPlayer(MediaElement mediaElement) {
var handler = mediaElement.Handler;
if (handler is null) {
return null;
}
// Current Toolkit path:
// MediaElement.Handler.PlatformView is MauiMediaElement.
// MauiMediaElement has private field: playerView.
var platformView = handler.PlatformView;
var playerView = platformView?.GetType()
.GetField("playerView", BindingFlags.Instance | BindingFlags.NonPublic)?
.GetValue(platformView) as PlayerView;
if (playerView?.Player is IExoPlayer exoPlayerFromPlayerView) {
return exoPlayerFromPlayerView;
}
// Older / alternate path:
// Handler has protected MediaManager, MediaManager has protected Player.
var mediaManager = handler.GetType()
.GetProperty("MediaManager", BindingFlags.Instance | BindingFlags.NonPublic)?
.GetValue(handler);
var player = mediaManager?.GetType()
.GetProperty("Player", BindingFlags.Instance | BindingFlags.NonPublic)?
.GetValue(mediaManager);
return player as IExoPlayer;
}
```
7. Observe that Android readiness is not reliably represented by `MediaOpened`. In the tested app, direct native ExoPlayer `IPlayerListener` callbacks showed `STATE_READY`, and the media had a valid duration, while app-level `MediaOpened` readiness did not correctly advance.
## Verified workaround
A temporary app-side workaround was to reflect the underlying Android `IExoPlayer` from the `MediaElement` handler and attach a native `IPlayerListener`.
The system then treated `Player.STATE_READY` as the opened signal, and `Player.STATE_ENDED` as the ended signal.
This fixed the Android readiness problem. All behavior became consistent and reliable using the custom listener.
That workaround confirms that the native player reaches the correct state and that the problem is in the Toolkit Android wrapper’s event mapping/timing.
## Requested change
Please update Android `MediaManager.android.cs` so that:
1. `MediaOpened()` is raised from ExoPlayer `STATE_READY`, not immediately after `Player.Prepare()`.
2. `MediaOpened()` is raised once per source item.
3. `Duration` and `Position` are updated before raising `MediaOpened()`.
4. `OnPlayerStateChanged()` uses ExoPlayer state constants, not Android media-session playback-state constants, or delegates to the same state handling path as `OnPlaybackStateChanged()`.
5. Existing `MediaEnded()` and `MediaFailed()` behavior remains unchanged except where needed for consistency.
This would make Android `MediaOpened` semantically match “the media is actually opened and ready,” which is what application code needs from this event.
### Environment
```markdown
- .NET MAUI CommunityToolkit:
- OS:
- .NET MAUI:
```
Contributor guide
Research direction
Start in src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs, tracing PlatformUpdateSource(), OnPlaybackStateChanged(), and OnPlayerStateChanged(). Compare the existing ExoPlayer state constants and event dispatch paths, using the issue’s minimal reproduction to observe readiness. Done means MediaOpened is raised once per source at STATE_READY after Duration and Position are updated, with existing ended and failed behavior preserved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, csharp
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100