diff --git a/packages/interactive_media_ads/CHANGELOG.md b/packages/interactive_media_ads/CHANGELOG.md index 3457bdfc89e..5c84cf244ec 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 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/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/example/lib/main.dart b/packages/interactive_media_ads/example/lib/main.dart index da823b7e427..8d2317849d4 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 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%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: 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 _requestAds(AdDisplayContainer container) { - return _adsLoader.requestAds( - AdsRequest( - adTagUrl: _adTagUrl, - contentProgressProvider: _contentProgressProvider, +class _HomeScreenState extends State { + 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=', + ), + ]; + + void _pushVideoAdExampleWithAdTagUrl({ + required String adType, + required String adTagUrl, + }) { + Navigator.of(context).push( + MaterialPageRoute( + builder: + (_) => VideoAdExampleScreen(adType: adType, 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 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 00000000000..99a4ad1b998 --- /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 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 { + 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 _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: [ + 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: [ + // 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 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/lib/src/android/android_ad_display_container.dart b/packages/interactive_media_ads/lib/src/android/android_ad_display_container.dart index ced3776667e..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 @@ -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 ads to be played. + final Queue _loadedAdMediaInfoQueue = + Queue(); // The saved ad position, used to resume ad playback following an ad // click-through. @@ -137,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. @@ -171,11 +150,21 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _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(this), @@ -183,9 +172,10 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { _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 @@ 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), @@ -228,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. @@ -242,15 +244,15 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { 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 @@ 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._adDuration = null; } }, ); @@ -290,7 +290,13 @@ base class AndroidAdDisplayContainer extends PlatformAdDisplayContainer { 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 @@ 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) { 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?._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 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 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); }, - release: (_) => weakThis.target?._resetState(), - stopAd: (_, __) => weakThis.target?._resetState(), ); } } 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..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; @@ -852,6 +853,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 +902,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 +925,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); } @@ -4416,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, @@ -4518,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. @@ -4575,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, @@ -4617,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!, ); @@ -4801,6 +4840,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/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 ec4244b5bc0..0671728ac42 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. @@ -708,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. @@ -724,6 +755,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. 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` 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..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)); @@ -695,13 +710,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 +771,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 +900,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)); + }); }); } 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(