システム開発部のTです。
前編の続きです。

前回のあらすじ

ブラウザでBGMを再生するための実装を検討したが、iOS版のブラウザ全般で思ったような再生ができなかった。
結局、iOSでの純粋な自動再生は出来そうにないので、別の方法を検討するのだった・・・。

前回のおさらい

前回の記事にて、iOS版での挙動を見ていくと、以下のようなイメージでした。

上記を見てみると、A画面、およびB画面においては、次画面から戻ってくるときに各画面のBGMが再生されます。
想定ですが、A画面からB画面に遷移するときにボタンタップしますが、その作用で再生許可された扱いとなり、次画面から戻ったときのdidPopNext()での再生処理で再生されていると思います。

結局は、初回での自動再生は厳しいようです。
これを踏まえて、対応を検討していくのが本章の議題になります。

自動再生っぽく手動再生

初回での自動再生・・・つまりライフサイクルをトリガーとした自動再生は厳しいと判断し、手動での再生を検討します。しかし、次画面に遷移したあと、ユーザー自身にいちいち再生ボタンをタップしてもらうのも面倒な思いをさせることになります。

そこで、以下のイメージを検討してみました。

ようするに、前画面で画面遷移するときのタップイベントを利用し、前画面でBGMを再生させるというものです。
こうすることで、各画面ではユーザー側としても自動的に再生されているように感じてもらえるかと思います。
この手法で処理を組んでみようと思います。

Playerクラスの実装

BGMを再生させるためのPlayerクラスの実装です。
以前の実装では、画面ごとにPlayerクラスのインスタンスを生成していましたが、
本件を実現するために、アプリ起動時のタイミングでインスタンスを生成し、それを各画面で利用するようにしていきたいと思います。そのため、以前の実装とは異なります。

import 'package:audioplayers/audioplayers.dart';
import 'package:audioplayers/audioplayers_api.dart';

class BgmPlayer {

  late AudioCache _cache;
  AudioPlayer? _player = AudioPlayer();
  String? nowBgmName;
  Map<String, Uri> bgmUriMap = Map<String, Uri>();

  BgmPlayer() {
    _cache = AudioCache(fixedPlayer: _player);
  }

  Future<void> loadBgm() async {
    // 本件ではあらかじめBGMをバッファに保持させておく、
    // 戻り値のURIはファイル名をキーとしてMap内に保持し、再生時に使用する
    bgmUriMap["none_volume_bgm.mp3"] = await _cache.load("bgm/none_volume_bgm.mp3");
    bgmUriMap["title_op_bgm.mp3"] = await _cache.load("bgm/title_op_bgm.mp3");
    bgmUriMap["main_bgm.mp3"] = await _cache.load("bgm/main_bgm.mp3");
    bgmUriMap["category_bgm.mp3"] = await _cache.load("bgm/category_bgm.mp3");
  }

  void playBgm({required String name, bool isLoop = true}) {
    nowBgmName = name;
    if (isLoop) {
      _player?.setReleaseMode(ReleaseMode.LOOP);
    } else {
      _player?.setReleaseMode(ReleaseMode.RELEASE);
    }
    _player?.play(bgmUriMap[name].toString());
    _player?.setVolume(1.0);
  }

  void pauseBgm(String? name) async {
    if (nowBgmName == name) {
      await _player?.pause();
    }
  }

  void stopBgm(String? name) async {
    if (nowBgmName == name) {
      await _player?.stop();
    }
  }

  Future<void> disposeBgm() async {
    return await _player?.dispose();
  }

}

main.dartの変更

main.dartのmain関数にて、アプリ開始時にProviderでBgmPlayerのインスタンスを保持するように変更。

import 'package:bgmtestapp/BgmPlayer.dart';
import 'package:bgmtestapp/InitPage.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'PageA.dart';
import 'PageB.dart';
import 'PageC.dart';

void main() {
  runApp(Provider(
    create: (context) => BgmPlayer(),
    child: MyApp(),
  ));
}

// RouteObserverを利用するので、本件ではクラス外にて定義
var routeObserver = RouteObserver<PageRoute>();

class MyApp extends StatelessWidget {

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialRoute: "/initpage",
      routes: {
        "/initpage": (context) => InitPage(),
        "/pageA": (context) => PageA(),
        "/pageB": (context) => PageB(),
        "/pageC": (context) => PageC(),
      },
      navigatorObservers: [
        routeObserver,
      ],
    );
  }
}

BaseStateクラスの変更

Baseクラスのコンストラクタの自動再生処理を破棄し、Bgmのdisposeをアプリ終了時のみ実行するように変更しました。

import 'dart:async';

import 'package:bgmtestapp/BgmPlayer.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../../main.dart';

abstract class BasePageState<T extends StatefulWidget> extends State<T> with WidgetsBindingObserver, RouteAware {
  BgmPlayer? _bgm;
  // 自身の画面が表に表示されているかのフラグ
  bool isActive = false;
  String? _fileName;

  BgmPlayer? get bgm => _bgm;
  String? get fileName => _fileName;

  // コンストラクタ --------------------------------------------------
  BasePageState({String? fileName}) {
    if (fileName != null && fileName.isNotEmpty) {
      this._fileName = fileName;
    }
  }

  // アプリケーションのライフサイクル start --------------------------------------------------
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      this.onForeground();
    } else if (state == AppLifecycleState.paused) {
      this.onBackground();
    }
  }
  void onBackground() {
    // ホーム画面に切り替わり、バックグラウンド状態のとき、BGMを一時停止する
    this._bgm?.pauseBgm(_fileName);
  }

  void onForeground() {
    if (this.isActive && _fileName != null) {
      // 自分自身の画面がトップに表示されていれば、以降の処理を実施
      // ホーム画面からアプリに戻り、フォアグラウンド状態に戻ったとき、BGMを再生する
      this._bgm?.playBgm(name: _fileName!);
    }
  }
  // アプリケーションのライフサイクル end --------------------------------------------------
  // StatefulWidget(画面)のライフサイクル start --------------------------------------------------
  @override
  void initState() {
    super.initState();
    // アプリの状態遷移をハンドリングできるようにする
    WidgetsBinding.instance?.addObserver(this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _bgm = Provider.of<BgmPlayer>(context);
    // 画面遷移をハンドリングできるようにする
    routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
  }

  @override
  void dispose() {
    // POP、replace時に画面終了する際、後始末をする
    this.isActive = false;
    this._bgm?.pauseBgm(_fileName);
    WidgetsBinding.instance?.removeObserver(this);
    routeObserver.unsubscribe(this);
    super.dispose();
  }
  // StatefulWidget(画面)のライフサイクル end --------------------------------------------------
  // 画面遷移時のライフサイクル start --------------------------------------------------
  @override
  void didPush() {
    // 自身の画面がコールされたときに、isActiveをtrueにする
    this.isActive = true;
    super.didPush();
  }

  @override
  void didPopNext() {
    // 次画面がPOPされ自身の画面に戻ってきたとき、isActiveをtrueにし、BGMを再生させる
    this.isActive = true;
    if (_fileName != null) {
      // 自分自身の画面がトップに表示されていれば、以降の処理を実施
      // ホーム画面からアプリに戻り、フォアグラウンド状態に戻ったとき、BGMを再生する
      this._bgm?.playBgm(name: _fileName!);
    }
    super.didPopNext();
  }

  @override
  void didPushNext() {
    // Pushにて次画面に遷移する際、isActiveをfalseにし、BGMを一時停止させる
    this.isActive = false;
    this._bgm?.pauseBgm(_fileName);
    super.didPushNext();
  }

  // 画面遷移時のライフサイクル end --------------------------------------------------

  // 子クラスにおいては標準のbuildの代わりに実装を強制させる
  // 標準のbuildを利用したい場合、本メソッドの戻り値にnullを設定可能とする
  Widget? buildChildWidget(BuildContext context);

  @override
  Widget build(BuildContext context) {
    return Builder(builder: (context) => WillPopScope(
        child: this.buildChildWidget(context) ?? Text(""), // 上記の抽象メソッドをコール
        onWillPop: () async {
          // 自身の画面がPOPされたときに、Playerクラスの後始末をする
          // Androidでの戻るボタンでアプリを終了させる場合、
          // POP実行前のタイミングで後始末しないとBGMが止まらなくなる・・・
          this.isActive = false;
          this._bgm?.pauseBgm(_fileName);
          if (!Navigator.canPop(context)) {
            // 戻り先が存在しない場合、アプリ終了とみなし、bgmをdisposeする
            this._bgm?.disposeBgm();
          }
          return true;
        })
    );
  }
}

InitPageクラスの変更

最初に呼び出す画面クラスになります。
実装自体は前回の記事の内容と大差ありませんが、「画面Aに進む」ボタン押下時にBGMロード処理が実行され、その後、画面AのBGMの再生、画面Aに遷移という流れで処理します。

import 'package:bgmtestapp/PageA.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class InitPage extends StatefulWidget {
  @override
  _InitPageState createState() {
    return _InitPageState();
  }
}

class _InitPageState extends BasePageState<InitPage> {  // <-- ここの親クラスを「BasePageState」にする

  _InitPageState(): super(fileName: "none_volume_bgm.mp3");  // <-- 無音のメディアファイルを設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("最初の画面"),
              automaticallyImplyLeading: true,
            ),
            body: Container(
                child: Column(
                  children: [
                    Text("次の画面でBGMが鳴ります。"),
                    ElevatedButton(
                      child: Text("画面Aに進む"),
                      style: ElevatedButton.styleFrom(
                        primary: Colors.orange,
                        onPrimary: Colors.white,
                      ),
                      onPressed: () {
                        bgm?.loadBgm().then((_) { // ここでBGMデータの全ロード処理実行
                          bgm?.playBgm(name: PageA.screenBgm); // 画面AのBGM再生
                          Navigator.pushNamed(context, "/pageA"); // 画面Aに遷移
                        });
                      },
                    )
                  ],
                ),
              alignment: Alignment.center,
            )
        )
    );
  }
}

画面A〜Bの画面クラスの変更

基本的にInitPageと同様の変更になります。
画面遷移前に、遷移先のBGMを再生する処理を実装するだけです。
画面Cについては、以前の記事の内容と同様になります。

import 'package:bgmtestapp/PageB.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class PageA extends StatefulWidget {

  static String screenBgm = "title_op_bgm.mp3";

  @override
  _PageAState createState() {
    return _PageAState();
  }
}

class _PageAState extends BasePageState<PageA> {  // <-- ここの親クラスを「BasePageState」にする

  _PageAState(): super(fileName: PageA.screenBgm);  // <-- 親クラスのコンストラクタにファイル名設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("画面A"),
              automaticallyImplyLeading: true,
            ),
            body: Column(
              children: [
                Container(
                  child: ElevatedButton(
                    child: Text("画面Bに進む"),
                    style: ElevatedButton.styleFrom(
                      primary: Colors.orange,
                      onPrimary: Colors.white,
                    ),
                    onPressed: () {
                      bgm?.playBgm(name: PageB.screenBgm);  // 遷移前に画面BのBGMを再生
                      Navigator.pushNamed(context, "/pageB");
                    },
                  ),
                  alignment: Alignment.center,
                )

              ],
            )
        )
    );
  }
}
画面Bの変更
import 'package:bgmtestapp/PageC.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class PageB extends StatefulWidget {

  static String screenBgm = "main_bgm.mp3";

  @override
  _PageBState createState() {
    return _PageBState();
  }
}

class _PageBState extends BasePageState<PageB> {  // <-- ここの親クラスを「BasePageState」にする

  _PageBState(): super(fileName: PageB.screenBgm);  // <-- 親クラスのコンストラクタにファイル名設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("画面B"),
              automaticallyImplyLeading: true,
            ),
            body: Container(
              child: ElevatedButton(
                child: Text("画面Cに進む"),
                style: ElevatedButton.styleFrom(
                  primary: Colors.orange,
                  onPrimary: Colors.white,
                ),
                onPressed: () {
                  bgm?.playBgm(name: PageC.screenBgm);   // 画面C遷移前に、画面CのBGMを再生
                  Navigator.pushNamed(context, "/pageC");
                },
              ),
              alignment: Alignment.center,
            )
        )
    );
  }
}
画面Cのコード(変更なし)
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class PageC extends StatefulWidget {

  static String screenBgm = "category_bgm.mp3";

  @override
  _PageCState createState() {
    return _PageCState();
  }
}

class _PageCState extends BasePageState<PageC> {  // <-- ここの親クラスを「BasePageState」にする

  _PageCState(): super(fileName: PageC.screenBgm);  // <-- 親クラスのコンストラクタにファイル名設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("画面C"),
              automaticallyImplyLeading: true,
            ),
            body: Container(
              child: ElevatedButton(
                child: Text("画面Bに戻る"),
                style: ElevatedButton.styleFrom(
                  primary: Colors.orange,
                  onPrimary: Colors.white,
                ),
                onPressed: () {
                  Navigator.pop(context);
                },
              ),
              alignment: Alignment.center,
            )
        )
    );
  }
}

動作確認

ここまで実装し、動作確認してみましょう。
いかがでしょうか。
今度は画面ごとにBGMが再生されたかと思います。
自動再生ではなく、手動再生ですが、なんとなく自動再生っぽく出来ているかと思います。

以上が、iOS版も含めたブラウザでのBGM再生についての記事でした。

注意事項

本件のBGM再生に対して、注意事項がございます。
タップイベントをトリガーとして再生処理を実施することに変わりありませんが、
以下のコード例では再生処理されません。

ElevatedButton(
                    child: Text("画面Bに進む"),
                    style: ElevatedButton.styleFrom(
                      primary: Colors.orange,
                      onPrimary: Colors.white,
                    ),
                    onPressed: () {
                      // Timer内はスレッド違いになるので、タップイベントとはみなされない
                      Timer(Duration(milliseconds: 100), () {
                          // 100msec後に「自動再生」扱いになってしまうため、再生されない
                          bgm?.playBgm(name: PageB.screenBgm);
                          Navigator.pushNamed(context, "/pageB");
                      });
                    },
                  )

上記と同様、Future()での通知でも同様です。
この辺を考慮して実装していかないと、再生されないことになりますので注意してください。

まとめ

Webブラウザには制限の多い中、本件で取り上げたBGMの自動再生については、正直苦労しました。
他のOSでは問題なくても、iOSは本当に自動再生には頑なに厳しく扱っているということがわかりました。

業務アプリでは、画面ごとにBGMを再生する・・・ということはほとんど無いうえに、WebかつFlutterで・・・というのも過去の記事なかったので、今回はJavascriptで自動再生を実装されている方の記事なんかを参考にして、ここまでやれました。

あまり、ゲーム的なBGMの利用手段で実装する機会は無いとは思いますが、Flutterでこうやった的なものとして、参考程度に見ていただければと思います。

前回の記事のリンクも貼っておきますので、参考にしていただければ幸いです。

Flutterでアプリ開発・画面にBGMを奏でる

Flutterでアプリ開発・ブラウザでBGMを奏でる(前編)



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる