Flutterで動画の音声をNow Playingで再生する

こんにちは、Pentagonでアプリ開発している鈴木です。

Flutter製動画再生アプリに、通知バーやロック画面から操作できる「Now Playing」機能を追加したいという要件がありました。

今回は、すでにアプリに導入済みだった video_player をパッケージをそのまま使って、 audio_session / audio_service を組み合わせることで、iOS・Androidでの動画音声のバックグラウンド再生Now Playingの表示・制御を実装しました。

実装方法を調査する中で、flutter_playout というパッケージも見つかりましたが、最終更新が4年前と古く、現行環境で安定して使えるか不透明だったのと、プレイヤーごと差し替えるのは不具合や手戻りのリスクが高いと判断し、採用は見送りました。

個人的にハマった部分もあったので、本記事では実装手順とつまずきポイントもあわせてご紹介します。

【こんな人に読んでほしい】

  • Flutterで動画再生アプリを作っていて、Now Playingに対応したい
  • 動画の音声をアプリがバックグラウンドの状態でも再生したい
  • video_player / audio_session / audio_service の使い方を整理したい

【この記事を読むメリット】

  • FlutterでNow Playing + バックグラウンド音声再生の構成を理解できる
  • OSごとの差異(Android/iOS)や落とし穴への対処がわかる
  • 必要最小限のコードで実装まで到達できる

【結論】

video_player / audio_session / audio_serviceの組み合わせでFlutterでNow Playingの表示・制御を実装することは可能です。既存のvideo_playerを維持したまま、通知バーやロック画面からの再生操作まで問題なくカバーできました。

【この記事の前提】

  • video_player 2.10.0
  • audio_service: 0.18.18
  • audio_session: 0.2.2
  • iOS/Android両対応
  • Flutter 3.29.0 + Dart 3.7以上

※バックグラウンド再生に関する内容以外のvideo_playerの実装方法については省きます。
※この記事は、筆者の実際の開発体験をもとに一部生成AIで執筆しております。

目次

プラットフォームごとの設定

Android

android/app/src/main/AndroidManifest.xml に以下を追加します:

<manifest …>

  <!-- Now Playing 用パーミッション -->
  <uses-permission android:name="android.permission.WAKE_LOCK"/>
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>

  <application …>

    <!-- MainActivity を AudioServiceActivity に差し替え -->
    <activity
        android:name="com.ryanheise.audioservice.AudioServiceActivity"
        android:exported="true"
        android:launchMode="singleTask"
        tools:node="replace">
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
    </activity>

    <!-- Foregroundサービス(Now Playing 本体) -->
    <service
        android:name="com.ryanheise.audioservice.AudioService"
        android:foregroundServiceType="mediaPlayback"
        android:exported="true"
        tools:node="replace">
      <intent-filter>
        <action android:name="android.media.browse.MediaBrowserService"/>
      </intent-filter>
    </service>

    <!-- メディアボタン受信 -->
    <receiver
        android:name="com.ryanheise.audioservice.MediaButtonReceiver"
        android:exported="true"
        tools:node="replace">
      <intent-filter>
        <action android:name="android.intent.action.MEDIA_BUTTON"/>
      </intent-filter>
    </receiver>

    <!-- …中略… -->

  </application>
</manifest>

iOS設定

ios/Runner/Info.plist に以下を追加します:

<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
</array>

実装

main.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:audio_session/audio_session.dart';
import 'package:audio_service/audio_service.dart';
import 'package:video_player/video_player.dart';
import 'video_audio_handler.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 1. AudioSession を構成
  final session = await AudioSession.instance;
  await session.configure(
    AudioSessionConfiguration(
      avAudioSessionCategory: AVAudioSessionCategory.playback,
    ),
  );
  await session.setActive(true);

  // 2. VideoPlayerController の用意
  final controller = VideoPlayerController.networkUrl(
    Uri.parse(
      'https://example.com/video.mp4',
    ),
    videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true),
  );
  await controller.initialize();

  // 3. AudioHandler を起動
  final handler = await AudioService.init(
    builder: () => VideoAudioHandler(controller),
    config: const AudioServiceConfig(
      androidNotificationChannelId: 'com.example.player.channel.audio',
      androidNotificationChannelName: 'Now Playing',
      androidStopForegroundOnPause: false,
    ),
  );

  runApp(MyApp(handler, controller));
}

class MyApp extends StatelessWidget {
  final AudioHandler handler;
  final VideoPlayerController controller;

  const MyApp(this.handler, this.controller, {super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Now Playing Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: VideoPlayerScreen(controller: controller, handler: handler),
    );
  }
}

class VideoPlayerScreen extends StatefulWidget {
  final VideoPlayerController controller;
  final AudioHandler handler;

  const VideoPlayerScreen({
    super.key,
    required this.controller,
    required this.handler,
  });

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

class _VideoPlayerScreenState extends State<VideoPlayerScreen>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    widget.controller.addListener(() => setState(() {}));
  }

   // iOS でバックグラウンド遷移時に自動pauseされるのでその対策
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) async {
    if (!widget.controller.value.isPlaying) {
      return;
    }

    if (Platform.isIOS && state == AppLifecycleState.paused) {
      await Future.delayed(Duration(milliseconds: 100));
      widget.controller.play();
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    widget.controller.dispose();
    super.dispose();
  }

  String _formatDuration(Duration duration) {
    String two(int n) => n.toString().padLeft(2, '0');
    final h = two(duration.inHours);
    final m = two(duration.inMinutes.remainder(60));
    final s = two(duration.inSeconds.remainder(60));
    return duration.inHours > 0 ? '$h:$m:$s' : '$m:$s';
  }

  @override
  Widget build(BuildContext context) {
    // (video_player の UI 実装)
  }
}

video_audio_handler.dart

class VideoAudioHandler extends BaseAudioHandler
    with SeekHandler {
  final VideoPlayerController _player;

  VideoAudioHandler(this._player) {
    // 動画タイトルなどを設定
    mediaItem.add(
      const MediaItem(
        id: 'video_001',
        title: 'Sample Movie',
        artUri: Uri.parse('https://example.com/thumbnail.jpg'),
      ),
    );

    // video_player 側の状態を監視
    _player.addListener(() {
      final playing = _player.value.isPlaying;
      playbackState.add(
        playing
            ? const PlaybackState.playing()
            : const PlaybackState.paused(),
      );
    });
  }

  // OS からの play/pause を受け取る
  @override
  Future<void> play() async {
    await _player.play();
  }

  @override
  Future<void> pause() async {
    await _player.pause();
  }

  @override
  Future<void> seek(Duration position) async {
    await _player.seekTo(position);
  }
}

一番ハマったところ

video_player_androidが2.8.5以上でないとバックグラウンド再生できない

実装を終えて、iOSでは問題なく動作したのですが、Androidでバックグラウンドに移行した瞬間に再生が停止する現象に遭遇しました。Now Playingもバックグラウンド状態でのみ操作が無効となっていたため、動画のバックグラウンド再生自体が無効になっていると思い、権限周りやVideoPlayerOptionsの追記などを試しましたが、解消しませんでした。

調査を進める中で、そもそもAndroid側のvideo_player(video_player_android)でallowBackgroundPlayback: trueが機能していないというissueを発見し、その後、video_player_androidのChangelogに「2.8.5: Restores background playback support.」と記載があるのを見つけ、video_playerを2.10.0 にアップデートしたところ、Androidでも正常にバックグラウンド再生が行えるようになり、Now Playing経由の操作もできるようになりました。

つまづきポイントまとめ

Android

  • バックグラウンド再生されない
    → allowBackgroundPlayback: true が効かないバージョンが存在する。video_player_android 2.8.5以上にする。

  • NowPlaying起動時にSecurityExceptionが発生する
    → FOREGROUND_SERVICE_MEDIA_PLAYBACKパーミッションを宣言できているか確認する。

  • The Activity class declared in your AndroidManifest.xml is wrong or has not provided the correct FlutterEngine. とエラーが出る
    → AndroidManifest.xmlに必要な設定をすべて追記できているか確認する。

iOS

  • バックグラウンド再生されない
    → Info.plistにUIBackgroundModesaudioを追加しているか確認する。

  • バックグラウンド移行時に再生が停止する
    → didChangeAppLifecycleStateで自動pause対策を入れることで解決。

  • ミュートスイッチで音が消える
    → AVAudioSessionCategory.playback の設定が漏れていないか確認する。

まとめ

元々実装されていたvideo_playerを活かしつつ、audio_sessionaudio_serviceを組み合わせることで、アプリにNow Playing機能を追加することができました。参考になる記事があまり見つからず、当初はかなり苦戦するかと思っていたのですが、実際にやってみると意外とシンプルな構成で済んだ印象です(Androidだけバックグラウンド再生が止まる件では数時間溶けましたが)。
OSごとにネイティブ実装を挟まず、Flutterだけで完結できたのもよかったです。
Now Playing対応を検討する際は、ぜひ参考にしてみてください。

採用情報はこちら
目次