Skip to content

Commit 3fc8655

Browse files
committed
[video_player] Add Picture-in-Picture support for iOS
Adds PiP support across three packages: ## video_player_platform_interface (6.6.0 → 6.7.0) - Add enablePictureInPicture, disablePictureInPicture, startPictureInPicture, stopPictureInPicture, isPictureInPictureSupported, isPictureInPictureActive - Add pipStarted, pipStopped, pipRestoreUserInterface event types ## video_player_avfoundation (2.9.3 → 2.10.0) - Integrate AVPictureInPictureController with AVPlayerLayer - Support both texture-based and platform view players - Implement PiP delegate callbacks (start/stop/restore UI) - macOS returns stub implementations (PiP not supported) - Requires iOS 14.2+ ## video_player (2.11.0 → 2.12.0) - Add isPictureInPictureActive field to VideoPlayerValue - Add PiP methods to VideoPlayerController - Handle PiP events in the event listener Fixes flutter/flutter#60048
1 parent 12b43a1 commit 3fc8655

28 files changed

Lines changed: 1459 additions & 242 deletions

packages/video_player/video_player/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2.12.0
2+
3+
* Adds Picture-in-Picture (PiP) support for iOS.
4+
* Adds `isPictureInPictureActive` field to `VideoPlayerValue`.
5+
* Adds `enablePictureInPicture()`, `disablePictureInPicture()`, `startPictureInPicture()`, `stopPictureInPicture()`, `isPictureInPictureSupported()`, and `isPictureInPictureActive()` methods to `VideoPlayerController`.
6+
17
## 2.11.0
28

39
* Adds `getAudioTracks()` and `selectAudioTrack()` methods to retrieve and select available audio tracks.

packages/video_player/video_player/lib/video_player.dart

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ class VideoPlayerValue {
172172
this.rotationCorrection = 0,
173173
this.errorDescription,
174174
this.isCompleted = false,
175+
this.isPictureInPictureActive = false,
175176
});
176177

177178
/// Returns an instance for a video that hasn't been loaded.
@@ -238,6 +239,9 @@ class VideoPlayerValue {
238239
/// Does not update if video is looping.
239240
final bool isCompleted;
240241

242+
/// Whether Picture-in-Picture is currently active.
243+
final bool isPictureInPictureActive;
244+
241245
/// The [size] of the currently loaded video.
242246
final Size size;
243247

@@ -286,6 +290,7 @@ class VideoPlayerValue {
286290
int? rotationCorrection,
287291
String? errorDescription = _defaultErrorDescription,
288292
bool? isCompleted,
293+
bool? isPictureInPictureActive,
289294
}) {
290295
return VideoPlayerValue(
291296
duration: duration ?? this.duration,
@@ -305,6 +310,8 @@ class VideoPlayerValue {
305310
? errorDescription
306311
: this.errorDescription,
307312
isCompleted: isCompleted ?? this.isCompleted,
313+
isPictureInPictureActive:
314+
isPictureInPictureActive ?? this.isPictureInPictureActive,
308315
);
309316
}
310317

@@ -324,7 +331,8 @@ class VideoPlayerValue {
324331
'volume: $volume, '
325332
'playbackSpeed: $playbackSpeed, '
326333
'errorDescription: $errorDescription, '
327-
'isCompleted: $isCompleted),';
334+
'isCompleted: $isCompleted, '
335+
'isPictureInPictureActive: $isPictureInPictureActive),';
328336
}
329337

330338
@override
@@ -346,7 +354,8 @@ class VideoPlayerValue {
346354
size == other.size &&
347355
rotationCorrection == other.rotationCorrection &&
348356
isInitialized == other.isInitialized &&
349-
isCompleted == other.isCompleted;
357+
isCompleted == other.isCompleted &&
358+
isPictureInPictureActive == other.isPictureInPictureActive;
350359

351360
@override
352361
int get hashCode => Object.hash(
@@ -365,6 +374,7 @@ class VideoPlayerValue {
365374
rotationCorrection,
366375
isInitialized,
367376
isCompleted,
377+
isPictureInPictureActive,
368378
);
369379
}
370380

@@ -646,6 +656,12 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
646656
} else {
647657
value = value.copyWith(isPlaying: event.isPlaying);
648658
}
659+
case platform_interface.VideoEventType.pipStarted:
660+
value = value.copyWith(isPictureInPictureActive: true);
661+
case platform_interface.VideoEventType.pipStopped:
662+
value = value.copyWith(isPictureInPictureActive: false);
663+
case platform_interface.VideoEventType.pipRestoreUserInterface:
664+
break;
649665
case platform_interface.VideoEventType.unknown:
650666
break;
651667
}
@@ -988,6 +1004,71 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
9881004
return _videoPlayerPlatform.isAudioTrackSupportAvailable();
9891005
}
9901006

1007+
/// Enables Picture-in-Picture for this player.
1008+
///
1009+
/// This must be called before [startPictureInPicture] to prepare the player
1010+
/// for PiP mode. On platforms that don't support PiP, this may be a no-op.
1011+
///
1012+
/// Throws a [StateError] if the controller is disposed or not initialized.
1013+
Future<void> enablePictureInPicture() async {
1014+
if (_isDisposedOrNotInitialized) {
1015+
throw StateError('VideoPlayerController is disposed or not initialized');
1016+
}
1017+
await _videoPlayerPlatform.enablePictureInPicture(_playerId);
1018+
}
1019+
1020+
/// Disables Picture-in-Picture for this player.
1021+
///
1022+
/// Throws a [StateError] if the controller is disposed or not initialized.
1023+
Future<void> disablePictureInPicture() async {
1024+
if (_isDisposedOrNotInitialized) {
1025+
throw StateError('VideoPlayerController is disposed or not initialized');
1026+
}
1027+
await _videoPlayerPlatform.disablePictureInPicture(_playerId);
1028+
}
1029+
1030+
/// Starts Picture-in-Picture mode for this player.
1031+
///
1032+
/// [enablePictureInPicture] must be called before this method.
1033+
///
1034+
/// Throws a [StateError] if the controller is disposed or not initialized.
1035+
Future<void> startPictureInPicture() async {
1036+
if (_isDisposedOrNotInitialized) {
1037+
throw StateError('VideoPlayerController is disposed or not initialized');
1038+
}
1039+
await _videoPlayerPlatform.startPictureInPicture(_playerId);
1040+
}
1041+
1042+
/// Stops Picture-in-Picture mode for this player.
1043+
///
1044+
/// Throws a [StateError] if the controller is disposed or not initialized.
1045+
Future<void> stopPictureInPicture() async {
1046+
if (_isDisposedOrNotInitialized) {
1047+
throw StateError('VideoPlayerController is disposed or not initialized');
1048+
}
1049+
await _videoPlayerPlatform.stopPictureInPicture(_playerId);
1050+
}
1051+
1052+
/// Returns whether Picture-in-Picture is supported on this device.
1053+
///
1054+
/// Throws a [StateError] if the controller is disposed or not initialized.
1055+
Future<bool> isPictureInPictureSupported() async {
1056+
if (_isDisposedOrNotInitialized) {
1057+
throw StateError('VideoPlayerController is disposed or not initialized');
1058+
}
1059+
return _videoPlayerPlatform.isPictureInPictureSupported(_playerId);
1060+
}
1061+
1062+
/// Returns whether Picture-in-Picture is currently active for this player.
1063+
///
1064+
/// Throws a [StateError] if the controller is disposed or not initialized.
1065+
Future<bool> isPictureInPictureActive() async {
1066+
if (_isDisposedOrNotInitialized) {
1067+
throw StateError('VideoPlayerController is disposed or not initialized');
1068+
}
1069+
return _videoPlayerPlatform.isPictureInPictureActive(_playerId);
1070+
}
1071+
9911072
bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized;
9921073
}
9931074

packages/video_player/video_player/pubspec.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter
33
widgets on Android, iOS, macOS and web.
44
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player
55
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
6-
version: 2.11.0
6+
version: 2.12.0
77

88
environment:
99
sdk: ^3.10.0
@@ -26,8 +26,8 @@ dependencies:
2626
sdk: flutter
2727
html: ^0.15.0
2828
video_player_android: ^2.9.1
29-
video_player_avfoundation: ^2.9.0
30-
video_player_platform_interface: ^6.6.0
29+
video_player_avfoundation: ^2.10.0
30+
video_player_platform_interface: ^6.7.0
3131
video_player_web: ^2.1.0
3232

3333
dev_dependencies:

packages/video_player/video_player/test/video_player_test.dart

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ class FakeController extends ValueNotifier<VideoPlayerValue>
130130
return true;
131131
}
132132

133+
@override
134+
Future<void> enablePictureInPicture() async {}
135+
136+
@override
137+
Future<void> disablePictureInPicture() async {}
138+
139+
@override
140+
Future<void> startPictureInPicture() async {}
141+
142+
@override
143+
Future<void> stopPictureInPicture() async {}
144+
145+
@override
146+
Future<bool> isPictureInPictureSupported() async => true;
147+
148+
@override
149+
Future<bool> isPictureInPictureActive() async => false;
150+
133151
String? selectedAudioTrackId;
134152
}
135153

@@ -1050,6 +1068,128 @@ void main() {
10501068
});
10511069
});
10521070

1071+
group('Picture-in-Picture', () {
1072+
test('enablePictureInPicture calls platform', () async {
1073+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1074+
addTearDown(controller.dispose);
1075+
1076+
await controller.initialize();
1077+
await controller.enablePictureInPicture();
1078+
1079+
expect(fakeVideoPlayerPlatform.calls.last, 'enablePictureInPicture');
1080+
});
1081+
1082+
test('disablePictureInPicture calls platform', () async {
1083+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1084+
addTearDown(controller.dispose);
1085+
1086+
await controller.initialize();
1087+
await controller.disablePictureInPicture();
1088+
1089+
expect(fakeVideoPlayerPlatform.calls.last, 'disablePictureInPicture');
1090+
});
1091+
1092+
test('startPictureInPicture calls platform', () async {
1093+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1094+
addTearDown(controller.dispose);
1095+
1096+
await controller.initialize();
1097+
await controller.startPictureInPicture();
1098+
1099+
expect(fakeVideoPlayerPlatform.calls.last, 'startPictureInPicture');
1100+
});
1101+
1102+
test('stopPictureInPicture calls platform', () async {
1103+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1104+
addTearDown(controller.dispose);
1105+
1106+
await controller.initialize();
1107+
await controller.stopPictureInPicture();
1108+
1109+
expect(fakeVideoPlayerPlatform.calls.last, 'stopPictureInPicture');
1110+
});
1111+
1112+
test('isPictureInPictureSupported returns result', () async {
1113+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1114+
addTearDown(controller.dispose);
1115+
1116+
await controller.initialize();
1117+
final bool result = await controller.isPictureInPictureSupported();
1118+
1119+
expect(result, true);
1120+
expect(
1121+
fakeVideoPlayerPlatform.calls.last,
1122+
'isPictureInPictureSupported',
1123+
);
1124+
});
1125+
1126+
test('isPictureInPictureActive returns result', () async {
1127+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1128+
addTearDown(controller.dispose);
1129+
1130+
await controller.initialize();
1131+
final bool result = await controller.isPictureInPictureActive();
1132+
1133+
expect(result, false);
1134+
expect(fakeVideoPlayerPlatform.calls.last, 'isPictureInPictureActive');
1135+
});
1136+
1137+
test('PiP methods before initialization throw', () async {
1138+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1139+
addTearDown(controller.dispose);
1140+
1141+
expect(
1142+
() => controller.enablePictureInPicture(),
1143+
throwsA(isA<StateError>()),
1144+
);
1145+
expect(
1146+
() => controller.startPictureInPicture(),
1147+
throwsA(isA<StateError>()),
1148+
);
1149+
expect(
1150+
() => controller.stopPictureInPicture(),
1151+
throwsA(isA<StateError>()),
1152+
);
1153+
});
1154+
1155+
test('pipStarted event updates VideoPlayerValue', () async {
1156+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1157+
addTearDown(controller.dispose);
1158+
1159+
await controller.initialize();
1160+
1161+
expect(controller.value.isPictureInPictureActive, false);
1162+
1163+
fakeVideoPlayerPlatform.streams[controller.playerId]!.add(
1164+
VideoEvent(eventType: VideoEventType.pipStarted),
1165+
);
1166+
await Future<void>.delayed(Duration.zero);
1167+
1168+
expect(controller.value.isPictureInPictureActive, true);
1169+
});
1170+
1171+
test('pipStopped event updates VideoPlayerValue', () async {
1172+
final controller = VideoPlayerController.networkUrl(_localhostUri);
1173+
addTearDown(controller.dispose);
1174+
1175+
await controller.initialize();
1176+
1177+
// Simulate PiP started first.
1178+
fakeVideoPlayerPlatform.streams[controller.playerId]!.add(
1179+
VideoEvent(eventType: VideoEventType.pipStarted),
1180+
);
1181+
await Future<void>.delayed(Duration.zero);
1182+
expect(controller.value.isPictureInPictureActive, true);
1183+
1184+
// Now stop PiP.
1185+
fakeVideoPlayerPlatform.streams[controller.playerId]!.add(
1186+
VideoEvent(eventType: VideoEventType.pipStopped),
1187+
);
1188+
await Future<void>.delayed(Duration.zero);
1189+
expect(controller.value.isPictureInPictureActive, false);
1190+
});
1191+
});
1192+
10531193
group('caption', () {
10541194
test('works when position updates', () async {
10551195
final controller = VideoPlayerController.networkUrl(
@@ -1476,7 +1616,8 @@ void main() {
14761616
'volume: 0.5, '
14771617
'playbackSpeed: 1.5, '
14781618
'errorDescription: null, '
1479-
'isCompleted: false),',
1619+
'isCompleted: false, '
1620+
'isPictureInPictureActive: false),',
14801621
);
14811622
});
14821623

@@ -1904,4 +2045,36 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
19042045
}
19052046

19062047
final Map<int, String> selectedAudioTrackIds = <int, String>{};
2048+
2049+
@override
2050+
Future<void> enablePictureInPicture(int playerId) async {
2051+
calls.add('enablePictureInPicture');
2052+
}
2053+
2054+
@override
2055+
Future<void> disablePictureInPicture(int playerId) async {
2056+
calls.add('disablePictureInPicture');
2057+
}
2058+
2059+
@override
2060+
Future<void> startPictureInPicture(int playerId) async {
2061+
calls.add('startPictureInPicture');
2062+
}
2063+
2064+
@override
2065+
Future<void> stopPictureInPicture(int playerId) async {
2066+
calls.add('stopPictureInPicture');
2067+
}
2068+
2069+
@override
2070+
Future<bool> isPictureInPictureSupported(int playerId) async {
2071+
calls.add('isPictureInPictureSupported');
2072+
return true;
2073+
}
2074+
2075+
@override
2076+
Future<bool> isPictureInPictureActive(int playerId) async {
2077+
calls.add('isPictureInPictureActive');
2078+
return false;
2079+
}
19072080
}

packages/video_player/video_player_avfoundation/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 2.10.0
2+
3+
* Adds Picture-in-Picture (PiP) support for iOS.
4+
* Implements `enablePictureInPicture()`, `disablePictureInPicture()`, `startPictureInPicture()`, `stopPictureInPicture()`, `isPictureInPictureSupported()`, and `isPictureInPictureActive()` methods.
5+
16
## 2.9.3
27

38
* Fixes a regression where HTTP headers were ignored.

0 commit comments

Comments
 (0)