diff --git a/CHANGELOG.md b/CHANGELOG.md index 373283df..d0365274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added - `isBackgroundPlaybackEnabled` to `PlaybackConfig`. For now this is only supported on iOS. - `BackgroundPlayback` example to the example Application +- Support for AirPlay on iOS + - `Player.isAirPlayActive` to indicate whether media is being played externally using AirPlay. + - `Player.isAirPlayAvailable` to indicate whether AirPlay is available. + - `Player.showAirPlayTargetPicker` to display the AirPlay playback target picker. + - `RemoteControlConfig.isAirPlayEnabled` to control whether AirPlay should be possible. + - `AirPlayAvailableEvent` which is emitted when AirPlay is available. + - `AirPlayChangedEvent` which is emitted when AirPlay playback starts or stops. ## [0.2.0] - 2023-11-06 ### Added diff --git a/example/lib/pages/casting.dart b/example/lib/pages/casting.dart index 93f9f30e..eb98879e 100644 --- a/example/lib/pages/casting.dart +++ b/example/lib/pages/casting.dart @@ -101,7 +101,7 @@ class _CastingState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Basic Playback'), + title: const Text('Casting'), ), body: FutureBuilder(future: _playerState, builder: buildPlayer), ); diff --git a/example/lib/pages/event_subscription.dart b/example/lib/pages/event_subscription.dart index a46fda6e..7706b4cc 100644 --- a/example/lib/pages/event_subscription.dart +++ b/example/lib/pages/event_subscription.dart @@ -66,7 +66,9 @@ class _EventSubscriptionState extends State { ..onSubtitleRemoved = _onEvent ..onSubtitleChanged = _onEvent ..onCueEnter = _onEvent - ..onCueExit = _onEvent; + ..onCueExit = _onEvent + ..onAirPlayAvailable = _onEvent + ..onAirPlayChanged = _onEvent; } @override diff --git a/ios/Classes/Event+JSON.swift b/ios/Classes/Event+JSON.swift index c02eaf0b..3914f35a 100644 --- a/ios/Classes/Event+JSON.swift +++ b/ios/Classes/Event+JSON.swift @@ -422,6 +422,17 @@ extension CastWaitingForDeviceEvent { } } +extension AirPlayChangedEvent { + func toJSON() -> [String: Any] { + [ + "event": name, + "timestamp": Int(timestamp), + "isAirPlayActive": isAirPlayActive, + "time": time + ] + } +} + extension Double { var jsonValue: Any { switch self { diff --git a/ios/Classes/FlutterPlayer.swift b/ios/Classes/FlutterPlayer.swift index 1e228c74..09ca65a6 100644 --- a/ios/Classes/FlutterPlayer.swift +++ b/ios/Classes/FlutterPlayer.swift @@ -143,6 +143,12 @@ private extension FlutterPlayer { player.castVideo() case (Methods.castStop, .empty): player.castStop() + case (Methods.isAirPlayActive, .empty): + return player.isAirPlayActive + case (Methods.isAirPlayAvailable, .empty): + return player.isAirPlayAvailable + case (Methods.showAirPlayTargetPicker, .empty): + player.showAirPlayTargetPicker() default: throw BitmovinError.unknownMethod(call.method) } @@ -350,4 +356,12 @@ extension FlutterPlayer: PlayerListener { func onCastTimeUpdated(_ event: CastTimeUpdatedEvent, player: Player) { broadcast(name: event.name, data: event.toJsonFallback(), sink: eventSink) } + + func onAirPlayAvailable(_ event: AirPlayAvailableEvent, player: Player) { + broadcast(name: event.name, data: event.toJsonFallback(), sink: eventSink) + } + + func onAirPlayChanged(_ event: AirPlayChangedEvent, player: Player) { + broadcast(name: event.name, data: event.toJSON(), sink: eventSink) + } } diff --git a/ios/Classes/JsonObjects.swift b/ios/Classes/JsonObjects.swift index 212b32e7..b38ece2e 100644 --- a/ios/Classes/JsonObjects.swift +++ b/ios/Classes/JsonObjects.swift @@ -275,6 +275,7 @@ private enum JsonValues { internal struct FlutterRemoteControlConfig: FlutterToNativeConvertible { let receiverStylesheetUrl: String? let customReceiverConfig: [String: String] + let isAirPlayEnabled: Bool let isCastEnabled: Bool let sendManifestRequestsWithCredentials: Bool let sendSegmentRequestsWithCredentials: Bool @@ -287,6 +288,7 @@ internal struct FlutterRemoteControlConfig: FlutterToNativeConvertible { } result.customReceiverConfig = customReceiverConfig + result.isAirPlayEnabled = isAirPlayEnabled result.isCastEnabled = isCastEnabled result.sendManifestRequestsWithCredentials = sendManifestRequestsWithCredentials result.sendSegmentRequestsWithCredentials = sendSegmentRequestsWithCredentials @@ -301,6 +303,7 @@ extension RemoteControlConfig: NativeToFlutterConvertible { FlutterRemoteControlConfig( receiverStylesheetUrl: receiverStylesheetUrl?.absoluteString, customReceiverConfig: customReceiverConfig, + isAirPlayEnabled: isAirPlayEnabled, isCastEnabled: isCastEnabled, sendManifestRequestsWithCredentials: sendManifestRequestsWithCredentials, sendSegmentRequestsWithCredentials: sendSegmentRequestsWithCredentials, diff --git a/ios/Classes/Methods.swift b/ios/Classes/Methods.swift index 72bedc63..a54ab2ff 100644 --- a/ios/Classes/Methods.swift +++ b/ios/Classes/Methods.swift @@ -27,6 +27,9 @@ internal enum Methods { static let isCasting = "isCasting" static let castVideo = "castVideo" static let castStop = "castStop" + static let isAirPlayActive = "isAirPlayActive" + static let isAirPlayAvailable = "isAirPlayAvailable" + static let showAirPlayTargetPicker = "showAirPlayTargetPicker" // Player view related methods static let destroyPlayerView = "destroyPlayerView" diff --git a/lib/bitmovin_player.dart b/lib/bitmovin_player.dart index 5eee3e4d..95a17d8b 100644 --- a/lib/bitmovin_player.dart +++ b/lib/bitmovin_player.dart @@ -13,6 +13,8 @@ export 'src/api/event/data/cast_payload.dart'; export 'src/api/event/data/seek_position.dart'; export 'src/api/event/event.dart'; export 'src/api/event/listener/player_listener.dart'; +export 'src/api/event/player/airplay_available_event.dart'; +export 'src/api/event/player/airplay_changed_event.dart'; export 'src/api/event/player/cast_available_event.dart'; export 'src/api/event/player/cast_start_event.dart'; export 'src/api/event/player/cast_started_event.dart'; diff --git a/lib/src/api/casting/remote_control_config.dart b/lib/src/api/casting/remote_control_config.dart index 73b88c0b..05606eba 100644 --- a/lib/src/api/casting/remote_control_config.dart +++ b/lib/src/api/casting/remote_control_config.dart @@ -10,6 +10,7 @@ class RemoteControlConfig extends Equatable { const RemoteControlConfig({ this.receiverStylesheetUrl, this.customReceiverConfig = const {}, + this.isAirPlayEnabled = true, this.isCastEnabled = true, this.sendManifestRequestsWithCredentials = false, this.sendSegmentRequestsWithCredentials = false, @@ -32,6 +33,14 @@ class RemoteControlConfig extends Equatable { /// Default value is an empty map. final Map customReceiverConfig; + /// Whether the AirPlay option is enabled or not. Default value is `true`. + /// + /// Calling [Player.showAirPlayTargetPicker] when the value is `false` will + /// not have any effect. + /// + /// Only available on iOS. + final bool isAirPlayEnabled; + /// Whether casting is enabled. /// Default value is `true`. /// @@ -58,6 +67,7 @@ class RemoteControlConfig extends Equatable { List get props => [ receiverStylesheetUrl, customReceiverConfig, + isAirPlayEnabled, isCastEnabled, sendManifestRequestsWithCredentials, sendSegmentRequestsWithCredentials, diff --git a/lib/src/api/casting/remote_control_config.g.dart b/lib/src/api/casting/remote_control_config.g.dart index 28faa927..41e06ecb 100644 --- a/lib/src/api/casting/remote_control_config.g.dart +++ b/lib/src/api/casting/remote_control_config.g.dart @@ -14,6 +14,7 @@ RemoteControlConfig _$RemoteControlConfigFromJson(Map json) => (k, e) => MapEntry(k, e as String), ) ?? const {}, + isAirPlayEnabled: json['isAirPlayEnabled'] as bool? ?? true, isCastEnabled: json['isCastEnabled'] as bool? ?? true, sendManifestRequestsWithCredentials: json['sendManifestRequestsWithCredentials'] as bool? ?? false, @@ -28,6 +29,7 @@ Map _$RemoteControlConfigToJson( { 'receiverStylesheetUrl': instance.receiverStylesheetUrl, 'customReceiverConfig': instance.customReceiverConfig, + 'isAirPlayEnabled': instance.isAirPlayEnabled, 'isCastEnabled': instance.isCastEnabled, 'sendManifestRequestsWithCredentials': instance.sendManifestRequestsWithCredentials, diff --git a/lib/src/api/event/listener/player_listener.dart b/lib/src/api/event/listener/player_listener.dart index 94df267e..b1b814a0 100644 --- a/lib/src/api/event/listener/player_listener.dart +++ b/lib/src/api/event/listener/player_listener.dart @@ -108,4 +108,10 @@ abstract class PlayerListener { /// See [CastTimeUpdatedEvent] for details on this event. set onCastTimeUpdated(void Function(CastTimeUpdatedEvent) func); + + /// See [AirPlayAvailableEvent] for details on this event. + set onAirPlayAvailable(void Function(AirPlayAvailableEvent) func); + + /// See [AirPlayChangedEvent] for details on this event. + set onAirPlayChanged(void Function(AirPlayChangedEvent) func); } diff --git a/lib/src/api/event/player/airplay_available_event.dart b/lib/src/api/event/player/airplay_available_event.dart new file mode 100644 index 00000000..22113c14 --- /dev/null +++ b/lib/src/api/event/player/airplay_available_event.dart @@ -0,0 +1,18 @@ +import 'package:bitmovin_player/src/api/event/event.dart'; +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'airplay_available_event.g.dart'; + +/// Emitted when AirPlay is available. +@JsonSerializable(explicitToJson: true) +class AirPlayAvailableEvent extends Event with EquatableMixin { + const AirPlayAvailableEvent({required super.timestamp}); + + factory AirPlayAvailableEvent.fromJson(Map json) { + return _$AirPlayAvailableEventFromJson(json); + } + + @override + Map toJson() => _$AirPlayAvailableEventToJson(this); +} diff --git a/lib/src/api/event/player/airplay_available_event.g.dart b/lib/src/api/event/player/airplay_available_event.g.dart new file mode 100644 index 00000000..960fe347 --- /dev/null +++ b/lib/src/api/event/player/airplay_available_event.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'airplay_available_event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AirPlayAvailableEvent _$AirPlayAvailableEventFromJson( + Map json) => + AirPlayAvailableEvent( + timestamp: json['timestamp'] as int?, + ); + +Map _$AirPlayAvailableEventToJson( + AirPlayAvailableEvent instance) => + { + 'timestamp': instance.timestamp, + }; diff --git a/lib/src/api/event/player/airplay_changed_event.dart b/lib/src/api/event/player/airplay_changed_event.dart new file mode 100644 index 00000000..01adc13d --- /dev/null +++ b/lib/src/api/event/player/airplay_changed_event.dart @@ -0,0 +1,31 @@ +import 'package:bitmovin_player/src/api/event/event.dart'; +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'airplay_changed_event.g.dart'; + +/// Emitted when AirPlay playback starts or stops. +@JsonSerializable(explicitToJson: true) +class AirPlayChangedEvent extends Event with EquatableMixin { + const AirPlayChangedEvent({ + required super.timestamp, + required this.isAirPlayActive, + required this.time, + }); + + factory AirPlayChangedEvent.fromJson(Map json) { + return _$AirPlayChangedEventFromJson(json); + } + + /// Indicates whether AirPlay is active. + final bool isAirPlayActive; + + // The current playback time (in seconds). + final double time; + + @override + Map toJson() => _$AirPlayChangedEventToJson(this); + + @override + List get props => [timestamp, isAirPlayActive, time]; +} diff --git a/lib/src/api/event/player/airplay_changed_event.g.dart b/lib/src/api/event/player/airplay_changed_event.g.dart new file mode 100644 index 00000000..8061b0e7 --- /dev/null +++ b/lib/src/api/event/player/airplay_changed_event.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'airplay_changed_event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AirPlayChangedEvent _$AirPlayChangedEventFromJson(Map json) => + AirPlayChangedEvent( + timestamp: json['timestamp'] as int?, + isAirPlayActive: json['isAirPlayActive'] as bool, + time: (json['time'] as num).toDouble(), + ); + +Map _$AirPlayChangedEventToJson( + AirPlayChangedEvent instance) => + { + 'timestamp': instance.timestamp, + 'isAirPlayActive': instance.isAirPlayActive, + 'time': instance.time, + }; diff --git a/lib/src/api/player/player_api.dart b/lib/src/api/player/player_api.dart index ab710e99..fef49c21 100644 --- a/lib/src/api/player/player_api.dart +++ b/lib/src/api/player/player_api.dart @@ -101,4 +101,19 @@ abstract class PlayerApi { /// Stops casting the current video. Future castStop(); + + /// Returns `true` when media is played externally using AirPlay. + /// + /// Only available on iOS. + Future get isAirPlayActive; + + /// Returns `true` when AirPlay is available. + /// + /// Only available on iOS. + Future get isAirPlayAvailable; + + /// Shows the AirPlay playback target picker. + /// + /// Only available on iOS. + Future showAirPlayTargetPicker(); } diff --git a/lib/src/methods.dart b/lib/src/methods.dart index dcb624d9..6cecbc58 100644 --- a/lib/src/methods.dart +++ b/lib/src/methods.dart @@ -25,6 +25,9 @@ class Methods { static const String isCasting = 'isCasting'; static const String castVideo = 'castVideo'; static const String castStop = 'castStop'; + static const String isAirPlayActive = 'isAirPlayActive'; + static const String isAirPlayAvailable = 'isAirPlayAvailable'; + static const String showAirPlayTargetPicker = 'showAirPlayTargetPicker'; /// Player view related methods static const String destroyPlayerView = 'destroyPlayerView'; diff --git a/lib/src/player.dart b/lib/src/player.dart index 7d1e7ef1..b0d2811d 100644 --- a/lib/src/player.dart +++ b/lib/src/player.dart @@ -289,6 +289,17 @@ class Player with PlayerEventHandler implements PlayerApi { @override Future castStop() => _invokeMethod(Methods.castStop); + + @override + Future get isAirPlayActive => _invokeMethod(Methods.isAirPlayActive); + + @override + Future get isAirPlayAvailable => + _invokeMethod(Methods.isAirPlayAvailable); + + @override + Future showAirPlayTargetPicker() => + _invokeMethod(Methods.showAirPlayTargetPicker); } class _AnalyticsApi implements AnalyticsApi { diff --git a/lib/src/player_event_handler.dart b/lib/src/player_event_handler.dart index 0e64cb21..f60d6dbb 100644 --- a/lib/src/player_event_handler.dart +++ b/lib/src/player_event_handler.dart @@ -141,6 +141,13 @@ mixin PlayerEventHandler implements PlayerListener { case 'onCastTimeUpdated': emit(CastTimeUpdatedEvent.fromJson(data)); break; + case 'onAirPlayAvailable': + case 'onAirplayAvailable': + emit(AirPlayAvailableEvent.fromJson(data)); + break; + case 'onAirPlayChanged': + emit(AirPlayChangedEvent.fromJson(data)); + break; } } @@ -329,4 +336,14 @@ mixin PlayerEventHandler implements PlayerListener { set onCastTimeUpdated(void Function(CastTimeUpdatedEvent) func) { _addListener(func); } + + @override + set onAirPlayAvailable(void Function(AirPlayAvailableEvent) func) { + _addListener(func); + } + + @override + set onAirPlayChanged(void Function(AirPlayChangedEvent) func) { + _addListener(func); + } }