【Flutter】動画再生中に広告を挿入する簡単な方法

image

はじめに

youtubeのアプリでは、再生開始時や再生途中で広告が差し込まれることがあると思います。
この広告機能はGoogleが提供しているIMA SDKを使用しており、無料で提供されています。
公式ドキュメントではネイティブアプリへの導入方法しか記載されておらず、Flutterで実装する方法がなかったので、試してみようと思います。

余談

私が経験したプロジェクトでは動画再生アプリを開発しており、ネイティブコードのavFoundation(Objective-C)やexoPlayer(java)のソースをいじることで対応していました。
しかし、もっと簡単にFlutter側だけでできる方法があると個人開発などでも扱いやすいと思い調べてみると、Flutter公式がinteractive_media_adsというパッケージをリリースしていました。

IMA SDKとは

IMA SDK(Interactive Media Ads SDK)は、Googleが提供する広告ソリューションの一部で、主に動画コンテンツに広告を組み込むためのツールです。
VMAP(Video Multiple Ad Playlist)と呼ばれるXML形式のフォーマットから広告情報を読み込み、広告の差し込みを判断します。

https://developers.google.com/interactive-media-ads?hl=ja

実装してみる

利用パッケージ

video_player
interactive_media_ads

dependencies:
  flutter:
    sdk: flutter

  video_player: ^2.9.3
  interactive_media_ads: ^0.2.3+8

実装

基本的にはパッケージ側のexampleをコピペしました。

1.vmapUrlを作成(googleのsampleです)
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=';
2.adDisplayContanerを作成

これは広告を再生するためのウィジェットを作成しています。 AdsLoaderクラスを定義することで、onAdEventから色々広告の再生状態を検知できます。

late final AdDisplayContainer _adDisplayContainer = AdDisplayContainer(
    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();
        },
      );

      _requestAds(container);
    },
  );
3.VideoPlayerを初期化

AdDisplayContainerとは別でVideoPlayerも初期化します。


  void initState() {
    super.initState();
    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((_) {
            setState(() {});
          });
  }
4.表示

stackを使用し、AdDisplayContainerの上にVideoPlayerを重ねます。 _shouldShowContentVideoを検知して広告の出しわけを行います。

Stack(
  children: <Widget>[
    _adDisplayContainer,
    if (_shouldShowContentVideo)
      VideoPlayer(_contentVideoController),
  ],
),  
コード全文

リポジトリはこちら
https://github.com/NoriakiSakata/flutter_ads_playing

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:interactive_media_ads/interactive_media_ads.dart';
import 'package:video_player/video_player.dart';

class VideoPlayerScreen extends StatefulWidget {
  const VideoPlayerScreen({super.key});

  
  State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}

class _VideoPlayerScreenState extends State<VideoPlayerScreen>
    with WidgetsBindingObserver {
  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=';

  late final AdsLoader _adsLoader;
  AdsManager? _adsManager;
  AppLifecycleState _lastLifecycleState = AppLifecycleState.resumed;
  bool _shouldShowContentVideo = false;
  late final VideoPlayerController _contentVideoController;
  Timer? _contentProgressTimer;
  final ContentProgressProvider _contentProgressProvider =
      ContentProgressProvider();

  late final AdDisplayContainer _adDisplayContainer = AdDisplayContainer(
    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();
        },
      );

      _requestAds(container);
    },
  );

  
  void initState() {
    super.initState();
    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((_) {
            setState(() {});
          });
  }

  
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        if (!_shouldShowContentVideo) {
          _adsManager?.resume();
        }
      case AppLifecycleState.inactive:
        if (!_shouldShowContentVideo &&
            _lastLifecycleState == AppLifecycleState.resumed) {
          _adsManager?.pause();
        }
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
      case AppLifecycleState.detached:
    }
    _lastLifecycleState = state;
  }

  Future<void> _requestAds(AdDisplayContainer container) {
    return _adsLoader.requestAds(
      AdsRequest(
        adTagUrl: _adTagUrl,
        contentProgressProvider: _contentProgressProvider,
      ),
    );
  }

  Future<void> _resumeContent() async {
    setState(() {
      _shouldShowContentVideo = true;
    });

    if (_adsManager != null) {
      _contentProgressTimer = Timer.periodic(
        const Duration(milliseconds: 200),
        (Timer timer) async {
          if (_contentVideoController.value.isInitialized) {
            final Duration? progress = await _contentVideoController.position;
            if (progress != null) {
              await _contentProgressProvider.setProgress(
                progress: progress,
                duration: _contentVideoController.value.duration,
              );
            }
          }
        },
      );
    }

    await _contentVideoController.play();
  }

  Future<void> _pauseContent() {
    setState(() {
      _shouldShowContentVideo = false;
    });
    _contentProgressTimer?.cancel();
    _contentProgressTimer = null;
    return _contentVideoController.pause();
  }

  
  void dispose() {
    super.dispose();
    _contentProgressTimer?.cancel();
    _contentVideoController.dispose();
    _adsManager?.destroy();
    WidgetsBinding.instance.removeObserver(this);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: SizedBox(
            child:
                !_contentVideoController.value.isInitialized
                    ? Container()
                    : Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        AspectRatio(
                          aspectRatio:
                              _contentVideoController.value.aspectRatio,
                          child: Stack(
                            children: <Widget>[
                              _adDisplayContainer,
                              if (_shouldShowContentVideo)
                                VideoPlayer(_contentVideoController),
                            ],
                          ),
                        ),
                        VideoProgressIndicator(
                          _contentVideoController,
                          allowScrubbing: true,
                        ),

                        Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Column(
                            children: [
                              Text(
                                'IMA SDKのテストをするための動画です',
                                style: TextStyle(
                                  color: Colors.black,
                                  fontSize: 16,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
          ),
        ),
      ),
      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,
    );
  }
}

実際に動かしてみる

プレロール、ミッドロール、ポストロール全てちゃんと再生できました!

感想

よかった点

  • 実装がとにかくめちゃくちゃ簡単!
  • Flutter公式が作ってるという安心感
  • メンテナンスコストがかからない

微妙な点

  • VideoPlayerとAdDisplayContainerを出し分けるので広告の出しわけがFlutterの実装に委ねられる。広告が再生するときに、VideoPlayer側の動画を一時停止する仕組みなため、「広告再生中だけど本編も流れている」や「広告再生中なのに広告動画が表示されない」などの不具合が懸念される。
  • 広告側のコントローラーがないので広告再生中のプログレスバーが出せない(今後追加されそう)

まとめ

今回はFlutterで広告再生する方法について解説しました。 interactive_media_adsは実装コストも少ないのでいいなと思ったものの 最近リリースされたパッケージであり、まだまだ発展途上なのかなと思いました。
Flutter公式が作ってるのでIMA SDKの公式ドキュメントにもいずれFlutterでの実装方法が掲載されるといいなと思います。

動画系アプリにとって動画内広告は一番大きな収益源だと思うので、このパッケージがさらに発展し、動画系アプリ開発の敷居を下げることができればいいなと思います!

お知らせ

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!可茂IT塾のエンジニアの判断で、一定以上のスキルをを習得した方には有給でのインターンも受け入れています。

Read More
U30可茂ITインターンハッカソン

U30可茂ITインターンハッカソン

12月28,29日開催。2日間でアプリ開発の企画から完成までを目指す!U30可茂ITインターンハッカソンを開催します。

Read More

タグ

Flutter (120)初心者向け (30)イベント (19)Google Apps Script (17)Nextjs (12)可茂IT塾 (10)React (8)Firebase (7)riverpod (6)ChatGPT (5)vscode (5)デザイン (5)新卒 (4)就活 (4)Figma (4)Dart (4)JavaScript (4)お知らせ (4)FlutterWeb (3)Prisma (3)NestJS (3)Slack (3)TypeScript (3)ワーケーション (3)インターン (3)設計 (2)線型計画法 (2)事例 (2)Git (2)Image (2)File (2)Material Design (2)経験談 (2)画像 (2)iOS (2)アプリ開発 (2)React Hooks (2)tailwindcss (2)社会人 (2)大学生 (2)RSS (1)Google (1)Web (1)CodeRunner (1)個人開発 (1)Android (1)Unity (1)WebView (1)Twitter (1)フルリモート (1)TextScaler (1)textScaleFactor (1)学生向け (1)supabase (1)Java (1)Spring Boot (1)shell script (1)正規表現 (1)table (1)テーブル (1)hooks (1)react (1)パワーポイント (1)趣味 (1)モンスターボール (1)CSS (1)SCSS (1)Swift (1)MapBox (1)Cupertino (1)ListView (1)就活浪人 (1)既卒 (1)保守性 (1)iPad (1)シェアハウス (1)スクレイピング (1)PageView (1)画面遷移 (1)flutter_hooks (1)Gmail (1)GoogleWorkspace (1)ShaderMask (1)google map (1)Google Places API (1)GCPコンソール (1)Google_ML_Kit (1)Vercel (1)Google Domains (1)DeepLeaning (1)深層学習 (1)Google Colab (1)コード生成 (1)GitHub Copilot (1)オンラインオフィス (1)javascript (1)css (1)html (1)オブジェクト指向 (1)クラスの継承 (1)ポリモーフィズム (1)LINE Messaging API (1)LINE Notify (1)LINE (1)Bitcoin (1)bitFlyer (1)コミュニティー (1)文系エンジニア (1)build_runner (1)freezed (1)Freezed (1)ヒーター (1)作業効率 (1) (1)Flutter実践開発 (1) (1)permission_handler (1)flutter_local_notifications (1)markdown (1)GlobalKey (1)ValueKey (1)Key (1)アイコン (1)go_router (1)FireStorage (1)debug (1)datetime_picker (1)Apple Store Connect (1)FlutterGen (1)デバッグ (1)Widget Inspector (1)VRChat (1)API (1)検索機能 (1)Shader (1)Navigator (1)メール送信 (1)FlutterFlow (1)Firebase App Distribution (1)Fastlane (1)Dio (1)CustomClipper (1)ClipPath (1)video_player (1)IMA (1)カスタム認証 (1)アニメーション (1)Arduino (1)ESP32 (1)フリーランス (1)会社員 (1)mac (1)csv (1)docker (1)GithubActions (1)Dialog (1)BI (1)LifeHack (1)ショートカット (1)Chrome (1)高校生 (1)キャリア教育 (1)非同期処理 (1)生体認証 (1)BackdropFilter (1)レビュー (1)getAuth (1)クローズドテスト (1)PlayConsole (1)Algolia (1)コンサルティング (1)Symbol (1)

お知らせ

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!可茂IT塾のエンジニアの判断で、一定以上のスキルをを習得した方には有給でのインターンも受け入れています。

Read More
U30可茂ITインターンハッカソン

U30可茂ITインターンハッカソン

12月28,29日開催。2日間でアプリ開発の企画から完成までを目指す!U30可茂ITインターンハッカソンを開催します。

Read More