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

@FirentisTFW

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.

Metadata

Metadata

Assignees

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 team

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions