From d02fcc3eff43795e09d90e8bb3987b651893ced9 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:51:22 -0400 Subject: [PATCH 01/17] handle preloading ads --- .../example/lib/main.dart | 5 +- .../android/android_ad_display_container.dart | 35 ++++--- .../android/ad_display_container_test.dart | 97 ++++++++++++++++++- 3 files changed, 122 insertions(+), 15 deletions(-) diff --git a/packages/interactive_media_ads/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart index da823b7e427..b339b868230 100644 --- a/packages/interactive_media_ads/example/lib/main.dart +++ b/packages/interactive_media_ads/example/lib/main.dart @@ -34,7 +34,8 @@ class _AdExampleWidgetState extends State // 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='; + '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='; + // '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='; // The AdsLoader instance exposes the request ads method. late final AdsLoader _adsLoader; @@ -101,7 +102,7 @@ class _AdExampleWidgetState extends State ), ); - manager.init(settings: AdsRenderingSettings(enablePreloading: true)); + manager.init(settings: AdsRenderingSettings(enablePreloading: false)); }, onAdsLoadError: (AdsLoadErrorData data) { debugPrint('OnAdsLoadError: ${data.error.message}'); 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 ced3776667e..74abdb935ec 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 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { @internal ima.AdDisplayContainer? adDisplayContainer; - // Currently loaded ad. - ima.AdMediaInfo? _loadedAdMediaInfo; + // Queue of loaded ads. + final Queue _loadedAdMediaInfoQueue = + Queue(); // The saved ad position, used to resume ad playback following an ad // click-through. @@ -171,9 +173,17 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _savedAdPosition = 0; } + // Reset state to before an ad is loaded and release references to all ads and + // callbacks. + void _release() { + _resetStateForNextAd(); + _loadedAdMediaInfoQueue.clear(); + videoAdPlayerCallbacks.clear(); + } + // Clear state to before ad is loaded and replace current VideoView with a new // one. - void _resetState() { + void _resetStateForNextAd() { _stopAdProgressTracking(); _frameLayout.removeView(_videoView); @@ -183,7 +193,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _frameLayout.addView(_videoView); _clearMediaPlayer(); - _loadedAdMediaInfo = null; + _loadedAdMediaInfoQueue.removeFirst(); _adDuration = null; _startPlayerWhenVideoIsPrepared = true; } @@ -211,7 +221,8 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { await Future.wait(>[ _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), @@ -242,7 +253,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { container._stopAdProgressTracking(); for (final ima.VideoAdPlayerCallback callback in container.videoAdPlayerCallbacks) { - callback.onEnded(container._loadedAdMediaInfo!); + callback.onEnded(container._loadedAdMediaInfoQueue.first); } } }, @@ -267,9 +278,9 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { container._clearMediaPlayer(); for (final ima.VideoAdPlayerCallback callback in container.videoAdPlayerCallbacks) { - callback.onError(container._loadedAdMediaInfo!); + callback.onError(container._loadedAdMediaInfoQueue.first); } - container._loadedAdMediaInfo = null; + container._loadedAdMediaInfoQueue.removeFirst(); container._adDuration = null; } }, @@ -290,7 +301,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { weakThis.target?.videoAdPlayerCallbacks.remove(callback); }, loadAd: (_, ima.AdMediaInfo adMediaInfo, __) { - weakThis.target?._loadedAdMediaInfo = adMediaInfo; + weakThis.target?._loadedAdMediaInfoQueue.add(adMediaInfo); }, pauseAd: (_, __) async { final AndroidAdDisplayContainer? container = weakThis.target; @@ -312,8 +323,10 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { container._videoView.setVideoUri(adMediaInfo.url); } }, - release: (_) => weakThis.target?._resetState(), - stopAd: (_, __) => weakThis.target?._resetState(), + release: (_) { + weakThis.target?._release(); + }, + stopAd: (_, __) => weakThis.target?._resetStateForNextAd(), ); } } 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 58a80710e2d..ebce1562acd 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 @@ -695,13 +695,19 @@ void main() { }, 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 +756,19 @@ void main() { }, 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 +885,86 @@ void main() { ]), ); }); + + 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)); + }); }); } From 2f278c476066fdf6e2463188014af80f11c65ecf Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:49:59 -0400 Subject: [PATCH 02/17] update example to test multiple ad tags --- .../example/lib/main.dart | 278 +++--------------- .../example/lib/video_ad_example_screen.dart | 276 +++++++++++++++++ 2 files changed, 314 insertions(+), 240 deletions(-) create mode 100644 packages/interactive_media_ads/example/lib/video_ad_example_screen.dart diff --git a/packages/interactive_media_ads/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart index b339b868230..fac185290d5 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,259 +15,59 @@ void integrationTestMain() { } 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 createState() => _AdExampleWidgetState(); + State createState() => _HomeScreenState(); } -class _AdExampleWidgetState extends State - 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%3Dpreonlybumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&correlator='; - // '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='; - - // 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: [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: false)); - }, - 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 _requestAds(AdDisplayContainer container) { - return _adsLoader.requestAds( - AdsRequest( - adTagUrl: _adTagUrl, - contentProgressProvider: _contentProgressProvider, +class _HomeScreenState extends State with WidgetsBindingObserver { + final List<(String, String)> _testAdTagUrls = <(String, String)>[ + ( + 'Pre-, Mid-, Post-roll Single Ads', + '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=', + ), + ]; + + void _pushVideoAdExampleWithAdTagUrl(String adTagUrl) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoAdExampleScreen(adTagUrl: adTagUrl), ), ); } - Future _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 _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: [ - SizedBox( - width: 300, - child: - !_contentVideoController.value.isInitialized - ? Container() - : AspectRatio( - aspectRatio: _contentVideoController.value.aspectRatio, - child: Stack( - children: [ - // 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 title, String adTagUrl) = _testAdTagUrls[index]; + return ElevatedButton( + onPressed: () => _pushVideoAdExampleWithAdTagUrl(adTagUrl), + child: Text(title), + ); + }, ), ), - 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 00000000000..6dbcdc039e9 --- /dev/null +++ b/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart @@ -0,0 +1,276 @@ +// 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.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; + + @override + State createState() => _VideoAdExampleScreenState(); +} + +class _VideoAdExampleScreenState extends State + 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: [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 _requestAds(AdDisplayContainer container) { + return _adsLoader.requestAds( + AdsRequest( + adTagUrl: widget.adTagUrl, + contentProgressProvider: _contentProgressProvider, + ), + ); + } + + Future _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 _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: [ + SizedBox( + width: 300, + child: + !_contentVideoController.value.isInitialized + ? Container() + : AspectRatio( + aspectRatio: _contentVideoController.value.aspectRatio, + child: Stack( + children: [ + // 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, + ); + } +} From 8e58c2af7ba78d06ccec500bb0e3f3b03574b863 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:16:40 -0400 Subject: [PATCH 03/17] add other test ad tag urls --- packages/interactive_media_ads/example/lib/main.dart | 10 +++++++++- .../example/lib/video_ad_example_screen.dart | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/interactive_media_ads/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart index fac185290d5..8c9bfcc231b 100644 --- a/packages/interactive_media_ads/example/lib/main.dart +++ b/packages/interactive_media_ads/example/lib/main.dart @@ -30,13 +30,21 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State with WidgetsBindingObserver { final List<(String, String)> _testAdTagUrls = <(String, String)>[ ( - 'Pre-, Mid-, Post-roll Single Ads', + '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=', + ), ]; void _pushVideoAdExampleWithAdTagUrl(String adTagUrl) { 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 index 6dbcdc039e9..6b7db6cda25 100644 --- a/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart +++ b/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart @@ -170,6 +170,10 @@ class _VideoAdExampleScreenState extends State } Future _resumeContent() async { + if (!mounted) { + return; + } + setState(() { _shouldShowContentVideo = true; }); From ac3fd8f056fe083567cbeeb4215236ce215d6f08 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:29:16 -0400 Subject: [PATCH 04/17] version bump --- packages/interactive_media_ads/CHANGELOG.md | 4 ++++ .../packages/interactive_media_ads/AdsRequestProxyApi.kt | 2 +- .../interactive_media_ads/AdsRequestProxyAPIDelegate.swift | 2 +- packages/interactive_media_ads/pubspec.yaml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/interactive_media_ads/CHANGELOG.md b/packages/interactive_media_ads/CHANGELOG.md index 3457bdfc89e..a1e699590f6 100644 --- a/packages/interactive_media_ads/CHANGELOG.md +++ b/packages/interactive_media_ads/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.6+7 + +* Fixes preloading back-to-back ads on Android. + ## 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 2d4200761d9..5a239da78ba 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 @@ class AdsRequestProxyApi(override val pigeonRegistrar: ProxyApiRegistrar) : * * 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/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 360498b7a1e..802c3851508 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 @@ class AdsRequestProxyAPIDelegate: PigeonApiDelegateIMAAdsRequest { /// 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/pubspec.yaml b/packages/interactive_media_ads/pubspec.yaml index aed3c305483..17bb2c2c94f 100644 --- a/packages/interactive_media_ads/pubspec.yaml +++ b/packages/interactive_media_ads/pubspec.yaml @@ -2,7 +2,7 @@ name: interactive_media_ads 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` From f344f4b88fec388d272a3218affaa48411199639 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:11:44 -0400 Subject: [PATCH 05/17] formatting --- .../test/android/ad_display_container_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ebce1562acd..359acf5629f 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 @@ -887,8 +887,7 @@ void main() { }); test('AdDisplayContainer handles preloaded ads', () async { - late void Function(ima.VideoView, ima.MediaPlayer) - onCompletionCallback; + late void Function(ima.VideoView, ima.MediaPlayer) onCompletionCallback; late final void Function(ima.VideoAdPlayer, ima.VideoAdPlayerCallback) addCallbackCallback; @@ -898,7 +897,8 @@ void main() { ima.AdPodInfo, ) loadAdCallback; - late final void Function(ima.VideoAdPlayer, ima.AdMediaInfo) stopAdCallback; + late final void Function(ima.VideoAdPlayer, ima.AdMediaInfo) + stopAdCallback; final MockVideoView mockVideoView = MockVideoView(); final InteractiveMediaAdsProxy imaProxy = InteractiveMediaAdsProxy( From 752a6fb5e2f7eac8d1a2f40cf97f42c5f8ff41e9 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:30:01 -0400 Subject: [PATCH 06/17] also call onPlay and onPause --- .../lib/src/android/android_ad_display_container.dart | 9 +++++++++ 1 file changed, 9 insertions(+) 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 74abdb935ec..8b3cf4ed6d1 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 @@ -314,6 +314,11 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { container._savedAdPosition = await container._videoView.getCurrentPosition(); container._stopAdProgressTracking(); + await Future.wait(>[ + for (final ima.VideoAdPlayerCallback callback + in container.videoAdPlayerCallbacks) + callback.onPause(container._loadedAdMediaInfoQueue.first), + ]); } }, playAd: (_, ima.AdMediaInfo adMediaInfo) { @@ -321,6 +326,10 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { if (container != null) { container._startPlayerWhenVideoIsPrepared = true; container._videoView.setVideoUri(adMediaInfo.url); + for (final ima.VideoAdPlayerCallback callback + in container.videoAdPlayerCallbacks) { + callback.onPlay(adMediaInfo); + } } }, release: (_) { From 85675a3b4285179e9e6d07b29730319d50fb926f Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:38:48 -0400 Subject: [PATCH 07/17] call resume if resuming --- .../lib/src/android/android_ad_display_container.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 8b3cf4ed6d1..8913e3fc710 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 @@ -326,9 +326,14 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { if (container != null) { container._startPlayerWhenVideoIsPrepared = true; container._videoView.setVideoUri(adMediaInfo.url); + for (final ima.VideoAdPlayerCallback callback in container.videoAdPlayerCallbacks) { - callback.onPlay(adMediaInfo); + if (container._savedAdPosition == 0) { + callback.onPlay(adMediaInfo); + } else { + callback.onResume(adMediaInfo); + } } } }, From e75b84a490edb2e965c7bea703b70693450fd660 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sat, 30 Aug 2025 17:58:16 -0400 Subject: [PATCH 08/17] add audio focus request support --- .../InteractiveMediaAdsLibrary.g.kt | 79 +++++++++++++++++++ .../ProxyApiRegistrar.kt | 8 ++ .../VideoViewProxyApi.kt | 17 ++++ .../TestProxyApiRegistrar.kt | 4 + .../VideoViewProxyApiTest.kt | 11 +++ .../src/android/interactive_media_ads.g.dart | 70 ++++++++++++++++ .../interactive_media_ads_android.dart | 36 +++++++++ 7 files changed, 225 insertions(+) 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 dee5d5d1ebf..f0028be7e34 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 @@ private class InteractiveMediaAdsLibraryPigeonProxyApiBaseCodec( value is AdErrorType || value is AdEventType || value is UiElement || + value is AudioManagerAudioFocus || value == null) { super.writeValue(stream, value) return @@ -956,6 +957,42 @@ enum class UiElement(val raw: Int) { } } +/** + * 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 @@ private open class InteractiveMediaAdsLibraryPigeonCodec : StandardMessageCodec( 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 @@ private open class InteractiveMediaAdsLibraryPigeonCodec : StandardMessageCodec( 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 class PigeonApiVideoView( */ 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 @@ abstract class PigeonApiVideoView( channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.interactive_media_ads.VideoView.setAudioFocusRequest", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as android.widget.VideoView + val focusGainArg = args[1] as AudioManagerAudioFocus + val wrapped: List = + 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 c34c6242b9f..6c7e12d5d16 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 @@ open class ProxyApiRegistrar(binaryMessenger: BinaryMessenger, var context: Cont 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 b84a28463c5..d1c00c90496 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 @@ class VideoViewProxyApi(override val pigeonRegistrar: ProxyApiRegistrar) : 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 0aba7066a28..b502006b2d6 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 @@ class TestProxyApiRegistrar : ProxyApiRegistrar(mock(), mock()) { 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 f43af0aa428..d61d7bd01c7 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 @@ class VideoViewProxyApiTest { assertEquals(0, api.getCurrentPosition(instance)) } + + @Test + fun setAudioFocusRequest() { + val api = TestProxyApiRegistrar().getPigeonApiVideoView() + + val instance = mock() + api.setAudioFocusRequest(instance, AudioManagerAudioFocus.GAIN) + + verify(instance).setAudioFocusRequest(AudioManager.AUDIOFOCUS_GAIN) + } } 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 af248000c71..d24bc54580d 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 @@ -852,6 +852,36 @@ enum UiElement { 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 +901,9 @@ class _PigeonCodec extends StandardMessageCodec { } 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 +924,9 @@ class _PigeonCodec extends StandardMessageCodec { 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); } @@ -4801,6 +4837,40 @@ class VideoView extends View { } } + /// 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 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 pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [this, focusGain], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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/pigeons/interactive_media_ads_android.dart b/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart index ec4244b5bc0..34321f4fc41 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 @@ enum UiElement { 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. @@ -724,6 +754,12 @@ abstract class VideoView extends View { /// /// 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. From f992ce8dc06bb16da631e49f79d6f36cb1ebb887 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sat, 30 Aug 2025 19:14:52 -0400 Subject: [PATCH 09/17] change onprepared to async and actually make preloading possible --- .../android/android_ad_display_container.dart | 54 +++++++++++++++++-- .../src/android/interactive_media_ads.g.dart | 13 +++-- .../android/interactive_media_ads_proxy.dart | 2 +- .../interactive_media_ads_android.dart | 1 + 4 files changed, 59 insertions(+), 11 deletions(-) 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 8913e3fc710..44c81558153 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 @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:collection'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -301,7 +302,17 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { weakThis.target?.videoAdPlayerCallbacks.remove(callback); }, loadAd: (_, ima.AdMediaInfo adMediaInfo, __) { - weakThis.target?._loadedAdMediaInfoQueue.add(adMediaInfo); + final AndroidAdDisplayContainer? container = weakThis.target; + if (container != null) { + container._loadedAdMediaInfoQueue.add(adMediaInfo); + if (container._loadedAdMediaInfoQueue.length == 1) { + container._startPlayerWhenVideoIsPrepared = false; + container._videoView.setAudioFocusRequest( + ima.AudioManagerAudioFocus.none, + ); + container._videoView.setVideoUri(adMediaInfo.url); + } + } }, pauseAd: (_, __) async { final AndroidAdDisplayContainer? container = weakThis.target; @@ -324,8 +335,29 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { 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) { + // When the + container._mediaPlayer! + .start() + .then((_) => container._startAdProgressTracking()) + .onError( + (_, _) { + if (container._mediaPlayer != null) { + container._videoView.setVideoUri(adMediaInfo.url); + } + }, + test: + (PlatformException exception) => + exception.code == 'IllegalStateException', + ); + } container._startPlayerWhenVideoIsPrepared = true; - container._videoView.setVideoUri(adMediaInfo.url); for (final ima.VideoAdPlayerCallback callback in container.videoAdPlayerCallbacks) { @@ -337,10 +369,22 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { } } }, - release: (_) { - weakThis.target?._release(); + release: (_) => weakThis.target?._release(), + stopAd: (_, __) { + final AndroidAdDisplayContainer? container = weakThis.target; + if (container != null) { + container._resetStateForNextAd(); + if (container._loadedAdMediaInfoQueue.isNotEmpty) { + container._startPlayerWhenVideoIsPrepared = false; + container._videoView.setAudioFocusRequest( + ima.AudioManagerAudioFocus.none, + ); + container._videoView.setVideoUri( + container._loadedAdMediaInfoQueue.first.url, + ); + } + } }, - stopAd: (_, __) => weakThis.target?._resetStateForNextAd(), ); } } 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 d24bc54580d..340fbc796be 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 @@ class PigeonOverrides { int extra, ) onError, - void Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared, + Future Function(VideoView pigeon_instance, MediaPlayer player)? + onPrepared, void Function(VideoView pigeon_instance, MediaPlayer player)? onCompletion, })? videoView_new; @@ -4452,7 +4453,8 @@ class VideoView extends View { factory VideoView({ BinaryMessenger? pigeon_binaryMessenger, PigeonInstanceManager? pigeon_instanceManager, - void Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared, + Future Function(VideoView pigeon_instance, MediaPlayer player)? + onPrepared, void Function(VideoView pigeon_instance, MediaPlayer player)? onCompletion, required void Function( VideoView pigeon_instance, @@ -4554,7 +4556,7 @@ class VideoView extends View { /// /// Alternatively, [PigeonInstanceManager.removeWeakReference] can be used to /// release the associated Native object manually. - final void Function(VideoView pigeon_instance, MediaPlayer player)? + final Future Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared; /// Callback to be invoked when playback of a media source has completed. @@ -4611,7 +4613,8 @@ class VideoView extends View { bool pigeon_clearHandlers = false, BinaryMessenger? pigeon_binaryMessenger, PigeonInstanceManager? pigeon_instanceManager, - void Function(VideoView pigeon_instance, MediaPlayer player)? onPrepared, + Future Function(VideoView pigeon_instance, MediaPlayer player)? + onPrepared, void Function(VideoView pigeon_instance, MediaPlayer player)? onCompletion, void Function( VideoView pigeon_instance, @@ -4653,7 +4656,7 @@ class VideoView extends View { '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!, ); 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 5ba4b85ef7e..3cf99bc96f5 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 @@ class InteractiveMediaAdsProxy { /// Constructs [VideoView]. final VideoView Function({ required void Function(VideoView, MediaPlayer, int, int) onError, - void Function(VideoView, MediaPlayer)? onPrepared, + Future 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 34321f4fc41..0671728ac42 100644 --- a/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart +++ b/packages/interactive_media_ads/pigeons/interactive_media_ads_android.dart @@ -738,6 +738,7 @@ abstract class VideoView extends View { 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. From b73e95302ec8e4c76746f4ca7411b1f5e6a72f89 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:49:12 -0400 Subject: [PATCH 10/17] create adplayer to catch when app is sent to background --- .../example/lib/main.dart | 2 +- .../android/android_ad_display_container.dart | 121 ++++++++++++------ 2 files changed, 83 insertions(+), 40 deletions(-) diff --git a/packages/interactive_media_ads/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart index 8c9bfcc231b..f36b9a6bb99 100644 --- a/packages/interactive_media_ads/example/lib/main.dart +++ b/packages/interactive_media_ads/example/lib/main.dart @@ -27,7 +27,7 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State with WidgetsBindingObserver { +class _HomeScreenState extends State { final List<(String, String)> _testAdTagUrls = <(String, String)>[ ( 'Single Inline Linear', 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 44c81558153..a39873bbde9 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 @@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:collection'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -140,30 +139,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { @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 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. @@ -342,20 +318,9 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { ); if (container._mediaPlayer != null) { - // When the - container._mediaPlayer! - .start() - .then((_) => container._startAdProgressTracking()) - .onError( - (_, _) { - if (container._mediaPlayer != null) { - container._videoView.setVideoUri(adMediaInfo.url); - } - }, - test: - (PlatformException exception) => - exception.code == 'IllegalStateException', - ); + container._mediaPlayer!.start().then( + (_) => container._startAdProgressTracking(), + ); } container._startPlayerWhenVideoIsPrepared = true; @@ -388,3 +353,81 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { ); } } + +class _AdPlayer extends StatefulWidget { + _AdPlayer(this.container) : super(key: container._androidParams.key); + + final AndroidAdDisplayContainer container; + + @override + State 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._startPlayerWhenVideoIsPrepared = false; + container._videoView.setAudioFocusRequest( + ima.AudioManagerAudioFocus.none, + ); + container._videoView.setVideoUri( + container._loadedAdMediaInfoQueue.first.url, + ); + } + 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 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); + }, + ); + } +} From 941b6bb9f0c83922d659ab3c0a8d96fe7d5cd8bc Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:55:06 -0400 Subject: [PATCH 11/17] include title in test app screen --- .../example/lib/main.dart | 18 +++++++++++++----- .../example/lib/video_ad_example_screen.dart | 10 +++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/interactive_media_ads/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart index f36b9a6bb99..8d2317849d4 100644 --- a/packages/interactive_media_ads/example/lib/main.dart +++ b/packages/interactive_media_ads/example/lib/main.dart @@ -47,10 +47,14 @@ class _HomeScreenState extends State { ), ]; - void _pushVideoAdExampleWithAdTagUrl(String adTagUrl) { + void _pushVideoAdExampleWithAdTagUrl({ + required String adType, + required String adTagUrl, + }) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => VideoAdExampleScreen(adTagUrl: adTagUrl), + builder: + (_) => VideoAdExampleScreen(adType: adType, adTagUrl: adTagUrl), ), ); } @@ -68,10 +72,14 @@ class _HomeScreenState extends State { itemCount: _testAdTagUrls.length, separatorBuilder: (_, _) => const SizedBox(height: 50), itemBuilder: (_, int index) { - final (String title, String adTagUrl) = _testAdTagUrls[index]; + final (String adType, String adTagUrl) = _testAdTagUrls[index]; return ElevatedButton( - onPressed: () => _pushVideoAdExampleWithAdTagUrl(adTagUrl), - child: Text(title), + onPressed: + () => _pushVideoAdExampleWithAdTagUrl( + adType: adType, + adTagUrl: adTagUrl, + ), + child: Text(adType), ); }, ), 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 index 6b7db6cda25..1f201d5feba 100644 --- a/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart +++ b/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart @@ -13,6 +13,7 @@ class VideoAdExampleScreen extends StatefulWidget { /// Constructs an [VideoAdExampleScreen]. const VideoAdExampleScreen({ super.key, + required this.adType, required this.adTagUrl, this.enablePreloading = true, }); @@ -24,6 +25,9 @@ class VideoAdExampleScreen extends StatefulWidget { /// [AdsManager.start]. final bool enablePreloading; + /// The type of ads that will be requested. + final String adType; + @override State createState() => _VideoAdExampleScreenState(); } @@ -225,9 +229,13 @@ class _VideoAdExampleScreenState extends State ), body: Center( child: Column( - spacing: 100, + spacing: 80, mainAxisAlignment: MainAxisAlignment.center, children: [ + Text( + widget.adType, + style: Theme.of(context).textTheme.headlineMedium, + ), SizedBox( width: 300, child: From 5a53c06ab66800ade53e585190a22de3b03421c4 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:29:31 -0400 Subject: [PATCH 12/17] update mocks and only remove if not empty --- .../lib/src/android/android_ad_display_container.dart | 5 +++-- .../test/android/ad_display_container_test.mocks.dart | 11 +++++++++++ .../test/android/ads_loader_test.mocks.dart | 11 +++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) 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 a39873bbde9..8f065605e26 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 @@ -170,9 +170,10 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _frameLayout.addView(_videoView); _clearMediaPlayer(); - _loadedAdMediaInfoQueue.removeFirst(); + if (_loadedAdMediaInfoQueue.isNotEmpty) { + _loadedAdMediaInfoQueue.removeFirst(); + } _adDuration = null; - _startPlayerWhenVideoIsPrepared = true; } // Starts periodically updating the IMA SDK the progress of the currently 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 c5feb0c7a36..0ffd6327893 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 @@ -1110,6 +1110,17 @@ class MockVideoView extends _i1.Mock implements _i2.VideoView { ) as _i5.Future); + @override + _i5.Future setAudioFocusRequest( + _i2.AudioManagerAudioFocus? focusGain, + ) => + (super.noSuchMethod( + Invocation.method(#setAudioFocusRequest, [focusGain]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i2.VideoView pigeon_copy() => (super.noSuchMethod( 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 06bacf6f2dc..eb56f04dcaa 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 @@ -1506,6 +1506,17 @@ class MockVideoView extends _i1.Mock implements _i2.VideoView { ) as _i5.Future); + @override + _i5.Future setAudioFocusRequest( + _i2.AudioManagerAudioFocus? focusGain, + ) => + (super.noSuchMethod( + Invocation.method(#setAudioFocusRequest, [focusGain]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i2.VideoView pigeon_copy() => (super.noSuchMethod( From e6b4bef3e23d4ac097e297498dd612cfb7604a8d Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:34:40 -0400 Subject: [PATCH 13/17] fix unit tests --- .../android/ad_display_container_test.dart | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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 359acf5629f..47649366d61 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 @@ void main() { late final Future 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 @@ void main() { newFrameLayout: () => MockFrameLayout(), newVideoView: ({ dynamic onError, - dynamic onPrepared, + Future Function(ima.VideoView, ima.MediaPlayer)? onPrepared, dynamic onCompletion, }) { - // VideoView.onPrepared returns void, but the implementation uses an - // async callback method. - onPreparedCallback = - onPrepared! - as Future Function(ima.VideoView, ima.MediaPlayer); + onPreparedCallback = onPrepared!; final MockVideoView mockVideoView = MockVideoView(); when( mockVideoView.getCurrentPosition(), @@ -306,13 +305,14 @@ void main() { ) 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 @@ void main() { final ima.AdMediaInfo mockAdMediaInfo = MockAdMediaInfo(); loadAdCallback(MockVideoAdPlayer(), mockAdMediaInfo, MockAdPodInfo()); + playAdCallback(MockVideoAdPlayer(), mockAdMediaInfo); final MockVideoAdPlayerCallback mockPlayerCallback = MockVideoAdPlayerCallback(); @@ -619,6 +620,13 @@ void main() { }); 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 @@ void main() { }, 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 @@ void main() { 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)); From 18f3f5b6f0ef45fa11ed5d54df19729717121bb7 Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:35:52 -0400 Subject: [PATCH 14/17] better changelog --- packages/interactive_media_ads/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactive_media_ads/CHANGELOG.md b/packages/interactive_media_ads/CHANGELOG.md index a1e699590f6..5c84cf244ec 100644 --- a/packages/interactive_media_ads/CHANGELOG.md +++ b/packages/interactive_media_ads/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.2.6+7 -* Fixes preloading back-to-back ads on Android. +* Updates Android `PlatformAdDisplayContainer` implementation to support preloading ads. ## 0.2.6+6 From 25eddcb6f3a5a382c80319eedfd7acd0298183be Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:59:49 -0400 Subject: [PATCH 15/17] improve code by creating helper methods --- .../android/android_ad_display_container.dart | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) 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 8f065605e26..9bd762d6485 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 @@ -111,7 +111,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { @internal ima.AdDisplayContainer? adDisplayContainer; - // Queue of loaded ads. + // Queue of ads to be played. final Queue _loadedAdMediaInfoQueue = Queue(); @@ -150,19 +150,21 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _savedAdPosition = 0; } - // Reset state to before an ad is loaded and release references to all ads and - // callbacks. + // Resets the state to before an ad is loaded and releases references to all + // ads and allbacks. void _release() { _resetStateForNextAd(); _loadedAdMediaInfoQueue.clear(); videoAdPlayerCallbacks.clear(); } - // Clear state to before ad is loaded and replace current VideoView with a new - // one. + // 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(this), @@ -217,6 +219,17 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _adProgressTimer = null; } + /// Load the first ad in the queue. + Future _loadCurrentAd() { + _startPlayerWhenVideoIsPrepared = false; + return Future.wait(>[ + // 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. @@ -238,8 +251,8 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { 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); } @@ -253,13 +266,11 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { onError: (_, __, ___, ____) { final AndroidAdDisplayContainer? container = weakThis.target; if (container != null) { - container._clearMediaPlayer(); for (final ima.VideoAdPlayerCallback callback in container.videoAdPlayerCallbacks) { callback.onError(container._loadedAdMediaInfoQueue.first); } - container._loadedAdMediaInfoQueue.removeFirst(); - container._adDuration = null; + container._resetStateForNextAd(); } }, ); @@ -283,11 +294,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { if (container != null) { container._loadedAdMediaInfoQueue.add(adMediaInfo); if (container._loadedAdMediaInfoQueue.length == 1) { - container._startPlayerWhenVideoIsPrepared = false; - container._videoView.setAudioFocusRequest( - ima.AudioManagerAudioFocus.none, - ); - container._videoView.setVideoUri(adMediaInfo.url); + container._loadCurrentAd(); } } }, @@ -341,13 +348,7 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { if (container != null) { container._resetStateForNextAd(); if (container._loadedAdMediaInfoQueue.isNotEmpty) { - container._startPlayerWhenVideoIsPrepared = false; - container._videoView.setAudioFocusRequest( - ima.AudioManagerAudioFocus.none, - ); - container._videoView.setVideoUri( - container._loadedAdMediaInfoQueue.first.url, - ); + container._loadCurrentAd(); } } }, @@ -355,6 +356,11 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { } } +// 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); @@ -383,13 +389,7 @@ class _AdPlayerState extends State<_AdPlayer> with WidgetsBindingObserver { switch (state) { case AppLifecycleState.resumed: if (container._loadedAdMediaInfoQueue.isNotEmpty) { - container._startPlayerWhenVideoIsPrepared = false; - container._videoView.setAudioFocusRequest( - ima.AudioManagerAudioFocus.none, - ); - container._videoView.setVideoUri( - container._loadedAdMediaInfoQueue.first.url, - ); + container._loadCurrentAd(); } case AppLifecycleState.paused: container._mediaPlayer = null; From f99ed4e2d5834fc5c21971bbe795b0c3d9695f4a Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 17:15:37 -0400 Subject: [PATCH 16/17] move set state to in is completed --- .../example/lib/video_ad_example_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1f201d5feba..99a4ad1b998 100644 --- a/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart +++ b/packages/interactive_media_ads/example/lib/video_ad_example_screen.dart @@ -132,8 +132,8 @@ class _VideoAdExampleScreenState extends State ..addListener(() { if (_contentVideoController.value.isCompleted) { _adsLoader.contentComplete(); + setState(() {}); } - setState(() {}); }) ..initialize().then((_) { // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed. From b1a16043f4eb6687215cdf7c6fe030590015552a Mon Sep 17 00:00:00 2001 From: Maurice Parrish <10687576+bparrishMines@users.noreply.github.com> Date: Sun, 31 Aug 2025 18:12:32 -0400 Subject: [PATCH 17/17] only clear media player --- .../lib/src/android/android_ad_display_container.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9bd762d6485..377f260fcb4 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 @@ -266,11 +266,11 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { onError: (_, __, ___, ____) { final AndroidAdDisplayContainer? container = weakThis.target; if (container != null) { + container._clearMediaPlayer(); for (final ima.VideoAdPlayerCallback callback in container.videoAdPlayerCallbacks) { callback.onError(container._loadedAdMediaInfoQueue.first); } - container._resetStateForNextAd(); } }, );