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