From c18374103e7ad3e9f6bf578789967b032190e91f Mon Sep 17 00:00:00 2001 From: RWRZ Date: Wed, 15 Apr 2026 17:50:41 -0300 Subject: [PATCH] fix(video_player_android): avoid NPE in PlatformView path for audio-only sources PlatformViewExoPlayerEventListener.sendInitialized unconditionally dereferenced exoPlayer.getVideoFormat(), which returns null for audio-only media (e.g. .m4a podcasts played through VideoPlayerController). When VideoViewType.platformView was selected, the first STATE_READY after prepare would crash with NullPointerException at PlatformViewExoPlayerEventListener.java:31. Handle the null videoFormat case by reporting zero dimensions and no rotation, matching what the texture path already tolerates. Also guard the rotation fromDegrees() call against unexpected values, consistent with TextureExoPlayerEventListener. Added an "Audio" tab to the example app that plays a remote .m4a via the view-type switcher, so the crash can be reproduced (and the fix verified) by toggling "Platform view". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../video_player/example/lib/main.dart | 123 +++++++++++++++++- .../PlatformViewExoPlayerEventListener.java | 17 ++- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart index 88de46cb5c69..3585ceffd5fc 100644 --- a/packages/video_player/video_player/example/lib/main.dart +++ b/packages/video_player/video_player/example/lib/main.dart @@ -22,7 +22,7 @@ class _App extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 5, + length: 6, child: Scaffold( key: const ValueKey('home_page'), appBar: AppBar( @@ -76,6 +76,7 @@ class _App extends StatelessWidget { Tab(icon: Icon(Icons.list), text: 'List example'), Tab(icon: Icon(Icons.hd), text: 'HLS / ABR'), Tab(icon: Icon(Icons.memory), text: 'Decoders'), + Tab(icon: Icon(Icons.headphones), text: 'Audio'), ], ), ), @@ -101,6 +102,10 @@ class _App extends StatelessWidget { builder: (VideoViewType viewType) => DecoderDemo(viewType), ), + _ViewTypeTabBar( + builder: (VideoViewType viewType) => + _AudioOnlyRemote(viewType), + ), ], ), ), @@ -1157,6 +1162,122 @@ class _QualityButton extends StatelessWidget { } } +/// Plays an audio-only remote .m4a through the video_player controller. +/// +/// This exists specifically to reproduce a crash in the platform-view path: +/// on Android, `PlatformViewExoPlayerEventListener.sendInitialized` used to +/// NPE because `exoPlayer.getVideoFormat()` returns null for audio-only +/// sources. Switch to the "Platform view" sub-tab to verify the fix. +class _AudioOnlyRemote extends StatefulWidget { + const _AudioOnlyRemote(this.viewType); + + final VideoViewType viewType; + + @override + State<_AudioOnlyRemote> createState() => _AudioOnlyRemoteState(); +} + +class _AudioOnlyRemoteState extends State<_AudioOnlyRemote> { + late VideoPlayerController _controller; + Object? _initError; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.networkUrl( + Uri.parse( + 'https://storage.reallifeglobal.com/podcasts/17084864-4093-41f0-b354-8620d841cb7e/LEwTV_-_Rihanna_APP.m4a', + ), + viewType: widget.viewType, + ); + _controller.addListener(() { + if (mounted) { + setState(() {}); + } + }); + _controller.initialize().then((_) { + if (mounted) { + setState(() {}); + } + }).catchError((Object err) { + if (mounted) { + setState(() { + _initError = err; + }); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _fmt(Duration d) { + final String mm = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final String ss = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$mm:$ss'; + } + + @override + Widget build(BuildContext context) { + final VideoPlayerValue value = _controller.value; + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Audio-only remote .m4a (podcast)\n' + 'Use this tab with "Platform view" to repro the ' + 'PlatformViewExoPlayerEventListener NPE on audio-only sources.', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 16), + if (_initError != null) + Text( + 'Init error: $_initError', + style: const TextStyle(color: Colors.red), + ) + else if (!value.isInitialized) + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Initializing…'), + ], + ) + else ...[ + Text('Duration: ${_fmt(value.duration)}'), + Text('Position: ${_fmt(value.position)}'), + Text('Size: ${value.size.width.toInt()}x${value.size.height.toInt()}' + ' (audio-only = 0x0)'), + const SizedBox(height: 12), + VideoProgressIndicator(_controller, allowScrubbing: true), + const SizedBox(height: 12), + Row( + children: [ + IconButton( + icon: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: () { + value.isPlaying ? _controller.pause() : _controller.play(); + }, + ), + Text(value.isPlaying ? 'Playing' : 'Paused'), + ], + ), + ], + ], + ), + ); + } +} + class _PlayerVideoAndPopPage extends StatefulWidget { @override _PlayerVideoAndPopPageState createState() => _PlayerVideoAndPopPageState(); diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java index d73eeeb3558a..780eeb02a2c5 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java @@ -11,7 +11,6 @@ import androidx.media3.exoplayer.ExoPlayer; import io.flutter.plugins.videoplayer.ExoPlayerEventListener; import io.flutter.plugins.videoplayer.VideoPlayerCallbacks; -import java.util.Objects; public final class PlatformViewExoPlayerEventListener extends ExoPlayerEventListener { public PlatformViewExoPlayerEventListener( @@ -25,10 +24,20 @@ public PlatformViewExoPlayerEventListener( @Override protected void sendInitialized() { // We can't rely on VideoSize here, because at this point it is not available - the platform - // view was not created yet. We use the video format instead. + // view was not created yet. We use the video format instead. Audio-only sources have no + // video format, so we report zero dimensions and no rotation in that case. Format videoFormat = exoPlayer.getVideoFormat(); - RotationDegrees rotationCorrection = - RotationDegrees.fromDegrees(Objects.requireNonNull(videoFormat).rotationDegrees); + if (videoFormat == null) { + events.onInitialized(0, 0, exoPlayer.getDuration(), 0); + return; + } + + RotationDegrees rotationCorrection; + try { + rotationCorrection = RotationDegrees.fromDegrees(videoFormat.rotationDegrees); + } catch (IllegalArgumentException e) { + rotationCorrection = RotationDegrees.ROTATE_0; + } int width = videoFormat.width; int height = videoFormat.height;