MediaPlayer skips forward about 6 seconds on rotation
Asked Answered
A

3

7

I have a MediaPlayer in a Fragment which retains its instance on configuration changes. The player is playing a video loaded from my assets directory. I have the scenario set up with the goal of reproducing the YouTube app playback where the audio keeps playing during the configuration changes and the display is detached and reattached to the media player.

When I start the playback and rotate the device, the position jumps forward about 6 seconds and (necessarily) the audio cuts out when this happens. Afterwards, the playback continues normally. I have no idea what could be causing this to happen.

As requested, here is the code:

public class MainFragment extends Fragment implements SurfaceHolder.Callback, MediaController.MediaPlayerControl {

    private static final String TAG = MainFragment.class.getSimpleName();

    AssetFileDescriptor mVideoFd;

    SurfaceView mSurfaceView;
    MediaPlayer mMediaPlayer;
    MediaController mMediaController;
    boolean mPrepared;
    boolean mShouldResumePlayback;
    int mBufferingPercent;
    SurfaceHolder mSurfaceHolder;

    @Override
    public void onInflate(Activity activity, AttributeSet attrs, Bundle savedInstanceState) {
        super.onInflate(activity, attrs, savedInstanceState);
        final String assetFileName = "test-video.mp4";
        try {
            mVideoFd = activity.getAssets().openFd(assetFileName);
        } catch (IOException ioe) {
            Log.e(TAG, "Can't open file " + assetFileName + "!");
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setRetainInstance(true);

        // initialize the media player
        mMediaPlayer = new MediaPlayer();
        try {
            mMediaPlayer.setDataSource(mVideoFd.getFileDescriptor(), mVideoFd.getStartOffset(), mVideoFd.getLength());
        } catch (IOException ioe) {
            Log.e(TAG, "Unable to read video file when setting data source.");
            throw new RuntimeException("Can't read assets file!");
        }

        mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
            @Override
            public void onPrepared(MediaPlayer mp) {
                mPrepared = true;
            }
        });

        mMediaPlayer.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener() {
            @Override
            public void onBufferingUpdate(MediaPlayer mp, int percent) {
                mBufferingPercent = percent;
            }
        });

        mMediaPlayer.prepareAsync();
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        super.onCreateView(inflater, container, savedInstanceState);
        View view = inflater.inflate(R.layout.fragment_main, container, false);
        mSurfaceView = (SurfaceView) view.findViewById(R.id.surface);
        mSurfaceView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mMediaController.show();
            }
        });

        mSurfaceHolder = mSurfaceView.getHolder();
        if (mSurfaceHolder == null) {
            throw new RuntimeException("SufraceView's holder is null");
        }
        mSurfaceHolder.addCallback(this);
        return view;
    }

    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        mMediaController = new MediaController(getActivity());
        mMediaController.setEnabled(false);
        mMediaController.setMediaPlayer(this);
        mMediaController.setAnchorView(view);
    }

    @Override
    public void onResume() {
        super.onResume();
        if (mShouldResumePlayback) {
            start();
        } else {
            mSurfaceView.post(new Runnable() {
                @Override
                public void run() {
                    mMediaController.show();
                }
            });
        }
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        mMediaPlayer.setDisplay(mSurfaceHolder);
        mMediaController.setEnabled(true);
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        // nothing
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        mMediaPlayer.setDisplay(null);
    }

    @Override
    public void onPause() {
        if (mMediaPlayer.isPlaying() && !getActivity().isChangingConfigurations()) {
            pause();
            mShouldResumePlayback = true;
        }
        super.onPause();
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);

    }

    @Override
    public void onDestroyView() {
        mMediaController.setAnchorView(null);
        mMediaController = null;
        mMediaPlayer.setDisplay(null);
        mSurfaceHolder.removeCallback(this);
        mSurfaceHolder = null;
        mSurfaceView = null;
        super.onDestroyView();
    }

    @Override
    public void onDestroy() {
        mMediaPlayer.release();
        mMediaPlayer = null;
        try {
            mVideoFd.close();
        } catch (IOException ioe) {
            Log.e(TAG, "Can't close asset file..", ioe);
        }
        mVideoFd = null;
        super.onDestroy();
    }

    // MediaControler methods:
    @Override
    public void start() {
        mMediaPlayer.start();
    }

    @Override
    public void pause() {
        mMediaPlayer.pause();
    }

    @Override
    public int getDuration() {
        return mMediaPlayer.getDuration();
    }

    @Override
    public int getCurrentPosition() {
        return mMediaPlayer.getCurrentPosition();
    }

    @Override
    public void seekTo(int pos) {
        mMediaPlayer.seekTo(pos);
    }

    @Override
    public boolean isPlaying() {
        return mMediaPlayer.isPlaying();
    }

    @Override
    public int getBufferPercentage() {
        return mBufferingPercent;
    }

    @Override
    public boolean canPause() {
        return true;
    }

    @Override
    public boolean canSeekBackward() {
        return true;
    }

    @Override
    public boolean canSeekForward() {
        return true;
    }

    @Override
    public int getAudioSessionId() {
        return mMediaPlayer.getAudioSessionId();
    }
}

The if block in the onPause method is not being hit.

Update:

After doing a bit more debugging, removing the interaction with the SurfaceHolder causes the problem to go away. In other words, if I don't setDisplay on the MediaPlayer the audio will work fine during the configuration change: no pause, no skip. It would seem there is some timing issue with setting the display on the MediaPlayer that is confusing the player.

Additionally, I have found that you must hide() the MediaController before you remove it during the configuration change. This improves stability but does not fix the skipping issue.

Another update:

If you care, the Android media stack looks like this:

MediaPlayer.java 
 -> android_media_MediaPlayer.cpp 
 -> MediaPlayer.cpp
 -> IMediaPlayer.cpp 
 -> MediaPlayerService.cpp 
 -> BnMediaPlayerService.cpp 
 -> IMediaPlayerService.cpp 
 -> *ConcreteMediaPlayer*
 -> *BaseMediaPlayer* (Stagefright, NuPlayerDriver, Midi, etc) 
 -> *real MediaPlayerProxy* (AwesomePlayer, NuPlayer, etc) 
 -> *RealMediaPlayer* (AwesomePlayerSource, NuPlayerDecoder, etc) 
  -> Codec
  -> HW/SW decoder

Upon examining AwesomePlayer, it appears this awesome player takes the liberty of pausing itself for you when you setSurface():

status_t AwesomePlayer::setNativeWindow_l(const sp<ANativeWindow> &native) {
    mNativeWindow = native;

    if (mVideoSource == NULL) {
        return OK;
    }

    ALOGV("attempting to reconfigure to use new surface");

    bool wasPlaying = (mFlags & PLAYING) != 0;

    pause_l();
    mVideoRenderer.clear();

    shutdownVideoDecoder_l();

    status_t err = initVideoDecoder();

    if (err != OK) {
        ALOGE("failed to reinstantiate video decoder after surface change.");
        return err;
    }

    if (mLastVideoTimeUs >= 0) {
        mSeeking = SEEK;
        mSeekTimeUs = mLastVideoTimeUs;
        modifyFlags((AT_EOS | AUDIO_AT_EOS | VIDEO_AT_EOS), CLEAR);
    }

    if (wasPlaying) {
        play_l();
    }

    return OK;
}

This reveals that setting the surface will cause the player to destroy whatever surface was previously being used as well as the video decoder along with it. While setting a surface to null should not cause the audio to stop, setting it to a new surface requires the video decoder to be reinitialized and the player to seek to the current location in the video. By convention, seeking will never take you further than you request, that is, if you overshoot a keyframe when seeking, you should land on the frame you overshot (as opposed to the next one).

My hypothesis, then, is that the Android MediaPlayer does not honor this convention and jumps forward to the next keyframe when seeking. This, coupled with a video source that has sparse keyframes, could explain the jumping I am experiencing. I have not looked at AwesomePlayer's implementation of seek, though. It was mentioned to me that jumping to the next keyframe is something that needs to happen if your MediaPlayer is developed with streaming in mind since the stream can be discarded as soon as it has been consumed. Point being, it might not be that far fetch to think the MediaPlayer would choose to jump forward as opposed to backwards.

Final Update:

While I still don't know why the playback skips when attaching a new Surface as the display for a MediaPlayer, thanks to the accepted answer, I have gotten the playback to be seamless during rotation.

Aldin answered 1/10, 2013 at 0:46 Comment(9)
can you post your code?Tintometer
@RSenApps added. Hope it helps but I think what I'm doing is pretty generic..Aldin
@PulkitSethi that's simply incorrect: pastebin.com/4Gt6iM4nAldin
you just mentioned the onPause is not getting hit. Is it not getting hit or the condition is falsePriestcraft
6 seconds would be very sparse keyframes in my experience. You could verify your hypothesis by examining the particular media more closely. My inclination is that there's more to it, but kudos for looking as deeply as you have.Mislead
@Mislead the thing is this video is optimized for streaming. I have experienced videos before (longer streamed videos) where the seek resolution is only every 30s-60s. It's less common, yes, but still possible I think. However, I'd love to know if there's more going on. I certainly don't consider my hypothesis to be the conclusion, more digging definitely needs to be done.Aldin
@Mislead after looking more into it, it seems an i-frame every 6 sec (regardless of framerate) would not be very plausible. As in I think you're right that they generally need to come more frequently.Aldin
Did you ever figure out what was going on? I am hitting a similar issue to yours (not exactly 6 seconds) and I thought it could be keyframes as well. However, that doesn't seem to be true.Prose
@Prose I've still been looking into ways to do this properly. My current working solution is to retain the activity onconfigurationchanges but as you understand that is suboptimal. Your solution is what I'm trying to get to work too. I just have a few unanswered questions that should determine whether this method leaks memory and, if so, how to avoid it.Aldin
A
7

Thanks to natez0r's answer, I have managed to get the setup described working. However, I use a slightly different method. I'll detail it here for reference.

I have one Fragment which I flag to be retained on configuration changes. This fragment handles both the media playback (MediaPlayer), and the standard TextureView (which provides the SurfaceTexture where the video buffer gets dumped). I initialize the media playback only once my Activity has finished onResume() and once the SurfaceTexture is available. Instead of subclassing TextureView, I simply call setSurfaceTexture (since it's public) in my fragment once I receive a reference to the SurfaceTexture. The only two things retained when a configuration change happens are the MediaPlayer reference, and the SurfaceTexture reference.

I've uploaded the source of my sample project to Github. Feel free to take a look!

Aldin answered 9/12, 2013 at 20:32 Comment(4)
I tried something similar in Grafika (github.com/google/grafika, "Double Decode") a few days ago, but it didn't work because TextureView#setSurfaceTexture() wasn't updating the SurfaceTexture listener, so TextureView wouldn't see any updates. The key difference between your code and mine is the place where setSurfaceTexture() is called -- I was waiting until TV called back to say it was ready. If I do it from onCreate(), it pre-empts the creation of the additional SurfaceTexture, and TV happily puts a listener on the ST provided. So my thanks to you and natez0r.Savagism
Do you think the mDisplay.setSurfaceTextureListener(mTextureListener);statement could be moved to onResume avoiding complex state mangement, or is there a good reason for this placement. I've tried successfully moving it in onResume, but maybe I'm lucky on my test device...Mush
@Mush my impression was that the texture would be created when the view was created by the layout inflater or otherwise had its state restored, which happens before onResume. But thinking about it, you probably don't want to retain a bunch of gl_contexts if you're not running (resume/pause lifecycle) so having the texture creation happen during or post onResume is may be how it's actually implemented (meaning we can preempt it even in onResume). Do you know when the surfaceTextureListener textureCreated callback is fired in the Activity lifecycle? (Did you print the ordering or anything?)Aldin
@dcow, the callbacks (either onSurfaceTextureAvailableand onSurfaceTextureSizeChanged) are called after my onResume()fragment method.Mush
P
5

I know this question is a tad old now, but I was able to get this working in my app without the skipping. The issue is the surface getting destroyed (killing whatever buffer it had in it). This may not solve all your issues because it targets API 16, but you can manage your own SurfaceTexture inside your custom TextureView where the video is drawn:

private SurfaceTexture mTexture;

    private TextureView.SurfaceTextureListener mSHCallback =
            new TextureView.SurfaceTextureListener() {
                @Override
                public void onSurfaceTextureAvailable(SurfaceTexture surface, int width,
                        int height) {
                    mTexture = surface;
                    mPlayer.setSurface(new Surface(mTexture));
                }

                @Override
                public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width,
                        int height) {
                    mTexture = surface;
                }

                @Override
                public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
                    mTexture = surface;
                    return false;
                }

                @Override
                public void onSurfaceTextureUpdated(SurfaceTexture surface) {
                    mTexture = surface;
                }
            };

the key is returning false in onSurfaceTextureDestroyed and holding onto mTexture. When the view gets re-attached to the window you can set the surfaceTexture:

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (mTexture != null) {
            setSurfaceTexture(mTexture);
        }
    }

This allows my view to continue playing video from EXACTLY where it left off.

Prose answered 27/11, 2013 at 22:2 Comment(9)
This is the type of solution I was envisioning but I am unsure whether a surface texture retains a reference to the context which created it or not. If it does, then we need to modify this so the texture is created with the application context. If not, then am I to assume that a TextureView reuses the same SurfaceTexture if you return false from onSurfaceTextureDestroyed?Aldin
You actually have to setSurfaceTexture() on the textureview when it gets re-attached to the window, I don't think it will re-use it automatically. As for leaking contexts, I am pretty sure you should be OK there. The view gets attached and detached from the window during normal orientation changes and you don't leak contexts, this should be no different. Let me know if you determine otherwise. Lastly, you should release the surface when it's no longer needed (developer.android.com/reference/android/graphics/…), though.Prose
setSurfaceTexture() will do the trick. My intuition would be that it's okay to hold onto a texture reference (the actual gl texture object is just referenced by an int). I'll double check everything and try an implementation using this method. Thanks for the input. I'll reply here with the results but it may not be for a bit.Aldin
Can you post a little more detail about how exactly you are going about this? Are you holding onto the entire TextureView when you rotate? If that's true you are, in fact, leaking the original context your TextureView was created with. I don't see how else you would be doing it if you're holding onto the SurfaceTexture inside your custom TextureView.Aldin
My custom textureview is being re-attached to the window on rotation, I am not actually doing anything special to retain the TextureView (I am letting android handle that). So, once the textureview gets a call to onAttachedToWindow() again after rotation, it calls setSurfaceTexture() with the saved texture (if it's not null). I also have a lifecycle attached to the view so I know when the surfacetexture must be released (when the activity is destroyed for good).Prose
Would you mind posting a pastbin of your Activity code? Regardless, thanks to your answer, I have managed to get this working with a standard TextureView. The TextureView is getting destroyed and recreated via onDestroyView() and onCreateView() in my retained media-playing Fragment. I retain only the SurfaceTexture reference and call setSurfaceTexture() on the new TextureView in onCreateView() if there is one retained.Aldin
Unfortunately, I don't think I can paste our code, but glad that you were able to get it working!Prose
That's fine, I just wanted to make sure you aren't leaking anything (=Aldin
Thanks for this amazing tip. Guess I never would have found this out by my own.Pettifer
R
0

I solved it without preventing surface from getting destroyed.

      override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
        surfaceDestroyed = true
        currentPosition = mediaPlayer?.currentPosition
        return true
      }

      override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
        if (surfaceDestroyed) {
          surfaceDestroyed = false
          mediaPlayer?.setSurface(Surface(surface))
          currentPosition?.let { mediaPlayer?.seekTo(it, MediaPlayer.SEEK_CLOSEST)
        } else {
          // do normal init
      }
Representative answered 14/4, 2023 at 20:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.