Skip to content

[BUG] Android MediaElement.MediaOpened is raised from Player.Prepare() instead of ExoPlayer STATE_READY, leading to unpredictable state transitions #3248

Description

@jonmdev

Is there an existing issue for this?

  • I have searched the existing issues

Did you read the "Reporting a bug" section on Contributing file?

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.

			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:

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:

src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs

Class:

CommunityToolkit.Maui.Core.Views.MediaManager

Relevant current logic:

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:

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:

bool hasMediaOpened;

Reset it when a new source is assigned:

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:

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:

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:

const int bufferState = 2;
const int readyState = 3;
const int endedState = 4;

These correspond to ExoPlayer states:

STATE_BUFFERING = 2
STATE_READY = 3
STATE_ENDED = 4

But OnPlayerStateChanged() switches over:

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:

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 MediaElements.
  3. Set for example as:
AndroidViewType = AndroidViewType.TextureView;
ShouldAutoPlay = false;
ShouldShowPlaybackControls = false;
  1. Attach MediaOpened, MediaFailed, and CurrentStateChanged events before assigning Source.
  2. Assign a local resource:
mediaElement.Source = MediaSource.FromResource("video.mp4");
  1. 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:
    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;
    }
  1. 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

- .NET MAUI CommunityToolkit:
- OS:
- .NET MAUI:

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions