Skip to content

[video_player_android] Video gets on top of the UI when using platform view and the player is scrolled out of view #164899

Open

Description

@FirentisTFW

What package does this bug report belong to?

video_player_android (2.8.1) - it can't be reproduced in the main package (video_player) yet, since the platform view feature is not yet exposed by it.

What target platforms are you seeing this bug on?

Android (seems to happen below API 34, but I'm not entirely sure about this).

Steps to reproduce

  1. Replace the example app's code with the one attached below (it adds a tab that shows a video in a list).
  2. Run the example app.
  3. Tap on "List example" tab, then on "Platform view" tab below.
  4. Scroll until you can see the video.
  5. Scroll down, so that the video hides under TabBar widgets.

Expected results

Player is not visible anymore since it was scroll out of the visible viewport.

Actual results

Player shows on top of Flutter's UI. Just the player itself, without any transformations made to it, and without any widgets placed on top of it in Flutter (play/pause button, progress bar).

Screenshots or Videos

Video demonstration
Screen.Recording.2025-03-10.at.10.47.37.mov

Additional information

I also did some other tests with the platform view and looks like the issue happens when the video player gets out of visible view, but the dispose method was not yet called on the widget embedding the player. When dispose gets called, the floating player disappears.

The issue does not seem to happen on all devices. I observed it on most of the emulators and devices below API 34, but could not see it on emulators with API 34 and API 35 (although I'm not sure if this is a reliable information).

The problematic code seems to be the SurfaceView that is used by video_player_android to render a platform view. Seems like, as long as it's visible, the Flutter can properly handle it, but as soon as it disappears from the visible viewport, it punches a hole in Flutter's UI until it gets disposed (though again, it doesn't happen for all devices).

Code sample

Example app code with a list
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// ignore_for_file: public_member_api_docs

import 'package:flutter/material.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';

import 'mini_controller.dart';

void main() {
  runApp(
    MaterialApp(
      home: _App(),
    ),
  );
}

class _App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 4,
      child: Scaffold(
        key: const ValueKey<String>('home_page'),
        appBar: AppBar(
          title: const Text('Video player example'),
          bottom: const TabBar(
            isScrollable: true,
            tabs: <Widget>[
              Tab(icon: Icon(Icons.cloud), text: 'Remote'),
              Tab(icon: Icon(Icons.videocam), text: 'RTSP'),
              Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset'),
              Tab(icon: Icon(Icons.list), text: 'List example'),
            ],
          ),
        ),
        body: TabBarView(
          children: <Widget>[
            _ViewTypeTabBar(
              builder: (VideoViewType viewType) =>
                  _BumbleBeeRemoteVideo(viewType),
            ),
            _ViewTypeTabBar(
              builder: (VideoViewType viewType) => _RtspRemoteVideo(viewType),
            ),
            _ViewTypeTabBar(
              builder: (VideoViewType viewType) =>
                  _ButterFlyAssetVideo(viewType),
            ),
            _ViewTypeTabBar(
              builder: (VideoViewType viewType) =>
                  _ButterFlyAssetVideoInList(viewType),
            ),
          ],
        ),
      ),
    );
  }
}

class _ViewTypeTabBar extends StatefulWidget {
  const _ViewTypeTabBar({
    required this.builder,
  });

  final Widget Function(VideoViewType) builder;

  @override
  State<_ViewTypeTabBar> createState() => _ViewTypeTabBarState();
}

class _ViewTypeTabBarState extends State<_ViewTypeTabBar>
    with SingleTickerProviderStateMixin {
  late final TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TabBar(
          controller: _tabController,
          isScrollable: true,
          tabs: const <Widget>[
            Tab(
              icon: Icon(Icons.texture),
              text: 'Texture view',
            ),
            Tab(
              icon: Icon(Icons.construction),
              text: 'Platform view',
            ),
          ],
        ),
        Expanded(
          child: TabBarView(
            controller: _tabController,
            children: <Widget>[
              widget.builder(VideoViewType.textureView),
              widget.builder(VideoViewType.platformView),
            ],
          ),
        ),
      ],
    );
  }
}

class _ButterFlyAssetVideo extends StatefulWidget {
  const _ButterFlyAssetVideo(this.viewType);

  final VideoViewType viewType;

  @override
  _ButterFlyAssetVideoState createState() => _ButterFlyAssetVideoState();
}

class _ButterFlyAssetVideoState extends State<_ButterFlyAssetVideo> {
  late MiniController _controller;

  @override
  void initState() {
    super.initState();
    _controller = MiniController.asset(
      'assets/Butterfly-209.mp4',
      viewType: widget.viewType,
    );

    _controller.addListener(() {
      setState(() {});
    });
    _controller.initialize().then((_) => _controller.play());
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: <Widget>[
          Container(
            padding: const EdgeInsets.only(top: 20.0),
          ),
          const Text('With assets mp4'),
          Container(
            padding: const EdgeInsets.all(20),
            child: AspectRatio(
              aspectRatio: _controller.value.aspectRatio,
              child: Stack(
                alignment: Alignment.bottomCenter,
                children: <Widget>[
                  VideoPlayer(_controller),
                  _ControlsOverlay(controller: _controller),
                  VideoProgressIndicator(_controller),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _BumbleBeeRemoteVideo extends StatefulWidget {
  const _BumbleBeeRemoteVideo(this.viewType);

  final VideoViewType viewType;

  @override
  _BumbleBeeRemoteVideoState createState() => _BumbleBeeRemoteVideoState();
}

class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> {
  late MiniController _controller;

  @override
  void initState() {
    super.initState();
    _controller = MiniController.network(
      'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
      viewType: widget.viewType,
    );

    _controller.addListener(() {
      setState(() {});
    });
    _controller.initialize();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: <Widget>[
          Container(padding: const EdgeInsets.only(top: 20.0)),
          const Text('With remote mp4'),
          Container(
            padding: const EdgeInsets.all(20),
            child: AspectRatio(
              aspectRatio: _controller.value.aspectRatio,
              child: Stack(
                alignment: Alignment.bottomCenter,
                children: <Widget>[
                  VideoPlayer(_controller),
                  _ControlsOverlay(controller: _controller),
                  VideoProgressIndicator(_controller),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _RtspRemoteVideo extends StatefulWidget {
  const _RtspRemoteVideo(this.viewType);

  final VideoViewType viewType;

  @override
  _RtspRemoteVideoState createState() => _RtspRemoteVideoState();
}

class _RtspRemoteVideoState extends State<_RtspRemoteVideo> {
  MiniController? _controller;

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  Future<void> changeUrl(String url) async {
    if (_controller != null) {
      await _controller!.dispose();
    }

    setState(() {
      _controller = MiniController.network(
        url,
        viewType: widget.viewType,
      );
    });

    _controller!.addListener(() {
      setState(() {});
    });

    return _controller!.initialize();
  }

  String? _validateRtspUrl(String? value) {
    if (value == null || !value.startsWith('rtsp://')) {
      return 'Enter a valid RTSP URL';
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: <Widget>[
          Container(padding: const EdgeInsets.only(top: 20.0)),
          const Text('With RTSP streaming'),
          Padding(
            padding: const EdgeInsets.all(20.0),
            child: TextFormField(
              autovalidateMode: AutovalidateMode.onUserInteraction,
              decoration: const InputDecoration(label: Text('RTSP URL')),
              validator: _validateRtspUrl,
              textInputAction: TextInputAction.done,
              onFieldSubmitted: (String value) {
                if (_validateRtspUrl(value) == null) {
                  changeUrl(value);
                } else {
                  setState(() {
                    _controller?.dispose();
                    _controller = null;
                  });
                }
              },
            ),
          ),
          if (_controller != null)
            Container(
              padding: const EdgeInsets.all(20),
              child: AspectRatio(
                aspectRatio: _controller!.value.aspectRatio,
                child: Stack(
                  alignment: Alignment.bottomCenter,
                  children: <Widget>[
                    VideoPlayer(_controller!),
                    _ControlsOverlay(controller: _controller!),
                    VideoProgressIndicator(_controller!),
                  ],
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _ButterFlyAssetVideoInList extends StatelessWidget {
  const _ButterFlyAssetVideoInList(this.viewType);

  final VideoViewType viewType;

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: <Widget>[
        const _ExampleCard(title: 'Item a'),
        const _ExampleCard(title: 'Item b'),
        const _ExampleCard(title: 'Item c'),
        const _ExampleCard(title: 'Item d'),
        const _ExampleCard(title: 'Item e'),
        const _ExampleCard(title: 'Item f'),
        const _ExampleCard(title: 'Item g'),
        Card(
          child: Column(
            children: <Widget>[
              const ListTile(
                leading: Icon(Icons.cake),
                title: Text('Video video'),
              ),
              _ButterFlyAssetVideo(viewType),
            ],
          ),
        ),
        const _ExampleCard(title: 'Item h'),
        const _ExampleCard(title: 'Item i'),
        const _ExampleCard(title: 'Item j'),
        const _ExampleCard(title: 'Item k'),
        const _ExampleCard(title: 'Item l'),
        const _ExampleCard(title: 'Item m'),
        const _ExampleCard(title: 'Item n'),
        const _ExampleCard(title: 'Item o'),
      ],
    );
  }
}

class ViewType {}

class _ControlsOverlay extends StatelessWidget {
  const _ControlsOverlay({required this.controller});

  static const List<double> _examplePlaybackRates = <double>[
    0.25,
    0.5,
    1.0,
    1.5,
    2.0,
    3.0,
    5.0,
    10.0,
  ];

  final MiniController controller;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        AnimatedSwitcher(
          duration: const Duration(milliseconds: 50),
          reverseDuration: const Duration(milliseconds: 200),
          child: controller.value.isPlaying
              ? const SizedBox.shrink()
              : const ColoredBox(
                  color: Colors.black26,
                  child: Center(
                    child: Icon(
                      key: ValueKey<String>('Play'),
                      Icons.play_arrow,
                      color: Colors.white,
                      size: 100.0,
                      semanticLabel: 'Play',
                    ),
                  ),
                ),
        ),
        GestureDetector(
          onTap: () {
            controller.value.isPlaying ? controller.pause() : controller.play();
          },
        ),
        Align(
          alignment: Alignment.topRight,
          child: PopupMenuButton<double>(
            initialValue: controller.value.playbackSpeed,
            tooltip: 'Playback speed',
            onSelected: (double speed) {
              controller.setPlaybackSpeed(speed);
            },
            itemBuilder: (BuildContext context) {
              return <PopupMenuItem<double>>[
                for (final double speed in _examplePlaybackRates)
                  PopupMenuItem<double>(
                    value: speed,
                    child: Text('${speed}x'),
                  )
              ];
            },
            child: Padding(
              padding: const EdgeInsets.symmetric(
                // Using less vertical padding as the text is also longer
                // horizontally, so it feels like it would need more spacing
                // horizontally (matching the aspect ratio of the video).
                vertical: 12,
                horizontal: 16,
              ),
              child: Text('${controller.value.playbackSpeed}x'),
            ),
          ),
        ),
      ],
    );
  }
}

/// A filler card to show the video in a list of scrolling contents.
class _ExampleCard extends StatelessWidget {
  const _ExampleCard({required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          ListTile(
            leading: const Icon(Icons.airline_seat_flat_angled),
            title: Text(title),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: OverflowBar(
              alignment: MainAxisAlignment.end,
              spacing: 8.0,
              children: <Widget>[
                TextButton(
                  child: const Text('BUY TICKETS'),
                  onPressed: () {
                    /* ... */
                  },
                ),
                TextButton(
                  child: const Text('SELL TICKETS'),
                  onPressed: () {
                    /* ... */
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.27.3, on macOS 14.4.1 23E224 darwin-arm64, locale en-PL)
    • Flutter version 3.27.3 on channel stable at /Users/paweljakubowski/Development/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision c519ee916e (7 weeks ago), 2025-01-21 10:32:23 -0800
    • Engine revision e672b006cb
    • Dart version 3.6.1
    • DevTools version 2.40.2

[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
    • Android SDK at /Users/paweljakubowski/Library/Android/sdk
    • Platform android-35, build-tools 35.0.0
    • ANDROID_HOME = /Users/paweljakubowski/Library/Android/sdk
    • Java binary at: /opt/homebrew/opt/openjdk@17/bin/java
    • Java version OpenJDK Runtime Environment Homebrew (build 17.0.13+0)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 15.4)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 15F31d
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2024.2)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1)

[✓] VS Code (version 1.98.0)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.106.0

[✓] Connected device (4 available)
    • Android SDK built for arm64 (mobile) • emulator-5556         • android-arm64  • Android 8.1.0 (API 27) (emulator)
    • macOS (desktop)                      • macos                 • darwin-arm64   • macOS 14.4.1 23E224 darwin-arm64
    • Mac Designed for iPad (desktop)      • mac-designed-for-ipad • darwin         • macOS 14.4.1 23E224 darwin-arm64
    • Chrome (web)                         • chrome                • web-javascript • Google Chrome 133.0.6943.142

[✓] Network resources
    • All expected network resources are available.

Activity

  1. self-assigned this
    on Mar 10, 2025
  2. added
    platform-androidAndroid applications specifically
    packageflutter/packages repository. See also p: labels.
    on Mar 10, 2025
  3. stuartmorgan-g commented on Mar 10, 2025

    @stuartmorgan-g
    Contributor

    Is the SurfaceView present in the view hierarchy when the platform view is first created? From skimming the code it looks like it probably isn't, and there's a known issue with TLHC where the logic that's supposed to bypass TLHC for plugins with SurfaceViews (since they don't work in TLHC) won't trigger in that case.

    If you add a dummy SurfaceView to the view returned by PlatformVideoViewFactory, does it fix the issue?

  4. FirentisTFW commented on Mar 10, 2025

    @FirentisTFW
    Author

    Thanks for the tip. I think the SurfaceView should be there when creating a platform view, it is returned by PlatformVideoView.

    Yes, it works with a dummy SurfaceView. It looks like attaching the SurfaceView to the ExoPlayer is the problematic part, even if we try to de-attach it later, one frame seems to stick there and cause the issue. I think I might find a workaround with observing the player and hiding/showing it on the native side, based on whether it is visible or not, but I'm not sure whether it'd be nice (and if this will work in the first place). I probably need to somehow get information about the UI that is rendered by Flutter and could obscure part of the view.

    Or do you have a different idea on how this issue could be solved? Or why does it happen in the first place when using ExoPlayer and whether this is something that Flutter's rendering should handle?

  5. stuartmorgan-g commented on Mar 10, 2025

    @stuartmorgan-g
    Contributor

    I think the SurfaceView should be there when creating a platform view, it is returned by PlatformVideoView.

    It looks like I misread the code this morning, sorry for the bad pointer there. It's interesting though; having the SurfaceView showing in above all content at a fixed location is the same behavior we would see with failure to fall back from TLHC, so maybe there's still something related happening?

    I think this is going to require someone to dig into some platform view-side debugging in the engine to see if anything jumps out there, and also to see if HC++ resolves this. Adding team-android for direction to the right person.

  6. removed their assignment
    on Mar 17, 2025
  7. added
    P2Important issues not at the top of the work list
    on Mar 18, 2025
  8. 1 remaining item

  9. ihijazi commented on Jul 20, 2025

    @ihijazi

    Also can confirm this is happening on:
    Pixel 9, Android v15
    Pixel 9 Pro XL, Android v15
    Pixel 9, Android v16

    Doesn't happen on Pixel 6 Pro, Android v13

    Hopefully this can get better priority. In a list with different videos, this is basically not usable.

  10. added
    ⏳Bot is counting down the days until it unassigns the issue
    on Jul 22, 2025
  11. flutter-triage-bot commented on Jul 22, 2025

    @flutter-triage-bot

    This issue is assigned to @gmackall but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks!

  12. gmackall commented on Aug 28, 2025

    @gmackall
    Member

    I was unable to repro this on a pixel 9 fold and a pixel 9 pro. Will need a bit to get my hands on a regular pixel 9 or a pixel 9 pro xl

  13. removed
    ⏳Bot is counting down the days until it unassigns the issue
    on Aug 28, 2025
  14. gmackall commented on Sep 16, 2025

    @gmackall
    Member

    I'm not able to repro this on a pixel 9 w/ android 16 either, using the sample code on flutter 3.32.5:

    no_repro_video_on_top.mp4

    I've tried debug and release builds and am not able to encounter the problem

  15. flutter-triage-bot commented on Jan 21, 2026

    @flutter-triage-bot

    This issue is assigned to @gmackall but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks!

  16. added
    ⏳Bot is counting down the days until it unassigns the issue
    on Jan 21, 2026
  17. flutter-triage-bot commented on Apr 8, 2026

    @flutter-triage-bot

    This issue was assigned to @gmackall but has had no status updates in a long time. To remove any ambiguity about whether the issue is being worked on, the assignee was removed.

  18. removed
    ⏳Bot is counting down the days until it unassigns the issue
    on Apr 8, 2026
  19. gmackall commented on Apr 8, 2026

    @gmackall
    Member

    I believe this likely has the same root cause as #175546 did, which is that clipping of surface views doesn't work with the apis we were using.

    I was not able to reproduce this issue, but I would be curious if anyone hitting this finds it fixed when testing on a device on android api 34+, and passing the --enable-hcpp flag to flutter run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: platform-viewsEmbedding Android/iOS views in Flutter appsp: video_playerThe Video Player pluginpackageflutter/packages repository. See also p: labels.platform-androidAndroid applications specificallyteam-androidOwned by Android platform teamtriaged-androidTriaged by Android platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions