Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/video_player/video_player/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.12.0

* Adds Picture-in-Picture (PiP) support for iOS.
* Adds `isPictureInPictureActive` field to `VideoPlayerValue`.
* Adds `startPictureInPicture()`, `stopPictureInPicture()`, `isPictureInPictureSupported()`, and `isPictureInPictureActive()` methods to `VideoPlayerController`.

## 2.11.0

* Adds `getAudioTracks()` and `selectAudioTrack()` methods to retrieve and select available audio tracks.
Expand Down
59 changes: 57 additions & 2 deletions packages/video_player/video_player/lib/video_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ class VideoPlayerValue {
this.rotationCorrection = 0,
this.errorDescription,
this.isCompleted = false,
this.isPictureInPictureActive = false,
});

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

/// Whether Picture-in-Picture is currently active.
final bool isPictureInPictureActive;

/// The [size] of the currently loaded video.
final Size size;

Expand Down Expand Up @@ -286,6 +290,7 @@ class VideoPlayerValue {
int? rotationCorrection,
String? errorDescription = _defaultErrorDescription,
bool? isCompleted,
bool? isPictureInPictureActive,
}) {
return VideoPlayerValue(
duration: duration ?? this.duration,
Expand All @@ -305,6 +310,8 @@ class VideoPlayerValue {
? errorDescription
: this.errorDescription,
isCompleted: isCompleted ?? this.isCompleted,
isPictureInPictureActive:
isPictureInPictureActive ?? this.isPictureInPictureActive,
);
}

Expand All @@ -324,7 +331,8 @@ class VideoPlayerValue {
'volume: $volume, '
'playbackSpeed: $playbackSpeed, '
'errorDescription: $errorDescription, '
'isCompleted: $isCompleted),';
'isCompleted: $isCompleted, '
'isPictureInPictureActive: $isPictureInPictureActive),';
}

@override
Expand All @@ -346,7 +354,8 @@ class VideoPlayerValue {
size == other.size &&
rotationCorrection == other.rotationCorrection &&
isInitialized == other.isInitialized &&
isCompleted == other.isCompleted;
isCompleted == other.isCompleted &&
isPictureInPictureActive == other.isPictureInPictureActive;

@override
int get hashCode => Object.hash(
Expand All @@ -365,6 +374,7 @@ class VideoPlayerValue {
rotationCorrection,
isInitialized,
isCompleted,
isPictureInPictureActive,
);
}

Expand Down Expand Up @@ -646,6 +656,11 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
} else {
value = value.copyWith(isPlaying: event.isPlaying);
}
case platform_interface.VideoEventType.pipStarted:
value = value.copyWith(isPictureInPictureActive: true);
case platform_interface.VideoEventType.pipStopped:
value = value.copyWith(isPictureInPictureActive: false);
case platform_interface.VideoEventType.pipRestoreUserInterface:
case platform_interface.VideoEventType.unknown:
break;
}
Expand Down Expand Up @@ -988,6 +1003,46 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
return _videoPlayerPlatform.isAudioTrackSupportAvailable();
}

/// Starts Picture-in-Picture mode for this player.
///
/// Throws a [StateError] if the controller is disposed or not initialized.
Future<void> startPictureInPicture() async {
if (_isDisposedOrNotInitialized) {
throw StateError('VideoPlayerController is disposed or not initialized');
}
await _videoPlayerPlatform.startPictureInPicture(_playerId);
}

/// Stops Picture-in-Picture mode for this player.
///
/// Throws a [StateError] if the controller is disposed or not initialized.
Future<void> stopPictureInPicture() async {
if (_isDisposedOrNotInitialized) {
throw StateError('VideoPlayerController is disposed or not initialized');
}
await _videoPlayerPlatform.stopPictureInPicture(_playerId);
}

/// Returns whether Picture-in-Picture is supported on this device.
///
/// Throws a [StateError] if the controller is disposed or not initialized.
Future<bool> isPictureInPictureSupported() async {
if (_isDisposedOrNotInitialized) {
throw StateError('VideoPlayerController is disposed or not initialized');
}
return _videoPlayerPlatform.isPictureInPictureSupported(_playerId);
}

/// Returns whether Picture-in-Picture is currently active for this player.
///
/// Throws a [StateError] if the controller is disposed or not initialized.
Future<bool> isPictureInPictureActive() async {
if (_isDisposedOrNotInitialized) {
throw StateError('VideoPlayerController is disposed or not initialized');
}
return _videoPlayerPlatform.isPictureInPictureActive(_playerId);
}

bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/video_player/video_player/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter
widgets on Android, iOS, macOS and web.
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
version: 2.11.0
version: 2.12.0

environment:
sdk: ^3.10.0
Expand All @@ -26,8 +26,8 @@ dependencies:
sdk: flutter
html: ^0.15.0
video_player_android: ^2.9.1
video_player_avfoundation: ^2.9.0
video_player_platform_interface: ^6.6.0
video_player_avfoundation: ^2.10.0
video_player_platform_interface: ^6.7.0
video_player_web: ^2.1.0

dev_dependencies:
Expand Down
135 changes: 134 additions & 1 deletion packages/video_player/video_player/test/video_player_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ class FakeController extends ValueNotifier<VideoPlayerValue>
return true;
}

@override
Future<void> startPictureInPicture() async {}

@override
Future<void> stopPictureInPicture() async {}

@override
Future<bool> isPictureInPictureSupported() async => true;

@override
Future<bool> isPictureInPictureActive() async => false;

String? selectedAudioTrackId;
}

Expand Down Expand Up @@ -1050,6 +1062,104 @@ void main() {
});
});

group('Picture-in-Picture', () {
test('startPictureInPicture calls platform', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

await controller.initialize();
await controller.startPictureInPicture();

expect(fakeVideoPlayerPlatform.calls.last, 'startPictureInPicture');
});

test('stopPictureInPicture calls platform', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

await controller.initialize();
await controller.stopPictureInPicture();

expect(fakeVideoPlayerPlatform.calls.last, 'stopPictureInPicture');
});

test('isPictureInPictureSupported returns result', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

await controller.initialize();
final bool result = await controller.isPictureInPictureSupported();

expect(result, true);
expect(
fakeVideoPlayerPlatform.calls.last,
'isPictureInPictureSupported',
);
});

test('isPictureInPictureActive returns result', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

await controller.initialize();
final bool result = await controller.isPictureInPictureActive();

expect(result, false);
expect(fakeVideoPlayerPlatform.calls.last, 'isPictureInPictureActive');
});

test('PiP methods before initialization throw', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

expect(
() => controller.startPictureInPicture(),
throwsA(isA<StateError>()),
);
expect(
() => controller.stopPictureInPicture(),
throwsA(isA<StateError>()),
);
});

test('pipStarted event updates VideoPlayerValue', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

await controller.initialize();

expect(controller.value.isPictureInPictureActive, false);

fakeVideoPlayerPlatform.streams[controller.playerId]!.add(
VideoEvent(eventType: VideoEventType.pipStarted),
);
await Future<void>.delayed(Duration.zero);

expect(controller.value.isPictureInPictureActive, true);
});

test('pipStopped event updates VideoPlayerValue', () async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
addTearDown(controller.dispose);

await controller.initialize();

// Simulate PiP started first.
fakeVideoPlayerPlatform.streams[controller.playerId]!.add(
VideoEvent(eventType: VideoEventType.pipStarted),
);
await Future<void>.delayed(Duration.zero);
expect(controller.value.isPictureInPictureActive, true);

// Now stop PiP.
fakeVideoPlayerPlatform.streams[controller.playerId]!.add(
VideoEvent(eventType: VideoEventType.pipStopped),
);
await Future<void>.delayed(Duration.zero);
expect(controller.value.isPictureInPictureActive, false);
});
});

group('caption', () {
test('works when position updates', () async {
final controller = VideoPlayerController.networkUrl(
Expand Down Expand Up @@ -1476,7 +1586,8 @@ void main() {
'volume: 0.5, '
'playbackSpeed: 1.5, '
'errorDescription: null, '
'isCompleted: false),',
'isCompleted: false, '
'isPictureInPictureActive: false),',
);
});

Expand Down Expand Up @@ -1904,4 +2015,26 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
}

final Map<int, String> selectedAudioTrackIds = <int, String>{};

@override
Future<void> startPictureInPicture(int playerId) async {
calls.add('startPictureInPicture');
}

@override
Future<void> stopPictureInPicture(int playerId) async {
calls.add('stopPictureInPicture');
}

@override
Future<bool> isPictureInPictureSupported(int playerId) async {
calls.add('isPictureInPictureSupported');
return true;
}

@override
Future<bool> isPictureInPictureActive(int playerId) async {
calls.add('isPictureInPictureActive');
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2.10.0

* Adds Picture-in-Picture (PiP) support for iOS.
* Implements `startPictureInPicture()`, `stopPictureInPicture()`, `isPictureInPictureSupported()`, and `isPictureInPictureActive()` methods.

## 2.9.3

* Fixes a regression where HTTP headers were ignored.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ - (void)videoPlayerDidSetPlaying:(BOOL)playing {
[self sendOrQueue:@{@"event" : @"isPlayingStateUpdate", @"isPlaying" : @(playing)}];
}

- (void)videoPlayerDidStartPictureInPicture {
[self sendOrQueue:@{@"event" : @"pipStarted"}];
}

- (void)videoPlayerDidStopPictureInPicture {
[self sendOrQueue:@{@"event" : @"pipStopped"}];
}

- (void)videoPlayerShouldRestoreUserInterfaceForPictureInPicture {
[self sendOrQueue:@{@"event" : @"pipRestoreUserInterface"}];
}

- (void)videoPlayerWasDisposed {
[self.eventChannel setStreamHandler:nil];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#import "../video_player_avfoundation/include/video_player_avfoundation/FVPNativeVideoView.h"
#import "../video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h"
#import "../video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer_Internal.h"
#import "../video_player_avfoundation/include/video_player_avfoundation/messages.g.h"

@interface FVPNativeVideoViewFactory ()
Expand Down Expand Up @@ -37,7 +38,16 @@ - (NSView *)createWithViewIdentifier:(int64_t)viewIdentifier
#endif
NSNumber *playerIdentifier = @(args.playerId);
FVPVideoPlayer *player = self.playerByIdProvider(playerIdentifier);
return [[FVPNativeVideoView alloc] initWithPlayer:player.player];
FVPNativeVideoView *nativeView = [[FVPNativeVideoView alloc] initWithPlayer:player.player];

#if TARGET_OS_IOS
// Set up PiP with the platform view's player layer.
if (@available(iOS 14.2, *)) {
[player setupPictureInPictureWithPlayerLayer:nativeView.playerLayer];
}
#endif

return nativeView;
}

- (NSObject<FlutterMessageCodec> *)createArgsCodec {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ - (instancetype)initWithPlayerItem:(NSObject<FVPAVPlayerItem> *)item
CALayer *flutterLayer = viewProvider.view.layer;
#endif
[flutterLayer addSublayer:self.playerLayer];

#if TARGET_OS_IOS
// Set up PiP using the existing invisible player layer.
if (@available(iOS 14.2, *)) {
[self setupPictureInPictureWithPlayerLayer:_playerLayer];
}
#endif
}
return self;
}
Expand Down
Loading