[interactive_media_ads] Fixes preloading ad while another was playing on Android (#9904)
Supporting preloading ads on Android requires handling more than one `AdMediaInfo` at a time. See https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/preload.
This adds a queue to `AndroidAdDisplayContainer` to track which ad is currently playing. Also updates the example app with a screen to test various ad tag urls.
Fixes https://github.com/flutter/flutter/issues/174243
## Pre-Review Checklist
**Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.
[^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
diff --git a/packages/interactive_media_ads/CHANGELOG.md b/packages/interactive_media_ads/CHANGELOG.md
index 3457bdf..5c84cf2 100644
--- a/packages/interactive_media_ads/CHANGELOG.md
+++ b/packages/interactive_media_ads/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.2.6+7
+
+* Updates Android `PlatformAdDisplayContainer` implementation to support preloading ads.
+
## 0.2.6+6
* Bumps com.android.tools.build:gradle to 8.12.1 and kotlin_version to 2.2.10.
diff --git a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/AdsRequestProxyApi.kt b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/AdsRequestProxyApi.kt
index 2d42007..5a239da 100644
--- a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/AdsRequestProxyApi.kt
+++ b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/AdsRequestProxyApi.kt
@@ -21,7 +21,7 @@
*
* This must match the version in pubspec.yaml.
*/
- const val pluginVersion = "0.2.6+6"
+ const val pluginVersion = "0.2.6+7"
}
override fun setAdTagUrl(pigeon_instance: AdsRequest, adTagUrl: String) {
diff --git a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/InteractiveMediaAdsLibrary.g.kt b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/InteractiveMediaAdsLibrary.g.kt
index dee5d5d..f0028be 100644
--- a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/InteractiveMediaAdsLibrary.g.kt
+++ b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/InteractiveMediaAdsLibrary.g.kt
@@ -678,6 +678,7 @@
value is AdErrorType ||
value is AdEventType ||
value is UiElement ||
+ value is AudioManagerAudioFocus ||
value == null) {
super.writeValue(stream, value)
return
@@ -956,6 +957,42 @@
}
}
+/**
+ * Used to indicate the type of audio focus for a view.
+ *
+ * See https://developer.android.com/reference/android/media/AudioManager#AUDIOFOCUS_GAIN.
+ */
+enum class AudioManagerAudioFocus(val raw: Int) {
+ /** Used to indicate a gain of audio focus, or a request of audio focus, of unknown duration. */
+ GAIN(0),
+ /**
+ * Used to indicate a temporary gain or request of audio focus, anticipated to last a short amount
+ * of time.
+ *
+ * Examples of temporary changes are the playback of driving directions, or an event notification.
+ */
+ GAIN_TRANSIENT(1),
+ /**
+ * Used to indicate a temporary request of audio focus, anticipated to last a short amount of
+ * time, during which no other applications, or system components, should play anything.
+ */
+ GAIN_TRANSIENT_EXCLUSIVE(2),
+ /**
+ * Used to indicate a temporary request of audio focus, anticipated to last a short amount of
+ * time, and where it is acceptable for other audio applications to keep playing after having
+ * lowered their output level (also referred to as "ducking").
+ */
+ GAIN_TRANSIENT_MAY_DUCK(3),
+ /** Used to indicate no audio focus has been gained or lost, or requested. */
+ NONE(4);
+
+ companion object {
+ fun ofRaw(raw: Int): AudioManagerAudioFocus? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
private open class InteractiveMediaAdsLibraryPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -971,6 +1008,9 @@
132.toByte() -> {
return (readValue(buffer) as Long?)?.let { UiElement.ofRaw(it.toInt()) }
}
+ 133.toByte() -> {
+ return (readValue(buffer) as Long?)?.let { AudioManagerAudioFocus.ofRaw(it.toInt()) }
+ }
else -> super.readValueOfType(type, buffer)
}
}
@@ -993,6 +1033,10 @@
stream.write(132)
writeValue(stream, value.raw)
}
+ is AudioManagerAudioFocus -> {
+ stream.write(133)
+ writeValue(stream, value.raw)
+ }
else -> super.writeValue(stream, value)
}
}
@@ -3709,6 +3753,17 @@
*/
abstract fun getCurrentPosition(pigeon_instance: android.widget.VideoView): Long
+ /**
+ * Sets which type of audio focus will be requested during the playback, or configures playback to
+ * not request audio focus.
+ *
+ * Only available on Android API 26+. Noop on lower versions.
+ */
+ abstract fun setAudioFocusRequest(
+ pigeon_instance: android.widget.VideoView,
+ focusGain: AudioManagerAudioFocus
+ )
+
companion object {
@Suppress("LocalVariableName")
fun setUpMessageHandlers(binaryMessenger: BinaryMessenger, api: PigeonApiVideoView?) {
@@ -3783,6 +3838,30 @@
channel.setMessageHandler(null)
}
}
+ run {
+ val channel =
+ BasicMessageChannel<Any?>(
+ binaryMessenger,
+ "dev.flutter.pigeon.interactive_media_ads.VideoView.setAudioFocusRequest",
+ codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List<Any?>
+ val pigeon_instanceArg = args[0] as android.widget.VideoView
+ val focusGainArg = args[1] as AudioManagerAudioFocus
+ val wrapped: List<Any?> =
+ try {
+ api.setAudioFocusRequest(pigeon_instanceArg, focusGainArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ InteractiveMediaAdsLibraryPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
}
}
diff --git a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/ProxyApiRegistrar.kt b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/ProxyApiRegistrar.kt
index c34c624..6c7e12d 100644
--- a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/ProxyApiRegistrar.kt
+++ b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/ProxyApiRegistrar.kt
@@ -5,8 +5,10 @@
package dev.flutter.packages.interactive_media_ads
import android.content.Context
+import android.os.Build
import android.os.Handler
import android.os.Looper
+import androidx.annotation.ChecksSdkIntAtLeast
import io.flutter.plugin.common.BinaryMessenger
/**
@@ -22,6 +24,12 @@
Handler(Looper.getMainLooper()).post { callback.run() }
}
+ // Interface for an injectable SDK version checker.
+ @ChecksSdkIntAtLeast(parameter = 0)
+ open fun sdkIsAtLeast(version: Int): Boolean {
+ return Build.VERSION.SDK_INT >= version
+ }
+
override fun getPigeonApiBaseDisplayContainer(): PigeonApiBaseDisplayContainer {
return BaseDisplayContainerProxyApi(this)
}
diff --git a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApi.kt b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApi.kt
index b84a284..d1c00c9 100644
--- a/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApi.kt
+++ b/packages/interactive_media_ads/android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApi.kt
@@ -4,7 +4,9 @@
package dev.flutter.packages.interactive_media_ads
+import android.media.AudioManager
import android.media.MediaPlayer
+import android.os.Build
import android.widget.VideoView
import androidx.core.net.toUri
@@ -35,4 +37,19 @@
override fun getCurrentPosition(pigeon_instance: VideoView): Long {
return pigeon_instance.currentPosition.toLong()
}
+
+ override fun setAudioFocusRequest(pigeon_instance: VideoView, focusGain: AudioManagerAudioFocus) {
+ if (pigeonRegistrar.sdkIsAtLeast(Build.VERSION_CODES.O)) {
+ pigeon_instance.setAudioFocusRequest(
+ when (focusGain) {
+ AudioManagerAudioFocus.GAIN -> AudioManager.AUDIOFOCUS_GAIN
+ AudioManagerAudioFocus.GAIN_TRANSIENT -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
+ AudioManagerAudioFocus.GAIN_TRANSIENT_EXCLUSIVE ->
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
+ AudioManagerAudioFocus.GAIN_TRANSIENT_MAY_DUCK ->
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
+ AudioManagerAudioFocus.NONE -> AudioManager.AUDIOFOCUS_NONE
+ })
+ }
+ }
}
diff --git a/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/TestProxyApiRegistrar.kt b/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/TestProxyApiRegistrar.kt
index 0aba706..b502006 100644
--- a/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/TestProxyApiRegistrar.kt
+++ b/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/TestProxyApiRegistrar.kt
@@ -14,4 +14,8 @@
override fun runOnMainThread(callback: Runnable) {
callback.run()
}
+
+ override fun sdkIsAtLeast(version: Int): Boolean {
+ return true
+ }
}
diff --git a/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApiTest.kt b/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApiTest.kt
index f43af0a..d61d7bd 100644
--- a/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApiTest.kt
+++ b/packages/interactive_media_ads/android/src/test/kotlin/dev/flutter/packages/interactive_media_ads/VideoViewProxyApiTest.kt
@@ -4,6 +4,7 @@
package dev.flutter.packages.interactive_media_ads
+import android.media.AudioManager
import android.net.Uri
import android.widget.VideoView
import kotlin.test.Test
@@ -35,4 +36,14 @@
assertEquals(0, api.getCurrentPosition(instance))
}
+
+ @Test
+ fun setAudioFocusRequest() {
+ val api = TestProxyApiRegistrar().getPigeonApiVideoView()
+
+ val instance = mock<VideoView>()
+ api.setAudioFocusRequest(instance, AudioManagerAudioFocus.GAIN)
+
+ verify(instance).setAudioFocusRequest(AudioManager.AUDIOFOCUS_GAIN)
+ }
}
diff --git a/packages/interactive_media_ads/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart
index da823b7..8d23178 100644
--- a/packages/interactive_media_ads/example/lib/main.dart
+++ b/packages/interactive_media_ads/example/lib/main.dart
@@ -2,12 +2,10 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:async';
-
import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
-import 'package:interactive_media_ads/interactive_media_ads.dart';
-import 'package:video_player/video_player.dart';
+
+import 'video_ad_example_screen.dart';
/// Entry point for integration tests that require espresso.
@pragma('vm:entry-point')
@@ -17,258 +15,75 @@
}
void main() {
- runApp(const MaterialApp(home: AdExampleWidget()));
+ runApp(const MaterialApp(home: HomeScreen()));
}
/// Example widget displaying an Ad before a video.
-class AdExampleWidget extends StatefulWidget {
- /// Constructs an [AdExampleWidget].
- const AdExampleWidget({super.key});
+class HomeScreen extends StatefulWidget {
+ /// Constructs an [HomeScreen].
+ const HomeScreen({super.key});
@override
- State<AdExampleWidget> createState() => _AdExampleWidgetState();
+ State<HomeScreen> createState() => _HomeScreenState();
}
-class _AdExampleWidgetState extends State<AdExampleWidget>
- with WidgetsBindingObserver {
- // IMA sample tag for a pre-, mid-, and post-roll, single inline video ad. See more IMA sample
- // tags at https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/tags
- static const String _adTagUrl =
- 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpost&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=';
+class _HomeScreenState extends State<HomeScreen> {
+ final List<(String, String)> _testAdTagUrls = <(String, String)>[
+ (
+ 'Single Inline Linear',
+ 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_ad_samples&sz=640x480&cust_params=sample_ct%3Dlinear&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&correlator=',
+ ),
+ (
+ 'Pre-, Mid-, Post-roll Singles',
+ 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpost&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&cmsid=496&vid=short_onecue&correlator=',
+ ),
+ (
+ 'Pre-roll + Bumper',
+ 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpreonlybumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&correlator=',
+ ),
+ (
+ 'Mid-roll ad pod with 2 skippable',
+ 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_skip_ad_samples&sz=640x480&cust_params=sample_ar%3Dmidskiponly&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&cmsid=496&vid=short_onecue&correlator=',
+ ),
+ ];
- // The AdsLoader instance exposes the request ads method.
- late final AdsLoader _adsLoader;
-
- // AdsManager exposes methods to control ad playback and listen to ad events.
- AdsManager? _adsManager;
-
- // Last state received in `didChangeAppLifecycleState`.
- AppLifecycleState _lastLifecycleState = AppLifecycleState.resumed;
-
- // Whether the widget should be displaying the content video. The content
- // player is hidden while Ads are playing.
- bool _shouldShowContentVideo = false;
-
- // Controls the content video player.
- late final VideoPlayerController _contentVideoController;
-
- // Periodically updates the SDK of the current playback progress of the
- // content video.
- Timer? _contentProgressTimer;
-
- // Provides the SDK with the current playback progress of the content video.
- // This is required to support mid-roll ads.
- final ContentProgressProvider _contentProgressProvider =
- ContentProgressProvider();
-
- late final CompanionAdSlot companionAd = CompanionAdSlot(
- size: CompanionAdSlotSize.fixed(width: 300, height: 250),
- onClicked: () => debugPrint('Companion Ad Clicked'),
- );
-
- late final AdDisplayContainer _adDisplayContainer = AdDisplayContainer(
- companionSlots: <CompanionAdSlot>[companionAd],
- onContainerAdded: (AdDisplayContainer container) {
- _adsLoader = AdsLoader(
- container: container,
- onAdsLoaded: (OnAdsLoadedData data) {
- final AdsManager manager = data.manager;
- _adsManager = data.manager;
-
- manager.setAdsManagerDelegate(
- AdsManagerDelegate(
- onAdEvent: (AdEvent event) {
- debugPrint('OnAdEvent: ${event.type} => ${event.adData}');
- switch (event.type) {
- case AdEventType.loaded:
- manager.start();
- case AdEventType.contentPauseRequested:
- _pauseContent();
- case AdEventType.contentResumeRequested:
- _resumeContent();
- case AdEventType.allAdsCompleted:
- manager.destroy();
- _adsManager = null;
- case AdEventType.clicked:
- case AdEventType.complete:
- case _:
- }
- },
- onAdErrorEvent: (AdErrorEvent event) {
- debugPrint('AdErrorEvent: ${event.error.message}');
- _resumeContent();
- },
- ),
- );
-
- manager.init(settings: AdsRenderingSettings(enablePreloading: true));
- },
- onAdsLoadError: (AdsLoadErrorData data) {
- debugPrint('OnAdsLoadError: ${data.error.message}');
- _resumeContent();
- },
- );
-
- // Ads can't be requested until the `AdDisplayContainer` has been added to
- // the native View hierarchy.
- _requestAds(container);
- },
- );
-
- @override
- void initState() {
- super.initState();
- // Adds this instance as an observer for `AppLifecycleState` changes.
- WidgetsBinding.instance.addObserver(this);
-
- _contentVideoController =
- VideoPlayerController.networkUrl(
- Uri.parse(
- 'https://storage.googleapis.com/gvabox/media/samples/stock.mp4',
- ),
- )
- ..addListener(() {
- if (_contentVideoController.value.isCompleted) {
- _adsLoader.contentComplete();
- }
- setState(() {});
- })
- ..initialize().then((_) {
- // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed.
- setState(() {});
- });
- }
-
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- switch (state) {
- case AppLifecycleState.resumed:
- if (!_shouldShowContentVideo) {
- _adsManager?.resume();
- }
- case AppLifecycleState.inactive:
- // Pausing the Ad video player on Android can only be done in this state
- // because it corresponds to `Activity.onPause`. This state is also
- // triggered before resume, so this will only pause the Ad if the app is
- // in the process of being sent to the background.
- if (!_shouldShowContentVideo &&
- _lastLifecycleState == AppLifecycleState.resumed) {
- _adsManager?.pause();
- }
- case AppLifecycleState.hidden:
- case AppLifecycleState.paused:
- case AppLifecycleState.detached:
- }
- _lastLifecycleState = state;
- }
-
- Future<void> _requestAds(AdDisplayContainer container) {
- return _adsLoader.requestAds(
- AdsRequest(
- adTagUrl: _adTagUrl,
- contentProgressProvider: _contentProgressProvider,
+ void _pushVideoAdExampleWithAdTagUrl({
+ required String adType,
+ required String adTagUrl,
+ }) {
+ Navigator.of(context).push<void>(
+ MaterialPageRoute<void>(
+ builder:
+ (_) => VideoAdExampleScreen(adType: adType, adTagUrl: adTagUrl),
),
);
}
- Future<void> _resumeContent() async {
- setState(() {
- _shouldShowContentVideo = true;
- });
-
- if (_adsManager != null) {
- _contentProgressTimer = Timer.periodic(
- const Duration(milliseconds: 200),
- (Timer timer) async {
- if (_contentVideoController.value.isInitialized) {
- final Duration? progress = await _contentVideoController.position;
- if (progress != null) {
- await _contentProgressProvider.setProgress(
- progress: progress,
- duration: _contentVideoController.value.duration,
- );
- }
- }
- },
- );
- }
-
- await _contentVideoController.play();
- }
-
- Future<void> _pauseContent() {
- setState(() {
- _shouldShowContentVideo = false;
- });
- _contentProgressTimer?.cancel();
- _contentProgressTimer = null;
- return _contentVideoController.pause();
- }
-
- @override
- void dispose() {
- super.dispose();
- _contentProgressTimer?.cancel();
- _contentVideoController.dispose();
- _adsManager?.destroy();
- WidgetsBinding.instance.removeObserver(this);
- }
-
@override
Widget build(BuildContext context) {
return Scaffold(
+ appBar: AppBar(
+ title: const Text('IMA Test App'),
+ backgroundColor: Colors.blue,
+ ),
body: Center(
- child: Column(
- spacing: 100,
- mainAxisAlignment: MainAxisAlignment.center,
- children: <Widget>[
- SizedBox(
- width: 300,
- child:
- !_contentVideoController.value.isInitialized
- ? Container()
- : AspectRatio(
- aspectRatio: _contentVideoController.value.aspectRatio,
- child: Stack(
- children: <Widget>[
- // The display container must be on screen before any Ads can be
- // loaded and can't be removed between ads. This handles clicks for
- // ads.
- _adDisplayContainer,
- if (_shouldShowContentVideo)
- VideoPlayer(_contentVideoController),
- ],
- ),
- ),
- ),
- ColoredBox(
- color: Colors.green,
- child: SizedBox(
- width: 300,
- height: 250,
- child: companionAd.buildWidget(context),
- ),
- ),
- ],
+ child: ListView.separated(
+ padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 60),
+ itemCount: _testAdTagUrls.length,
+ separatorBuilder: (_, _) => const SizedBox(height: 50),
+ itemBuilder: (_, int index) {
+ final (String adType, String adTagUrl) = _testAdTagUrls[index];
+ return ElevatedButton(
+ onPressed:
+ () => _pushVideoAdExampleWithAdTagUrl(
+ adType: adType,
+ adTagUrl: adTagUrl,
+ ),
+ child: Text(adType),
+ );
+ },
),
),
- floatingActionButton:
- _contentVideoController.value.isInitialized && _shouldShowContentVideo
- ? FloatingActionButton(
- onPressed: () {
- setState(() {
- _contentVideoController.value.isPlaying
- ? _contentVideoController.pause()
- : _contentVideoController.play();
- });
- },
- child: Icon(
- _contentVideoController.value.isPlaying
- ? Icons.pause
- : Icons.play_arrow,
- ),
- )
- : null,
);
}
}
diff --git a/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart b/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart
new file mode 100644
index 0000000..99a4ad1
--- /dev/null
+++ b/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart
@@ -0,0 +1,288 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:interactive_media_ads/interactive_media_ads.dart';
+import 'package:video_player/video_player.dart';
+
+/// Example widget displaying an Ad before a video.
+class VideoAdExampleScreen extends StatefulWidget {
+ /// Constructs an [VideoAdExampleScreen].
+ const VideoAdExampleScreen({
+ super.key,
+ required this.adType,
+ required this.adTagUrl,
+ this.enablePreloading = true,
+ });
+
+ /// The URL from which ads will be requested.
+ final String adTagUrl;
+
+ /// Allows the player to preload the ad at any point before
+ /// [AdsManager.start].
+ final bool enablePreloading;
+
+ /// The type of ads that will be requested.
+ final String adType;
+
+ @override
+ State<VideoAdExampleScreen> createState() => _VideoAdExampleScreenState();
+}
+
+class _VideoAdExampleScreenState extends State<VideoAdExampleScreen>
+ with WidgetsBindingObserver {
+ // The AdsLoader instance exposes the request ads method.
+ late final AdsLoader _adsLoader;
+
+ // AdsManager exposes methods to control ad playback and listen to ad events.
+ AdsManager? _adsManager;
+
+ // Last state received in `didChangeAppLifecycleState`.
+ AppLifecycleState _lastLifecycleState = AppLifecycleState.resumed;
+
+ // Whether the widget should be displaying the content video. The content
+ // player is hidden while Ads are playing.
+ bool _shouldShowContentVideo = false;
+
+ // Controls the content video player.
+ late final VideoPlayerController _contentVideoController;
+
+ // Periodically updates the SDK of the current playback progress of the
+ // content video.
+ Timer? _contentProgressTimer;
+
+ // Provides the SDK with the current playback progress of the content video.
+ // This is required to support mid-roll ads.
+ final ContentProgressProvider _contentProgressProvider =
+ ContentProgressProvider();
+
+ late final CompanionAdSlot companionAd = CompanionAdSlot(
+ size: CompanionAdSlotSize.fixed(width: 300, height: 250),
+ onClicked: () => debugPrint('Companion Ad Clicked'),
+ );
+
+ late final AdDisplayContainer _adDisplayContainer = AdDisplayContainer(
+ companionSlots: <CompanionAdSlot>[companionAd],
+ onContainerAdded: (AdDisplayContainer container) {
+ _adsLoader = AdsLoader(
+ container: container,
+ onAdsLoaded: (OnAdsLoadedData data) {
+ final AdsManager manager = data.manager;
+ _adsManager = data.manager;
+
+ manager.setAdsManagerDelegate(
+ AdsManagerDelegate(
+ onAdEvent: (AdEvent event) {
+ debugPrint('OnAdEvent: ${event.type} => ${event.adData}');
+ switch (event.type) {
+ case AdEventType.loaded:
+ manager.start();
+ case AdEventType.contentPauseRequested:
+ _pauseContent();
+ case AdEventType.contentResumeRequested:
+ _resumeContent();
+ case AdEventType.allAdsCompleted:
+ manager.destroy();
+ _adsManager = null;
+ case AdEventType.clicked:
+ case AdEventType.complete:
+ case _:
+ }
+ },
+ onAdErrorEvent: (AdErrorEvent event) {
+ debugPrint('AdErrorEvent: ${event.error.message}');
+ _resumeContent();
+ },
+ ),
+ );
+
+ manager.init(
+ settings: AdsRenderingSettings(
+ enablePreloading: widget.enablePreloading,
+ ),
+ );
+ },
+ onAdsLoadError: (AdsLoadErrorData data) {
+ debugPrint('OnAdsLoadError: ${data.error.message}');
+ _resumeContent();
+ },
+ );
+
+ // Ads can't be requested until the `AdDisplayContainer` has been added to
+ // the native View hierarchy.
+ _requestAds(container);
+ },
+ );
+
+ @override
+ void initState() {
+ super.initState();
+ // Adds this instance as an observer for `AppLifecycleState` changes.
+ WidgetsBinding.instance.addObserver(this);
+
+ _contentVideoController =
+ VideoPlayerController.networkUrl(
+ Uri.parse(
+ 'https://storage.googleapis.com/gvabox/media/samples/stock.mp4',
+ ),
+ )
+ ..addListener(() {
+ if (_contentVideoController.value.isCompleted) {
+ _adsLoader.contentComplete();
+ setState(() {});
+ }
+ })
+ ..initialize().then((_) {
+ // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed.
+ setState(() {});
+ });
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ switch (state) {
+ case AppLifecycleState.resumed:
+ if (!_shouldShowContentVideo) {
+ _adsManager?.resume();
+ }
+ case AppLifecycleState.inactive:
+ // Pausing the Ad video player on Android can only be done in this state
+ // because it corresponds to `Activity.onPause`. This state is also
+ // triggered before resume, so this will only pause the Ad if the app is
+ // in the process of being sent to the background.
+ if (!_shouldShowContentVideo &&
+ _lastLifecycleState == AppLifecycleState.resumed) {
+ _adsManager?.pause();
+ }
+ case AppLifecycleState.hidden:
+ case AppLifecycleState.paused:
+ case AppLifecycleState.detached:
+ }
+ _lastLifecycleState = state;
+ }
+
+ Future<void> _requestAds(AdDisplayContainer container) {
+ return _adsLoader.requestAds(
+ AdsRequest(
+ adTagUrl: widget.adTagUrl,
+ contentProgressProvider: _contentProgressProvider,
+ ),
+ );
+ }
+
+ Future<void> _resumeContent() async {
+ if (!mounted) {
+ return;
+ }
+
+ setState(() {
+ _shouldShowContentVideo = true;
+ });
+
+ if (_adsManager != null) {
+ _contentProgressTimer = Timer.periodic(
+ const Duration(milliseconds: 200),
+ (Timer timer) async {
+ if (_contentVideoController.value.isInitialized) {
+ final Duration? progress = await _contentVideoController.position;
+ if (progress != null) {
+ await _contentProgressProvider.setProgress(
+ progress: progress,
+ duration: _contentVideoController.value.duration,
+ );
+ }
+ }
+ },
+ );
+ }
+
+ await _contentVideoController.play();
+ }
+
+ Future<void> _pauseContent() {
+ setState(() {
+ _shouldShowContentVideo = false;
+ });
+ _contentProgressTimer?.cancel();
+ _contentProgressTimer = null;
+ return _contentVideoController.pause();
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
+ _contentProgressTimer?.cancel();
+ _contentVideoController.dispose();
+ _adsManager?.destroy();
+ WidgetsBinding.instance.removeObserver(this);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('IMA Test App'),
+ backgroundColor: Colors.blue,
+ ),
+ body: Center(
+ child: Column(
+ spacing: 80,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ Text(
+ widget.adType,
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ SizedBox(
+ width: 300,
+ child:
+ !_contentVideoController.value.isInitialized
+ ? Container()
+ : AspectRatio(
+ aspectRatio: _contentVideoController.value.aspectRatio,
+ child: Stack(
+ children: <Widget>[
+ // The display container must be on screen before any Ads can be
+ // loaded and can't be removed between ads. This handles clicks for
+ // ads.
+ _adDisplayContainer,
+ if (_shouldShowContentVideo)
+ VideoPlayer(_contentVideoController),
+ ],
+ ),
+ ),
+ ),
+ ColoredBox(
+ color: Colors.green,
+ child: SizedBox(
+ width: 300,
+ height: 250,
+ child: companionAd.buildWidget(context),
+ ),
+ ),
+ ],
+ ),
+ ),
+ floatingActionButton:
+ _contentVideoController.value.isInitialized && _shouldShowContentVideo
+ ? FloatingActionButton(
+ onPressed: () {
+ setState(() {
+ _contentVideoController.value.isPlaying
+ ? _contentVideoController.pause()
+ : _contentVideoController.play();
+ });
+ },
+ child: Icon(
+ _contentVideoController.value.isPlaying
+ ? Icons.pause
+ : Icons.play_arrow,
+ ),
+ )
+ : null,
+ );
+ }
+}
diff --git a/packages/interactive_media_ads/ios/interactive_media_ads/Sources/interactive_media_ads/AdsRequestProxyAPIDelegate.swift b/packages/interactive_media_ads/ios/interactive_media_ads/Sources/interactive_media_ads/AdsRequestProxyAPIDelegate.swift
index 360498b..802c385 100644
--- a/packages/interactive_media_ads/ios/interactive_media_ads/Sources/interactive_media_ads/AdsRequestProxyAPIDelegate.swift
+++ b/packages/interactive_media_ads/ios/interactive_media_ads/Sources/interactive_media_ads/AdsRequestProxyAPIDelegate.swift
@@ -13,7 +13,7 @@
/// The current version of the `interactive_media_ads` plugin.
///
/// This must match the version in pubspec.yaml.
- static let pluginVersion = "0.2.6+6"
+ static let pluginVersion = "0.2.6+7"
func pigeonDefaultConstructor(
pigeonApi: PigeonApiIMAAdsRequest, adTagUrl: String, adDisplayContainer: IMAAdDisplayContainer,
diff --git a/packages/interactive_media_ads/lib/src/android/android_ad_display_container.dart b/packages/interactive_media_ads/lib/src/android/android_ad_display_container.dart
index ced3776..377f260 100644
--- a/packages/interactive_media_ads/lib/src/android/android_ad_display_container.dart
+++ b/packages/interactive_media_ads/lib/src/android/android_ad_display_container.dart
@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
+import 'dart:collection';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
@@ -110,8 +111,9 @@
@internal
ima.AdDisplayContainer? adDisplayContainer;
- // Currently loaded ad.
- ima.AdMediaInfo? _loadedAdMediaInfo;
+ // Queue of ads to be played.
+ final Queue<ima.AdMediaInfo> _loadedAdMediaInfoQueue =
+ Queue<ima.AdMediaInfo>();
// The saved ad position, used to resume ad playback following an ad
// click-through.
@@ -137,30 +139,7 @@
@override
Widget build(BuildContext context) {
- return AndroidViewWidget(
- key: params.key,
- view: _frameLayout,
- platformViewsServiceProxy: _androidParams._platformViewsProxy,
- layoutDirection: params.layoutDirection,
- onPlatformViewCreated: () async {
- adDisplayContainer = await _androidParams._imaProxy
- .createAdDisplayContainerImaSdkFactory(
- _frameLayout,
- _videoAdPlayer,
- );
- final Iterable<ima.CompanionAdSlot> nativeCompanionSlots =
- await Future.wait(
- _androidParams.companionSlots.map((PlatformCompanionAdSlot slot) {
- return (slot as AndroidCompanionAdSlot)
- .getNativeCompanionAdSlot();
- }),
- );
- await adDisplayContainer!.setCompanionSlots(
- nativeCompanionSlots.toList(),
- );
- params.onContainerAdded(this);
- },
- );
+ return _AdPlayer(this);
}
// Clears the current `MediaPlayer` and resets any saved position of an ad.
@@ -171,11 +150,21 @@
_savedAdPosition = 0;
}
- // Clear state to before ad is loaded and replace current VideoView with a new
- // one.
- void _resetState() {
+ // Resets the state to before an ad is loaded and releases references to all
+ // ads and allbacks.
+ void _release() {
+ _resetStateForNextAd();
+ _loadedAdMediaInfoQueue.clear();
+ videoAdPlayerCallbacks.clear();
+ }
+
+ // Clears the state to before ad is loaded and replace current VideoView with
+ // a new one.
+ void _resetStateForNextAd() {
_stopAdProgressTracking();
+ // The `VideoView` is replaced to clear the last frame of the last loaded
+ // ad. See https://stackoverflow.com/questions/25660994/clear-video-frame-from-surfaceview-on-video-complete.
_frameLayout.removeView(_videoView);
_videoView = _setUpVideoView(
WeakReference<AndroidAdDisplayContainer>(this),
@@ -183,9 +172,10 @@
_frameLayout.addView(_videoView);
_clearMediaPlayer();
- _loadedAdMediaInfo = null;
+ if (_loadedAdMediaInfoQueue.isNotEmpty) {
+ _loadedAdMediaInfoQueue.removeFirst();
+ }
_adDuration = null;
- _startPlayerWhenVideoIsPrepared = true;
}
// Starts periodically updating the IMA SDK the progress of the currently
@@ -211,7 +201,8 @@
await Future.wait(<Future<void>>[
_videoAdPlayer.setAdProgress(currentProgress),
- if (_loadedAdMediaInfo case final ima.AdMediaInfo loadedAdMediaInfo)
+ if (_loadedAdMediaInfoQueue.firstOrNull
+ case final ima.AdMediaInfo loadedAdMediaInfo)
...videoAdPlayerCallbacks.map(
(ima.VideoAdPlayerCallback callback) =>
callback.onAdProgress(loadedAdMediaInfo, currentProgress),
@@ -228,6 +219,17 @@
_adProgressTimer = null;
}
+ /// Load the first ad in the queue.
+ Future<void> _loadCurrentAd() {
+ _startPlayerWhenVideoIsPrepared = false;
+ return Future.wait(<Future<void>>[
+ // Audio focus is set to none to prevent the `VideoView` from requesting
+ // focus while loading the app in the background.
+ _videoView.setAudioFocusRequest(ima.AudioManagerAudioFocus.none),
+ _videoView.setVideoUri(_loadedAdMediaInfoQueue.first.url),
+ ]);
+ }
+
// This value is created in a static method because the callback methods for
// any wrapped classes must not reference the encapsulating object. This is to
// prevent a circular reference that prevents garbage collection.
@@ -242,15 +244,15 @@
container._stopAdProgressTracking();
for (final ima.VideoAdPlayerCallback callback
in container.videoAdPlayerCallbacks) {
- callback.onEnded(container._loadedAdMediaInfo!);
+ callback.onEnded(container._loadedAdMediaInfoQueue.first);
}
}
},
onPrepared: (_, ima.MediaPlayer player) async {
final AndroidAdDisplayContainer? container = weakThis.target;
if (container != null) {
- container._adDuration = await player.getDuration();
container._mediaPlayer = player;
+ container._adDuration = await player.getDuration();
if (container._savedAdPosition > 0) {
await player.seekTo(container._savedAdPosition);
}
@@ -267,10 +269,8 @@
container._clearMediaPlayer();
for (final ima.VideoAdPlayerCallback callback
in container.videoAdPlayerCallbacks) {
- callback.onError(container._loadedAdMediaInfo!);
+ callback.onError(container._loadedAdMediaInfoQueue.first);
}
- container._loadedAdMediaInfo = null;
- container._adDuration = null;
}
},
);
@@ -290,7 +290,13 @@
weakThis.target?.videoAdPlayerCallbacks.remove(callback);
},
loadAd: (_, ima.AdMediaInfo adMediaInfo, __) {
- weakThis.target?._loadedAdMediaInfo = adMediaInfo;
+ final AndroidAdDisplayContainer? container = weakThis.target;
+ if (container != null) {
+ container._loadedAdMediaInfoQueue.add(adMediaInfo);
+ if (container._loadedAdMediaInfoQueue.length == 1) {
+ container._loadCurrentAd();
+ }
+ }
},
pauseAd: (_, __) async {
final AndroidAdDisplayContainer? container = weakThis.target;
@@ -303,17 +309,126 @@
container._savedAdPosition =
await container._videoView.getCurrentPosition();
container._stopAdProgressTracking();
+ await Future.wait(<Future<void>>[
+ for (final ima.VideoAdPlayerCallback callback
+ in container.videoAdPlayerCallbacks)
+ callback.onPause(container._loadedAdMediaInfoQueue.first),
+ ]);
}
},
playAd: (_, ima.AdMediaInfo adMediaInfo) {
final AndroidAdDisplayContainer? container = weakThis.target;
if (container != null) {
+ assert(container._loadedAdMediaInfoQueue.first == adMediaInfo);
+
+ container._videoView.setAudioFocusRequest(
+ ima.AudioManagerAudioFocus.gain,
+ );
+
+ if (container._mediaPlayer != null) {
+ container._mediaPlayer!.start().then(
+ (_) => container._startAdProgressTracking(),
+ );
+ }
container._startPlayerWhenVideoIsPrepared = true;
- container._videoView.setVideoUri(adMediaInfo.url);
+
+ for (final ima.VideoAdPlayerCallback callback
+ in container.videoAdPlayerCallbacks) {
+ if (container._savedAdPosition == 0) {
+ callback.onPlay(adMediaInfo);
+ } else {
+ callback.onResume(adMediaInfo);
+ }
+ }
}
},
- release: (_) => weakThis.target?._resetState(),
- stopAd: (_, __) => weakThis.target?._resetState(),
+ release: (_) => weakThis.target?._release(),
+ stopAd: (_, __) {
+ final AndroidAdDisplayContainer? container = weakThis.target;
+ if (container != null) {
+ container._resetStateForNextAd();
+ if (container._loadedAdMediaInfoQueue.isNotEmpty) {
+ container._loadCurrentAd();
+ }
+ }
+ },
+ );
+ }
+}
+
+// Widget for displaying the native ViewGroup of the AdDisplayContainer.
+//
+// When the app is sent to the background, the state of the underlying native
+// `VideoView` is not maintained. So this widget uses `WidgetsBindingObserver`
+// to listen and react to lifecycle events.
+class _AdPlayer extends StatefulWidget {
+ _AdPlayer(this.container) : super(key: container._androidParams.key);
+
+ final AndroidAdDisplayContainer container;
+
+ @override
+ State<StatefulWidget> createState() => _AdPlayerState();
+}
+
+class _AdPlayerState extends State<_AdPlayer> with WidgetsBindingObserver {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
+ WidgetsBinding.instance.removeObserver(this);
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ final AndroidAdDisplayContainer container = widget.container;
+ switch (state) {
+ case AppLifecycleState.resumed:
+ if (container._loadedAdMediaInfoQueue.isNotEmpty) {
+ container._loadCurrentAd();
+ }
+ case AppLifecycleState.paused:
+ container._mediaPlayer = null;
+ case AppLifecycleState.inactive:
+ case AppLifecycleState.hidden:
+ case AppLifecycleState.detached:
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AndroidViewWidget(
+ view: widget.container._frameLayout,
+ platformViewsServiceProxy:
+ widget.container._androidParams._platformViewsProxy,
+ layoutDirection: widget.container._androidParams.layoutDirection,
+ onPlatformViewCreated: () async {
+ final ima.AdDisplayContainer nativeContainer = await widget
+ .container
+ ._androidParams
+ ._imaProxy
+ .createAdDisplayContainerImaSdkFactory(
+ widget.container._frameLayout,
+ widget.container._videoAdPlayer,
+ );
+ final Iterable<ima.CompanionAdSlot> nativeCompanionSlots =
+ await Future.wait(
+ widget.container._androidParams.companionSlots.map((
+ PlatformCompanionAdSlot slot,
+ ) {
+ return (slot as AndroidCompanionAdSlot)
+ .getNativeCompanionAdSlot();
+ }),
+ );
+ await nativeContainer.setCompanionSlots(nativeCompanionSlots.toList());
+
+ widget.container.adDisplayContainer = nativeContainer;
+ widget.container.params.onContainerAdded(widget.container);
+ },
);
}
}
diff --git a/packages/interactive_media_ads/lib/src/android/interactive_media_ads.g.dart b/packages/interactive_media_ads/lib/src/android/interactive_media_ads.g.dart
index af24800..340fbc7 100644
--- a/packages/interactive_media_ads/lib/src/android/interactive_media_ads.g.dart
+++ b/packages/interactive_media_ads/lib/src/android/interactive_media_ads.g.dart
@@ -66,7 +66,8 @@
int extra,
)
onError,
- void Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared,
+ Future<void> Function(VideoView pigeon_instance, MediaPlayer player)?
+ onPrepared,
void Function(VideoView pigeon_instance, MediaPlayer player)? onCompletion,
})?
videoView_new;
@@ -852,6 +853,36 @@
unknown,
}
+/// Used to indicate the type of audio focus for a view.
+///
+/// See https://developer.android.com/reference/android/media/AudioManager#AUDIOFOCUS_GAIN.
+enum AudioManagerAudioFocus {
+ /// Used to indicate a gain of audio focus, or a request of audio focus,
+ /// of unknown duration.
+ gain,
+
+ /// Used to indicate a temporary gain or request of audio focus, anticipated
+ /// to last a short amount of time.
+ ///
+ /// Examples of temporary changes are the playback of driving directions, or
+ /// an event notification.
+ gainTransient,
+
+ /// Used to indicate a temporary request of audio focus, anticipated to last a
+ /// short amount of time, during which no other applications, or system
+ /// components, should play anything.
+ gainTransientExclusive,
+
+ /// Used to indicate a temporary request of audio focus, anticipated to last a
+ /// short amount of time, and where it is acceptable for other audio
+ /// applications to keep playing after having lowered their output level (also
+ /// referred to as "ducking").
+ gainTransientMayDuck,
+
+ /// Used to indicate no audio focus has been gained or lost, or requested.
+ none,
+}
+
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -871,6 +902,9 @@
} else if (value is UiElement) {
buffer.putUint8(132);
writeValue(buffer, value.index);
+ } else if (value is AudioManagerAudioFocus) {
+ buffer.putUint8(133);
+ writeValue(buffer, value.index);
} else {
super.writeValue(buffer, value);
}
@@ -891,6 +925,9 @@
case 132:
final int? value = readValue(buffer) as int?;
return value == null ? null : UiElement.values[value];
+ case 133:
+ final int? value = readValue(buffer) as int?;
+ return value == null ? null : AudioManagerAudioFocus.values[value];
default:
return super.readValueOfType(type, buffer);
}
@@ -4416,7 +4453,8 @@
factory VideoView({
BinaryMessenger? pigeon_binaryMessenger,
PigeonInstanceManager? pigeon_instanceManager,
- void Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared,
+ Future<void> Function(VideoView pigeon_instance, MediaPlayer player)?
+ onPrepared,
void Function(VideoView pigeon_instance, MediaPlayer player)? onCompletion,
required void Function(
VideoView pigeon_instance,
@@ -4518,7 +4556,7 @@
///
/// Alternatively, [PigeonInstanceManager.removeWeakReference] can be used to
/// release the associated Native object manually.
- final void Function(VideoView pigeon_instance, MediaPlayer player)?
+ final Future<void> Function(VideoView pigeon_instance, MediaPlayer player)?
onPrepared;
/// Callback to be invoked when playback of a media source has completed.
@@ -4575,7 +4613,8 @@
bool pigeon_clearHandlers = false,
BinaryMessenger? pigeon_binaryMessenger,
PigeonInstanceManager? pigeon_instanceManager,
- void Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared,
+ Future<void> Function(VideoView pigeon_instance, MediaPlayer player)?
+ onPrepared,
void Function(VideoView pigeon_instance, MediaPlayer player)? onCompletion,
void Function(
VideoView pigeon_instance,
@@ -4617,7 +4656,7 @@
'Argument for dev.flutter.pigeon.interactive_media_ads.VideoView.onPrepared was null, expected non-null MediaPlayer.',
);
try {
- (onPrepared ?? arg_pigeon_instance!.onPrepared)?.call(
+ await (onPrepared ?? arg_pigeon_instance!.onPrepared)?.call(
arg_pigeon_instance!,
arg_player!,
);
@@ -4801,6 +4840,40 @@
}
}
+ /// Sets which type of audio focus will be requested during the playback, or
+ /// configures playback to not request audio focus.
+ ///
+ /// Only available on Android API 26+. Noop on lower versions.
+ Future<void> setAudioFocusRequest(AudioManagerAudioFocus focusGain) async {
+ final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec =
+ _pigeonVar_codecVideoView;
+ final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger;
+ const String pigeonVar_channelName =
+ 'dev.flutter.pigeon.interactive_media_ads.VideoView.setAudioFocusRequest';
+ final BasicMessageChannel<Object?> pigeonVar_channel =
+ BasicMessageChannel<Object?>(
+ pigeonVar_channelName,
+ pigeonChannelCodec,
+ binaryMessenger: pigeonVar_binaryMessenger,
+ );
+ final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
+ <Object?>[this, focusGain],
+ );
+ final List<Object?>? pigeonVar_replyList =
+ await pigeonVar_sendFuture as List<Object?>?;
+ if (pigeonVar_replyList == null) {
+ throw _createConnectionError(pigeonVar_channelName);
+ } else if (pigeonVar_replyList.length > 1) {
+ throw PlatformException(
+ code: pigeonVar_replyList[0]! as String,
+ message: pigeonVar_replyList[1] as String?,
+ details: pigeonVar_replyList[2],
+ );
+ } else {
+ return;
+ }
+ }
+
@override
VideoView pigeon_copy() {
return VideoView.pigeon_detached(
diff --git a/packages/interactive_media_ads/lib/src/android/interactive_media_ads_proxy.dart b/packages/interactive_media_ads/lib/src/android/interactive_media_ads_proxy.dart
index 5ba4b85..3cf99bc 100644
--- a/packages/interactive_media_ads/lib/src/android/interactive_media_ads_proxy.dart
+++ b/packages/interactive_media_ads/lib/src/android/interactive_media_ads_proxy.dart
@@ -48,7 +48,7 @@
/// Constructs [VideoView].
final VideoView Function({
required void Function(VideoView, MediaPlayer, int, int) onError,
- void Function(VideoView, MediaPlayer)? onPrepared,
+ Future<void> Function(VideoView, MediaPlayer)? onPrepared,
void Function(VideoView, MediaPlayer)? onCompletion,
})
newVideoView;
diff --git a/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart b/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart
index ec4244b..0671728 100644
--- a/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart
+++ b/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart
@@ -220,6 +220,36 @@
unknown,
}
+/// Used to indicate the type of audio focus for a view.
+///
+/// See https://developer.android.com/reference/android/media/AudioManager#AUDIOFOCUS_GAIN.
+enum AudioManagerAudioFocus {
+ /// Used to indicate a gain of audio focus, or a request of audio focus,
+ /// of unknown duration.
+ gain,
+
+ /// Used to indicate a temporary gain or request of audio focus, anticipated
+ /// to last a short amount of time.
+ ///
+ /// Examples of temporary changes are the playback of driving directions, or
+ /// an event notification.
+ gainTransient,
+
+ /// Used to indicate a temporary request of audio focus, anticipated to last a
+ /// short amount of time, during which no other applications, or system
+ /// components, should play anything.
+ gainTransientExclusive,
+
+ /// Used to indicate a temporary request of audio focus, anticipated to last a
+ /// short amount of time, and where it is acceptable for other audio
+ /// applications to keep playing after having lowered their output level (also
+ /// referred to as "ducking").
+ gainTransientMayDuck,
+
+ /// Used to indicate no audio focus has been gained or lost, or requested.
+ none,
+}
+
/// A base class for more specialized container interfaces.
///
/// See https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/api/reference/com/google/ads/interactivemedia/v3/api/BaseDisplayContainer.html.
@@ -708,6 +738,7 @@
VideoView();
/// Callback to be invoked when the media source is ready for playback.
+ @async
late final void Function(MediaPlayer player)? onPrepared;
/// Callback to be invoked when playback of a media source has completed.
@@ -724,6 +755,12 @@
///
/// In milliseconds.
int getCurrentPosition();
+
+ /// Sets which type of audio focus will be requested during the playback, or
+ /// configures playback to not request audio focus.
+ ///
+ /// Only available on Android API 26+. Noop on lower versions.
+ void setAudioFocusRequest(AudioManagerAudioFocus focusGain);
}
/// This class represents the basic building block for user interface components.
diff --git a/packages/interactive_media_ads/pubspec.yaml b/packages/interactive_media_ads/pubspec.yaml
index aed3c30..17bb2c2 100644
--- a/packages/interactive_media_ads/pubspec.yaml
+++ b/packages/interactive_media_ads/pubspec.yaml
@@ -2,7 +2,7 @@
description: A Flutter plugin for using the Interactive Media Ads SDKs on Android and iOS.
repository: https://github.com/flutter/packages/tree/main/packages/interactive_media_ads
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+interactive_media_ads%22
-version: 0.2.6+6 # This must match the version in
+version: 0.2.6+7 # This must match the version in
# `android/src/main/kotlin/dev/flutter/packages/interactive_media_ads/AdsRequestProxyApi.kt` and
# `ios/interactive_media_ads/Sources/interactive_media_ads/AdsRequestProxyAPIDelegate.swift`
diff --git a/packages/interactive_media_ads/test/android/ad_display_container_test.dart b/packages/interactive_media_ads/test/android/ad_display_container_test.dart
index 58a8071..4764936 100644
--- a/packages/interactive_media_ads/test/android/ad_display_container_test.dart
+++ b/packages/interactive_media_ads/test/android/ad_display_container_test.dart
@@ -272,6 +272,9 @@
late final Future<void> Function(ima.VideoView, ima.MediaPlayer)
onPreparedCallback;
+ late final void Function(ima.VideoAdPlayer, ima.AdMediaInfo)
+ playAdCallback;
+
const int adDuration = 100;
const int adProgress = 10;
@@ -279,14 +282,10 @@
newFrameLayout: () => MockFrameLayout(),
newVideoView: ({
dynamic onError,
- dynamic onPrepared,
+ Future<void> Function(ima.VideoView, ima.MediaPlayer)? onPrepared,
dynamic onCompletion,
}) {
- // VideoView.onPrepared returns void, but the implementation uses an
- // async callback method.
- onPreparedCallback =
- onPrepared!
- as Future<void> Function(ima.VideoView, ima.MediaPlayer);
+ onPreparedCallback = onPrepared!;
final MockVideoView mockVideoView = MockVideoView();
when(
mockVideoView.getCurrentPosition(),
@@ -306,13 +305,14 @@
)
loadAd,
required dynamic pauseAd,
- required dynamic playAd,
+ required void Function(ima.VideoAdPlayer, ima.AdMediaInfo) playAd,
required dynamic release,
required dynamic removeCallback,
required dynamic stopAd,
}) {
loadAdCallback = loadAd;
addCallbackCallback = addCallback;
+ playAdCallback = playAd;
return MockVideoAdPlayer();
},
newVideoProgressUpdate: ({
@@ -334,6 +334,7 @@
final ima.AdMediaInfo mockAdMediaInfo = MockAdMediaInfo();
loadAdCallback(MockVideoAdPlayer(), mockAdMediaInfo, MockAdPodInfo());
+ playAdCallback(MockVideoAdPlayer(), mockAdMediaInfo);
final MockVideoAdPlayerCallback mockPlayerCallback =
MockVideoAdPlayerCallback();
@@ -619,6 +620,13 @@
});
test('play ad', () async {
+ late final void Function(
+ ima.VideoAdPlayer,
+ ima.AdMediaInfo,
+ ima.AdPodInfo,
+ )
+ loadAdCallback;
+
late final void Function(ima.VideoAdPlayer, ima.AdMediaInfo)
playAdCallback;
@@ -637,13 +645,19 @@
},
newVideoAdPlayer: ({
required dynamic addCallback,
- required dynamic loadAd,
+ required void Function(
+ ima.VideoAdPlayer,
+ ima.AdMediaInfo,
+ ima.AdPodInfo,
+ )
+ loadAd,
required dynamic pauseAd,
required void Function(ima.VideoAdPlayer, ima.AdMediaInfo) playAd,
required dynamic release,
required dynamic removeCallback,
required dynamic stopAd,
}) {
+ loadAdCallback = loadAd;
playAdCallback = playAd;
return MockVideoAdPlayer();
},
@@ -659,6 +673,7 @@
const String videoUrl = 'url';
final ima.AdMediaInfo mockAdMediaInfo = MockAdMediaInfo();
when(mockAdMediaInfo.url).thenReturn(videoUrl);
+ loadAdCallback(MockVideoAdPlayer(), mockAdMediaInfo, MockAdPodInfo());
playAdCallback(MockVideoAdPlayer(), mockAdMediaInfo);
verify(mockVideoView.setVideoUri(videoUrl));
@@ -695,13 +710,19 @@
},
newVideoAdPlayer: ({
required dynamic addCallback,
- required dynamic loadAd,
+ required void Function(
+ ima.VideoAdPlayer,
+ ima.AdMediaInfo,
+ ima.AdPodInfo,
+ )
+ loadAd,
required dynamic pauseAd,
required dynamic playAd,
required dynamic release,
required dynamic removeCallback,
required void Function(ima.VideoAdPlayer, ima.AdMediaInfo) stopAd,
}) {
+ loadAd(MockVideoAdPlayer(), MockAdMediaInfo(), MockAdPodInfo());
stopAdCallback = stopAd;
return MockVideoAdPlayer();
},
@@ -750,13 +771,19 @@
},
newVideoAdPlayer: ({
required dynamic addCallback,
- required dynamic loadAd,
+ required void Function(
+ ima.VideoAdPlayer,
+ ima.AdMediaInfo,
+ ima.AdPodInfo,
+ )
+ loadAd,
required dynamic pauseAd,
required dynamic playAd,
required void Function(ima.VideoAdPlayer) release,
required dynamic removeCallback,
required dynamic stopAd,
}) {
+ loadAd(MockVideoAdPlayer(), MockAdMediaInfo(), MockAdPodInfo());
releaseCallback = release;
return MockVideoAdPlayer();
},
@@ -873,5 +900,86 @@
]),
);
});
+
+ test('AdDisplayContainer handles preloaded ads', () async {
+ late void Function(ima.VideoView, ima.MediaPlayer) onCompletionCallback;
+
+ late final void Function(ima.VideoAdPlayer, ima.VideoAdPlayerCallback)
+ addCallbackCallback;
+ late final void Function(
+ ima.VideoAdPlayer,
+ ima.AdMediaInfo,
+ ima.AdPodInfo,
+ )
+ loadAdCallback;
+ late final void Function(ima.VideoAdPlayer, ima.AdMediaInfo)
+ stopAdCallback;
+
+ final MockVideoView mockVideoView = MockVideoView();
+ final InteractiveMediaAdsProxy imaProxy = InteractiveMediaAdsProxy(
+ newFrameLayout: () => MockFrameLayout(),
+ newVideoView: ({
+ dynamic onError,
+ dynamic onPrepared,
+ void Function(ima.VideoView, ima.MediaPlayer)? onCompletion,
+ }) {
+ onCompletionCallback = onCompletion!;
+ return mockVideoView;
+ },
+ createAdDisplayContainerImaSdkFactory: (_, __) async {
+ return MockAdDisplayContainer();
+ },
+ newVideoAdPlayer: ({
+ required void Function(ima.VideoAdPlayer, ima.VideoAdPlayerCallback)
+ addCallback,
+ required void Function(
+ ima.VideoAdPlayer,
+ ima.AdMediaInfo,
+ ima.AdPodInfo,
+ )
+ loadAd,
+ required dynamic pauseAd,
+ required dynamic playAd,
+ required dynamic release,
+ required dynamic removeCallback,
+ required void Function(ima.VideoAdPlayer, ima.AdMediaInfo) stopAd,
+ }) {
+ addCallbackCallback = addCallback;
+ loadAdCallback = loadAd;
+ stopAdCallback = stopAd;
+ return MockVideoAdPlayer();
+ },
+ );
+
+ AndroidAdDisplayContainer(
+ AndroidAdDisplayContainerCreationParams(
+ onContainerAdded: (_) {},
+ imaProxy: imaProxy,
+ ),
+ );
+
+ final MockVideoAdPlayerCallback mockPlayerCallback =
+ MockVideoAdPlayerCallback();
+ addCallbackCallback(MockVideoAdPlayer(), mockPlayerCallback);
+
+ // Load first Ad
+ final ima.AdMediaInfo firstAdMediaInfo = MockAdMediaInfo();
+ loadAdCallback(MockVideoAdPlayer(), firstAdMediaInfo, MockAdPodInfo());
+
+ // Load second Ad before first Ad is completed
+ final ima.AdMediaInfo secondAdMediaInfo = MockAdMediaInfo();
+ loadAdCallback(MockVideoAdPlayer(), secondAdMediaInfo, MockAdPodInfo());
+
+ // Complete current ad which should be the first
+ onCompletionCallback(mockVideoView, MockMediaPlayer());
+ verify(mockPlayerCallback.onEnded(firstAdMediaInfo));
+
+ // Stop current ad to reset state
+ stopAdCallback(MockVideoAdPlayer(), MockAdMediaInfo());
+
+ // Complete current ad which should be the second
+ onCompletionCallback(mockVideoView, MockMediaPlayer());
+ verify(mockPlayerCallback.onEnded(secondAdMediaInfo));
+ });
});
}
diff --git a/packages/interactive_media_ads/test/android/ad_display_container_test.mocks.dart b/packages/interactive_media_ads/test/android/ad_display_container_test.mocks.dart
index c5feb0c..0ffd632 100644
--- a/packages/interactive_media_ads/test/android/ad_display_container_test.mocks.dart
+++ b/packages/interactive_media_ads/test/android/ad_display_container_test.mocks.dart
@@ -1111,6 +1111,17 @@
as _i5.Future<int>);
@override
+ _i5.Future<void> setAudioFocusRequest(
+ _i2.AudioManagerAudioFocus? focusGain,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#setAudioFocusRequest, [focusGain]),
+ returnValue: _i5.Future<void>.value(),
+ returnValueForMissingStub: _i5.Future<void>.value(),
+ )
+ as _i5.Future<void>);
+
+ @override
_i2.VideoView pigeon_copy() =>
(super.noSuchMethod(
Invocation.method(#pigeon_copy, []),
diff --git a/packages/interactive_media_ads/test/android/ads_loader_test.mocks.dart b/packages/interactive_media_ads/test/android/ads_loader_test.mocks.dart
index 06bacf6..eb56f04 100644
--- a/packages/interactive_media_ads/test/android/ads_loader_test.mocks.dart
+++ b/packages/interactive_media_ads/test/android/ads_loader_test.mocks.dart
@@ -1507,6 +1507,17 @@
as _i5.Future<int>);
@override
+ _i5.Future<void> setAudioFocusRequest(
+ _i2.AudioManagerAudioFocus? focusGain,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#setAudioFocusRequest, [focusGain]),
+ returnValue: _i5.Future<void>.value(),
+ returnValueForMissingStub: _i5.Future<void>.value(),
+ )
+ as _i5.Future<void>);
+
+ @override
_i2.VideoView pigeon_copy() =>
(super.noSuchMethod(
Invocation.method(#pigeon_copy, []),