-
Notifications
You must be signed in to change notification settings - Fork 28.5k
[video_player_android] Expose correct AspectRatio with rotationCorrection into consideration #166097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Are you sure its related? Because the other issue is alot older but in 2.9.3 i was not facing any stretch issue, only scale issue |
@Andi1986 I experienced a similar issue and would like to check if my findings fit here. It would be awesome if you could share some code and the exact |
@Andi1986 Thanks for the clarification, Please share a minimal and complete sample code, And is this issue on a particular device or all devices? |
@maheshj01 i tried to figure out what it could be, maybe the video player is not even totally responsible for this issue, it could be something with the camera plugin instead or the way i am implementing it. i tried to build an example which uses both the camera plugin and the video player plugin in combination. code sampleimport 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:video_player/video_player.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const CameraScreen(),
);
}
}
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
static const routeName = 'camera';
@override
State<CameraScreen> createState() => _CameraAppState();
}
class _CameraAppState extends State<CameraScreen> with WidgetsBindingObserver {
late CameraController _controller;
late List<CameraDescription> cameras;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
Future<CameraController> _requestAndInitCamera() async {
cameras = await availableCameras();
var firstCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back);
_controller = CameraController(
firstCamera,
ResolutionPreset.veryHigh,
);
await _controller.initialize();
return _controller;
}
@override
void dispose() {
_controller.dispose();
// KeepScreenOn.turnOff();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) => FutureBuilder(
future: _requestAndInitCamera(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
var size = MediaQuery.of(context).size;
double xScale;
double yScale;
if (MediaQuery.of(context).orientation == Orientation.portrait) {
xScale = size.height / _controller.value.aspectRatio / size.width;
yScale = 1.0;
} else {
xScale = 1.0;
yScale = size.width / size.height / _controller.value.aspectRatio;
}
return Scaffold(
body: AspectRatio(
aspectRatio: size.width / size.height,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.diagonal3Values(xScale, yScale, 1),
child: CameraPreview(_controller),
),
),
floatingActionButton: CameraButtons(
controller: _controller,
cameras: cameras),
);
});
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
if (state == AppLifecycleState.resumed) {
await _controller.initialize();
}
}
}
class CameraButtons extends StatefulWidget {
const CameraButtons(
{super.key,
required this.controller,
required this.cameras});
final CameraController controller;
final List<CameraDescription> cameras;
@override
State<CameraButtons> createState() => _CameraButtonsState();
}
class _CameraButtonsState extends State<CameraButtons> {
bool recording = false;
CameraLensDirection _cameraLensDirection = CameraLensDirection.back;
@override
Widget build(BuildContext context) =>
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
FloatingActionButton(
tooltip: 'Kamera wechseln',
child: Icon(_cameraLensDirection == CameraLensDirection.back
? Icons.switch_camera
: Icons.switch_camera_outlined),
onPressed: () {
setState(() => _cameraLensDirection =
_cameraLensDirection == CameraLensDirection.back
? CameraLensDirection.front
: CameraLensDirection.back);
var camera = widget.cameras.firstWhere(
(camera) => camera.lensDirection == _cameraLensDirection);
widget.controller.setDescription(camera);
},
),
if (!recording)
FloatingActionButton(
heroTag: 'heroVideoStart',
onPressed: startRecording,
tooltip: 'Video aufnehmen',
child: const Icon(Icons.fiber_manual_record),
),
if (recording)
FloatingActionButton(
heroTag: 'heroVideoStop',
onPressed: stopRecording,
tooltip: 'Video stoppen',
child: const Icon(Icons.stop_circle),
),
]);
Future<void> startRecording() async {
await widget.controller.startVideoRecording();
setState(() {
recording = true;
});
}
Future<void> stopRecording() async {
var video = await widget.controller.stopVideoRecording();
if (!mounted) {
return;
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Video(
videoPath: video.path,
),
),
);
setState(() {
recording = false;
});
}
}
class Video extends StatefulWidget {
const Video({super.key, required this.videoPath});
final String videoPath;
@override
State<Video> createState() => _VideoState();
}
class _VideoState extends State<Video> {
late VideoPlayerController _controller;
@override
void initState() {
_controller = VideoPlayerController.file(
File(widget.videoPath),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
_controller
..addListener(() {
setState(() {});
})
..setLooping(true)
..initialize();
super.initState();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Vorschau')),
body: Center(
child:
_controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
VideoPlayer(_controller),
_ControlsOverlay(controller: _controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
],
),
)
: Container(),
),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
class _ControlsOverlay extends StatelessWidget {
const _ControlsOverlay({required this.controller});
static const List<Duration> _exampleCaptionOffsets = <Duration>[
Duration(seconds: -10),
Duration(seconds: -3),
Duration(seconds: -1, milliseconds: -500),
Duration(milliseconds: -250),
Duration.zero,
Duration(milliseconds: 250),
Duration(seconds: 1, milliseconds: 500),
Duration(seconds: 3),
Duration(seconds: 10),
];
static const List<double> _examplePlaybackRates = <double>[
0.25,
0.5,
1,
1.5,
2,
3,
5,
10,
];
final VideoPlayerController controller;
@override
Widget build(BuildContext context) => 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(
Icons.play_arrow,
color: Colors.white,
size: 100,
semanticLabel: 'Play',
),
),
),
),
GestureDetector(
onTap: () {
controller.value.isPlaying ? controller.pause() : controller.play();
},
),
Align(
alignment: Alignment.topLeft,
child: PopupMenuButton<Duration>(
initialValue: controller.value.captionOffset,
tooltip: 'Caption Offset',
onSelected: controller.setCaptionOffset,
itemBuilder:
(context) => <PopupMenuItem<Duration>>[
for (final Duration offsetDuration in _exampleCaptionOffsets)
PopupMenuItem<Duration>(
value: offsetDuration,
child: Text('${offsetDuration.inMilliseconds}ms'),
),
],
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.captionOffset.inMilliseconds}ms'),
),
),
),
Align(
alignment: Alignment.topRight,
child: PopupMenuButton<double>(
initialValue: controller.value.playbackSpeed,
tooltip: 'Playback speed',
onSelected: controller.setPlaybackSpeed,
itemBuilder:
(context) => <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'),
),
),
),
],
);
} |
@Andi1986 You're not taking the AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
VideoPlayer(_controller),
_ControlsOverlay(controller: _controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
],
),
) The I think that's the expected behavior but isn't documented well enough. |
thanks alot @marvin-kolja, this is working now, tho it still feels hacky to me and makes me think it should somehow be native by the packages themselves.
|
It does feel hacky, yes. Just as a reference to when there were major changes to the rotation correction of the In commit flutter/packages@5e03bb1 ( Since the main In my mind, the following things would make sense:
Maybe @camsim99 can give some insights? |
Given that the aspect ratio can be be correctly applied using code sampleimport 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const CameraScreen(),
);
}
}
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
static const routeName = 'camera';
@override
State<CameraScreen> createState() => _CameraAppState();
}
class _CameraAppState extends State<CameraScreen> with WidgetsBindingObserver {
late CameraController _controller;
late List<CameraDescription> cameras;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
Future<CameraController> _requestAndInitCamera() async {
cameras = await availableCameras();
var firstCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
);
_controller = CameraController(firstCamera, ResolutionPreset.veryHigh);
await _controller.initialize();
return _controller;
}
@override
void dispose() {
_controller.dispose();
// KeepScreenOn.turnOff();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) => FutureBuilder(
future: _requestAndInitCamera(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
var size = MediaQuery.of(context).size;
double xScale;
double yScale;
if (MediaQuery.of(context).orientation == Orientation.portrait) {
xScale = size.height / _controller.value.aspectRatio / size.width;
yScale = 1.0;
} else {
xScale = 1.0;
yScale = size.width / size.height / _controller.value.aspectRatio;
}
return Scaffold(
body: AspectRatio(
aspectRatio: size.width / size.height,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.diagonal3Values(xScale, yScale, 1),
child: CameraPreview(_controller),
),
),
floatingActionButton: CameraButtons(
controller: _controller,
cameras: cameras,
),
);
},
);
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
if (state == AppLifecycleState.resumed) {
await _controller.initialize();
}
}
}
class CameraButtons extends StatefulWidget {
const CameraButtons({
super.key,
required this.controller,
required this.cameras,
});
final CameraController controller;
final List<CameraDescription> cameras;
@override
State<CameraButtons> createState() => _CameraButtonsState();
}
class _CameraButtonsState extends State<CameraButtons> {
bool recording = false;
CameraLensDirection _cameraLensDirection = CameraLensDirection.back;
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
tooltip: 'Kamera wechseln',
child: Icon(
_cameraLensDirection == CameraLensDirection.back
? Icons.switch_camera
: Icons.switch_camera_outlined,
),
onPressed: () {
setState(
() =>
_cameraLensDirection =
_cameraLensDirection == CameraLensDirection.back
? CameraLensDirection.front
: CameraLensDirection.back,
);
var camera = widget.cameras.firstWhere(
(camera) => camera.lensDirection == _cameraLensDirection,
);
widget.controller.setDescription(camera);
},
),
if (!recording)
FloatingActionButton(
heroTag: 'heroVideoStart',
onPressed: startRecording,
tooltip: 'Video aufnehmen',
child: const Icon(Icons.fiber_manual_record),
),
if (recording)
FloatingActionButton(
heroTag: 'heroVideoStop',
onPressed: stopRecording,
tooltip: 'Video stoppen',
child: const Icon(Icons.stop_circle),
),
],
);
Future<void> startRecording() async {
await widget.controller.startVideoRecording();
setState(() {
recording = true;
});
}
Future<void> stopRecording() async {
var video = await widget.controller.stopVideoRecording();
if (!mounted) {
return;
}
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => Video(videoPath: video.path)),
);
setState(() {
recording = false;
});
}
}
class Video extends StatefulWidget {
const Video({super.key, required this.videoPath});
final String videoPath;
@override
State<Video> createState() => _VideoState();
}
class _VideoState extends State<Video> {
late VideoPlayerController _controller;
@override
void initState() {
_controller = VideoPlayerController.file(
File(widget.videoPath),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
_controller
..addListener(() {
setState(() {});
})
..setLooping(true)
..initialize();
super.initState();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Vorschau')),
body: Center(
child:
_controller.value.isInitialized
? AspectRatio(
aspectRatio:
_controller.value.rotationCorrection == 90 ||
_controller.value.rotationCorrection == 270
? 1 / _controller.value.aspectRatio
: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
VideoPlayer(_controller),
_ControlsOverlay(controller: _controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
],
),
)
: Container(),
),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
class _ControlsOverlay extends StatelessWidget {
const _ControlsOverlay({required this.controller});
static const List<Duration> _exampleCaptionOffsets = <Duration>[
Duration(seconds: -10),
Duration(seconds: -3),
Duration(seconds: -1, milliseconds: -500),
Duration(milliseconds: -250),
Duration.zero,
Duration(milliseconds: 250),
Duration(seconds: 1, milliseconds: 500),
Duration(seconds: 3),
Duration(seconds: 10),
];
static const List<double> _examplePlaybackRates = <double>[
0.25,
0.5,
1,
1.5,
2,
3,
5,
10,
];
final VideoPlayerController controller;
@override
Widget build(BuildContext context) => 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(
Icons.play_arrow,
color: Colors.white,
size: 100,
semanticLabel: 'Play',
),
),
),
),
GestureDetector(
onTap: () {
controller.value.isPlaying ? controller.pause() : controller.play();
},
),
Align(
alignment: Alignment.topLeft,
child: PopupMenuButton<Duration>(
initialValue: controller.value.captionOffset,
tooltip: 'Caption Offset',
onSelected: controller.setCaptionOffset,
itemBuilder:
(context) => <PopupMenuItem<Duration>>[
for (final Duration offsetDuration in _exampleCaptionOffsets)
PopupMenuItem<Duration>(
value: offsetDuration,
child: Text('${offsetDuration.inMilliseconds}ms'),
),
],
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.captionOffset.inMilliseconds}ms'),
),
),
),
Align(
alignment: Alignment.topRight,
child: PopupMenuButton<double>(
initialValue: controller.value.playbackSpeed,
tooltip: 'Playback speed',
onSelected: controller.setPlaybackSpeed,
itemBuilder:
(context) => <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'),
),
),
),
],
);
}
|
@marvin-kolja Thank you for the clear explanation of potential fixes and the context! Looks like I understand both of the options you presented, though I think having to expose corrected/incorrect values for developers to use to manually correct the aspect ratio is sorta messy. Ideally, we use it to correct the aspect ratio, perhaps at the Dart |
@camsim99 Thank you so much for the insights! Having another look at the code, I think I found the issue causing this. Currently, the width and height are switched when the rotation correction is Testing on an emulator running Andriod 15.0 (V) - API 35, using a video with 1920x1080 dimensions and a 90-degree rotation, I logged the following:
It seems that the If the Furthermore, I suppose the behaviour should be similar to the newly added platform view. There the |
@marvin-kolja Thank you so much for the investigation and clear explanation! That could definitely explain what's going on! I agree3 that we should also investigate the platform view video player to ensure it's working correctly, too. |
@camsim99 I'll do a little more testing with different Android versions using emulators (I do not own a physical Android device). Then I could create a PR for others to test the changes I deem to be appropriate. |
@marvin-kolja Thank you, that sounds great! I can help test with physical devices when you create a PR. |
i can help testing this too when PR is up |
Thank you @suesitran and @camsim99. I opened a PR (flutter/packages#9199). Turns out we only needed minimal changes. Also, the new platform view is fine as it is and doesn't need any changes. |
Steps to reproduce
Expected results
I should see correct aspect ratio
Actual results
Aspect ratio is wrong, see screenshots below
So while 2.9.3 was bad, 2.9.5 is unfortionately worse for galaxy A50
Code sample
Code sample
[Paste your code here]
Screenshots or Video
Logs
Logs
[Paste your logs here]
Flutter Doctor output
Doctor output
/Users/andreashofmann/code/flutter/bin/flutter doctor --verbose
[✓] Flutter (Channel stable, 3.29.2, on macOS 15.3.2 24D81 darwin-arm64, locale de-DE) [487ms]
• Flutter version 3.29.2 on channel stable at /Users/andreashofmann/code/flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision c236373 (vor 2 Wochen), 2025-03-13 16:17:06 -0400
• Engine revision 18b71d6
• Dart version 3.7.2
• DevTools version 2.42.3
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) [1.470ms]
• Android SDK at /Users/andreashofmann/Library/Android/sdk
• Platform android-35, build-tools 35.0.0
• Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use:
flutter config --jdk-dir="path/to/jdk"
.• Java version OpenJDK Runtime Environment (build 21.0.5+-13047016-b750.29)
• All Android licenses accepted.
[✓] Xcode - develop for iOS and macOS (Xcode 16.2) [1.374ms]
• Xcode at /Applications/Xcode.app/Contents/Developer
• Build 16C5032a
• CocoaPods version 1.16.2
[✓] Chrome - develop for the web [31ms]
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Android Studio (version 2024.3) [30ms]
• 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
• android-studio-dir = /Applications/Android Studio.app
• Java version OpenJDK Runtime Environment (build 21.0.5+-13047016-b750.29)
[✓] IntelliJ IDEA Community Edition (version 2022.3.2) [29ms]
• IntelliJ at /Applications/IntelliJ IDEA CE.app
• 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
[✓] Connected device (6 available) [6,9s]
• SM A505FN (mobile) • R58MB47Z7FZ • android-arm64 • Android 11 (API 30)
• sdk gphone64 arm64 (mobile) • emulator-5554 • android-arm64 • Android 14 (API 34) (emulator)
• iPhone 16 Pro (mobile) • 0986E39E-BDF8-4A29-BA63-C814D50CB09B • ios • com.apple.CoreSimulator.SimRuntime.iOS-18-2 (simulator)
• macOS (desktop) • macos • darwin-arm64 • macOS 15.3.2 24D81 darwin-arm64
• Mac Designed for iPad (desktop) • mac-designed-for-ipad • darwin • macOS 15.3.2 24D81 darwin-arm64
• Chrome (web) • chrome • web-javascript • Google Chrome 134.0.6998.166
[✓] Network resources [269ms]
• All expected network resources are available.
• No issues found!
Process finished with exit code 0
The text was updated successfully, but these errors were encountered: