Flutterアプリケーション開発概論

video_playerで動画を再生する

この節で学ぶこと

前回の 4-6 では、PageView.builder を使って、TikTokのように上下スワイプで画面を切り替える仕組みを作りました。

前回の段階では、まだ本物の動画は再生していません。

代わりに、次のような仮の動画エリアを表示していました。

PET
FOOD
ROOM

今回の 4-7 では、いよいよ本物の動画を再生します。

DartPadでも動かしやすいように、Flutter公式が公開している .mp4 動画URLを使います。

この節で大切なのは、次の一文です。

動画を再生するには、video_playerパッケージとVideoPlayerControllerを使う。

まず今回作るもの

この節では、次のような画面を作ります。

前回までの VideoPlaceholder を、本物の動画表示に置き換えます。

変更前
VideoPlaceholder

変更後
VideoBackground

最終的には、TikTok風アプリの背景として動画を全画面表示します。

video_playerとは何か

video_player は、Flutterで動画を再生するためのパッケージです。

パッケージとは、Flutterに機能を追加するための部品です。

Flutter本体だけでも多くのUIは作れますが、動画再生のような機能は、専用のパッケージを使うと便利です。

package = Flutterに機能を追加する道具
video_player = 動画再生機能を追加するパッケージ

DartPadで使う場合は、Packagesに次を追加します。

video_player: ^2.9.2

新しい言葉:VideoPlayerControllerとは何か

動画を再生するには、VideoPlayerController を使います。

Controller とは、何かを操作・管理するためのものです。

今回の場合は、動画を操作します。

VideoPlayerController
├─ 動画を読み込む
├─ 動画を再生する
├─ 動画を停止する
├─ 音量を設定する
└─ ループ再生を設定する

コードでは、次のように作ります。

final controller = VideoPlayerController.networkUrl(
  Uri.parse(video.videoUrl),
);

これは、次の意味です。

video.videoUrl の動画を再生するためのControllerを作る。

新しい言葉:initializeとは何か

動画は、URLを指定しただけではすぐに使えません。

まず、動画を読み込んで、再生できる準備をする必要があります。

その準備をするのが initialize です。

await controller.initialize();

初心者向けには、次のように理解してください。

initialize = 動画を使う準備をする処理

動画のサイズや長さなどを取得するためにも、initialize が必要です。

新しい言葉:Futureとasync / await

動画の読み込みには時間がかかります。

そのため、Dartでは Futureasyncawait という書き方を使います。

Future<void> initializeVideo() async {
  await controller.initialize();
}

ここでは、深く考えすぎなくて大丈夫です。

まずは次の理解で進めます。

用語初心者向けの意味
Futureすぐには終わらない処理
async時間がかかる処理を書く印
await処理が終わるまで待つ

動画読み込みは時間がかかるので、await controller.initialize() と書きます。

新しい言葉:StatefulWidgetが必要になる理由

前回までの画面は、表示するだけでした。

しかし、動画再生では次のような「変化」があります。

動画を読み込み中
↓
読み込み完了
↓
再生する
↓
一時停止する

このように、画面の状態が変わるため、StatefulWidget を使います。

StatelessWidget
表示が変わらない部品

StatefulWidget
状態が変わる部品

動画再生では、VideoPlayerController を作り、読み込み完了後に画面を更新する必要があります。

そのため、今回のメイン画面は StatefulWidget にします。

まずは1本の動画を再生する

最初から縦スワイプに戻すと難しいので、まずは1本の動画だけを再生してみます。

DartPadのPackagesに video_player: ^2.9.2 を追加したうえで、次のコードを貼り付けてください。(最初から入っている場合あり)

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

void main() {
  runApp(const VideoPlayerPracticeApp());
}

class VideoPlayerPracticeApp extends StatelessWidget {
  const VideoPlayerPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: VideoPlayerPracticePage(),
    );
  }
}

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

  @override
  State<VideoPlayerPracticePage> createState() =>
      _VideoPlayerPracticePageState();
}

class _VideoPlayerPracticePageState extends State<VideoPlayerPracticePage> {
  late final VideoPlayerController controller;

  bool isReady = false;

  @override
  void initState() {
    super.initState();
    initializeVideo();
  }

  Future<void> initializeVideo() async {
    controller = VideoPlayerController.networkUrl(
      Uri.parse(
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
      ),
    );

    await controller.initialize();

    controller
      ..setLooping(true)
      ..setVolume(0)
      ..play();

    setState(() {
      isReady = true;
    });
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  void togglePlay() {
    setState(() {
      if (controller.value.isPlaying) {
        controller.pause();
      } else {
        controller.play();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!isReady) {
      return const Scaffold(
        backgroundColor: Colors.black,
        body: Center(
          child: CircularProgressIndicator(
            color: Colors.white,
          ),
        ),
      );
    }

    return Scaffold(
      backgroundColor: Colors.black,
      body: GestureDetector(
        onTap: togglePlay,
        child: Center(
          child: AspectRatio(
            aspectRatio: controller.value.aspectRatio,
            child: VideoPlayer(controller),
          ),
        ),
      ),
    );
  }
}

実行して確認すること

実行すると、黒背景の中央に動画が表示されます。

動画部分をタップすると、一時停止と再生を切り替えられます。

タップ
↓
再生中なら停止

もう一度タップ
↓
停止中なら再生

ここでは、次のことを確認してください。

- video_playerで動画が表示できる
- VideoPlayerControllerで動画を管理している
- initializeが終わるまで読み込み表示を出している
- GestureDetectorでタップ操作を受け取っている

コードを順番に理解する

import

import 'package:video_player/video_player.dart';

これは、video_player パッケージを使うための読み込みです。

このimportがないと、VideoPlayerControllerVideoPlayer が使えません。

controllerを用意する

late final VideoPlayerController controller;

ここでは、動画を管理するcontrollerを用意しています。

late は、「あとで必ず値を入れる」という意味です。

このコードでは、initState の中で initializeVideo() を呼び、その中でcontrollerを作ります。

initStateとは何か

@override
void initState() {
  super.initState();
  initializeVideo();
}

initState は、StatefulWidgetが最初に作られたときに一度だけ呼ばれる場所です。

初心者向けには、次のように覚えてください。

initState = 最初に一度だけ準備をする場所

動画再生では、最初にcontrollerを作り、動画を読み込む必要があります。

そのため、initStateinitializeVideo() を呼びます。

動画を読み込む

controller = VideoPlayerController.networkUrl(
  Uri.parse(
    'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
  ),
);

これは、ネット上の動画URLを指定してcontrollerを作っています。

networkUrl は、インターネット上の動画を再生するときに使います。

initializeする

await controller.initialize();

これは、動画を使える状態にする処理です。

動画の読み込みが終わるまで待ちます。

ループ、音量、再生

controller
  ..setLooping(true)
  ..setVolume(0)
  ..play();

この書き方は少し見慣れないかもしれません。

.. は、同じcontrollerに対して続けて処理を書くための記法です。

普通に書くと、次と同じです。

controller.setLooping(true);
controller.setVolume(0);
controller.play();

それぞれの意味は次の通りです。

コード意味
setLooping(true)繰り返し再生する
setVolume(0)音量を0にする
play()再生する

DartPadやブラウザでは、自動再生の制限があるため、音量を0にしておくと再生されやすくなります。

setStateで画面を更新する

setState(() {
  isReady = true;
});

setState は、状態が変わったことをFlutterに知らせるための関数です。

ここでは、動画の準備が終わったので、isReadytrue にしています。

isReady = false
↓
読み込み中画面を表示

isReady = true
↓
動画画面を表示

setState を呼ぶことで、Flutterが画面を再描画します。

disposeでcontrollerを片付ける

@override
void dispose() {
  controller.dispose();
  super.dispose();
}

dispose は、Widgetが不要になったときに呼ばれる場所です。

動画controllerは、使い終わったら片付ける必要があります。

初心者向けには、次のように覚えてください。

dispose = 使い終わったものを片付ける場所

動画controllerを片付けないと、メモリを無駄に使ってしまうことがあります。

VideoPlayerで動画を表示する

VideoPlayer(controller)

VideoPlayer は、実際に動画を画面に表示するWidgetです。

ただし、VideoPlayer はcontrollerが必要です。

VideoPlayerController
↓
動画を管理する

VideoPlayer
↓
動画を画面に表示する

この2つはセットで考えると分かりやすいです。

AspectRatioとは何か

AspectRatio(
  aspectRatio: controller.value.aspectRatio,
  child: VideoPlayer(controller),
)

AspectRatio は、縦横比を保つためのWidgetです。

動画には、それぞれ縦横比があります。

横長の動画を無理に正方形にすると、映像がつぶれて見えます。

AspectRatio を使うと、動画本来の比率で表示できます。

初心者向けには、次のように理解してください。

AspectRatio = 縦横比を守って表示するWidget

GestureDetectorでタップを受け取る

GestureDetector(
  onTap: togglePlay,
  child: Center(
    child: AspectRatio(...),
  ),
)

GestureDetector は、タップなどの操作を検知するWidgetです。

ここでは、動画をタップすると togglePlay が呼ばれます。

void togglePlay() {
  setState(() {
    if (controller.value.isPlaying) {
      controller.pause();
    } else {
      controller.play();
    }
  });
}

この処理は、次の意味です。

もし再生中なら停止する
そうでなければ再生する

次に全画面背景として動画を表示する

TikTok風アプリでは、動画は中央に小さく表示するのではなく、画面全体に広げます。

そのため、次は FittedBox を使います。

新しい言葉:FittedBoxとは何か

FittedBox は、子Widgetを指定した範囲に合わせて拡大・縮小するWidgetです。

FittedBox(
  fit: BoxFit.cover,
  child: ...
)

BoxFit.cover は、画面全体を埋めるように表示する指定です。

動画の一部が切れることはありますが、TikTok風の全画面背景に近い見た目になります。

BoxFit.cover
↓
画面全体を埋める
↓
必要なら一部を切り取る

全画面背景として動画を表示するコード

次は、先ほどのコードをTikTok風の全画面背景に近づけます。

DartPadに次のコードを貼り付けてください。

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

void main() {
  runApp(const FullScreenVideoPracticeApp());
}

class FullScreenVideoPracticeApp extends StatelessWidget {
  const FullScreenVideoPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: FullScreenVideoPracticePage(),
    );
  }
}

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

  @override
  State<FullScreenVideoPracticePage> createState() =>
      _FullScreenVideoPracticePageState();
}

class _FullScreenVideoPracticePageState
    extends State<FullScreenVideoPracticePage> {
  late final VideoPlayerController controller;

  bool isReady = false;

  @override
  void initState() {
    super.initState();
    initializeVideo();
  }

  Future<void> initializeVideo() async {
    controller = VideoPlayerController.networkUrl(
      Uri.parse(
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
      ),
    );

    await controller.initialize();

    controller
      ..setLooping(true)
      ..setVolume(0)
      ..play();

    setState(() {
      isReady = true;
    });
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  void togglePlay() {
    setState(() {
      if (controller.value.isPlaying) {
        controller.pause();
      } else {
        controller.play();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!isReady) {
      return const Scaffold(
        backgroundColor: Colors.black,
        body: Center(
          child: CircularProgressIndicator(
            color: Colors.white,
          ),
        ),
      );
    }

    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: [
          Positioned.fill(
            child: GestureDetector(
              onTap: togglePlay,
              child: VideoBackground(controller: controller),
            ),
          ),
          const Positioned.fill(
            child: VideoGradientOverlay(),
          ),
          const Positioned(
            top: 52,
            left: 0,
            right: 0,
            child: TopNavigationPractice(),
          ),
          const Positioned(
            left: 16,
            right: 92,
            bottom: 36,
            child: BottomVideoInfoPractice(),
          ),
        ],
      ),
    );
  }
}

class VideoBackground extends StatelessWidget {
  const VideoBackground({
    super.key,
    required this.controller,
  });

  final VideoPlayerController controller;

  @override
  Widget build(BuildContext context) {
    final size = controller.value.size;

    return Container(
      color: Colors.black,
      child: FittedBox(
        fit: BoxFit.cover,
        child: SizedBox(
          width: size.width,
          height: size.height,
          child: VideoPlayer(controller),
        ),
      ),
    );
  }
}

class VideoGradientOverlay extends StatelessWidget {
  const VideoGradientOverlay({super.key});

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.black.withOpacity(0.45),
              Colors.transparent,
              Colors.transparent,
              Colors.black.withOpacity(0.86),
            ],
            stops: const [0.0, 0.24, 0.58, 1.0],
          ),
        ),
      ),
    );
  }
}

class TopNavigationPractice extends StatelessWidget {
  const TopNavigationPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      bottom: false,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'フォロー中',
            style: TextStyle(
              color: Colors.white.withOpacity(0.6),
              fontSize: 15,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(width: 18),
          const Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                'おすすめ',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 16,
                  fontWeight: FontWeight.bold,
                ),
              ),
              SizedBox(height: 4),
              SizedBox(
                width: 28,
                height: 3,
                child: DecoratedBox(
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.all(
                      Radius.circular(999),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class BottomVideoInfoPractice extends StatelessWidget {
  const BottomVideoInfoPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return const SafeArea(
      top: false,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '@pet_cafe_diary',
            style: TextStyle(
              color: Colors.white,
              fontSize: 16,
              fontWeight: FontWeight.bold,
              shadows: [
                Shadow(
                  color: Colors.black87,
                  blurRadius: 8,
                ),
              ],
            ),
          ),
          SizedBox(height: 8),
          Text(
            '小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。',
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              color: Colors.white,
              fontSize: 14,
              height: 1.35,
              shadows: [
                Shadow(
                  color: Colors.black87,
                  blurRadius: 8,
                ),
              ],
            ),
          ),
          SizedBox(height: 10),
          Row(
            children: [
              Icon(
                Icons.music_note_rounded,
                color: Colors.white,
                size: 17,
              ),
              SizedBox(width: 6),
              Expanded(
                child: Text(
                  'Healing Cafe Sound - Pet Cafe Diary',
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 13,
                    fontWeight: FontWeight.w600,
                    shadows: [
                      Shadow(
                        color: Colors.black87,
                        blurRadius: 8,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

実行して確認すること

このコードを実行すると、動画が画面全体の背景として表示されます。

さらに、その上に次のUIが重なっています。

上部:フォロー中 / おすすめ
下部:ユーザー名 / キャプション / 音源情報

ここで確認するポイントは、次の3つです。

1. 動画をVideoPlayerで再生している
2. FittedBox + BoxFit.coverで全画面背景にしている
3. Stackで動画の上に文字を重ねている

これで、かなりTikTok風アプリの見た目に近づきました。

VideoBackgroundの仕組み

VideoBackground は、動画を背景として表示するWidgetです。

class VideoBackground extends StatelessWidget {
  const VideoBackground({
    super.key,
    required this.controller,
  });

  final VideoPlayerController controller;

ここでは、外から VideoPlayerController を受け取っています。

required this.controller,

そして、VideoPlayer(controller) で動画を表示します。

VideoPlayer(controller)

size.widthとsize.height

final size = controller.value.size;

動画には、元のサイズがあります。

そのサイズを取得して、SizedBox に指定しています。

SizedBox(
  width: size.width,
  height: size.height,
  child: VideoPlayer(controller),
)

このようにすることで、動画本来の縦横比を保ちながら、FittedBox で画面全体に広げられます。

グラデーションを重ねる理由

動画の上に白文字を置くと、背景によっては文字が読みにくくなることがあります。

そこで、VideoGradientOverlay を重ねています。

const Positioned.fill(
  child: VideoGradientOverlay(),
),

VideoGradientOverlay は、上と下を少し暗くする半透明のグラデーションです。

動画そのまま
↓
白文字が読みにくいことがある

動画 + 黒グラデーション
↓
白文字が読みやすくなる

TikTok風UIでは、下部に文字が多いため、下を少し暗くすると読みやすくなります。

IgnorePointerとは何か

VideoGradientOverlay では、IgnorePointer を使っています。

return IgnorePointer(
  child: DecoratedBox(...),
);

IgnorePointer は、タップなどの操作を無視するためのWidgetです。

グラデーションは見た目のためだけに重ねています。

もしグラデーションがタップを受け取ってしまうと、動画のタップ操作が邪魔されることがあります。

そこで、IgnorePointer を使います。

初心者向けには、次のように理解してください。

IgnorePointer = 見た目だけ表示して、タップ操作は受け取らないようにするWidget

この節で覚える動画再生の流れ

動画再生の基本の流れは、次の通りです。

1. VideoPlayerControllerを作る
2. initializeで動画を準備する
3. setLoopingでループ設定する
4. setVolumeで音量を設定する
5. playで再生する
6. VideoPlayerで画面に表示する
7. disposeで片付ける

コードで見ると、次の流れです。

controller = VideoPlayerController.networkUrl(
  Uri.parse(videoUrl),
);

await controller.initialize();

controller
  ..setLooping(true)
  ..setVolume(0)
  ..play();

表示するときは、次のようにします。

VideoPlayer(controller)

使い終わったら、次のように片付けます。

controller.dispose();

手を動かす練習1:別の動画に変える

動画URLを次の部分で指定しています。

'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4'

これを次のように変えてみてください。

'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'

動画が変わります。

この練習で、動画URLを変えると表示される動画も変わることを確認できます。

手を動かす練習2:ループを止める

次の部分を探してください。

..setLooping(true)

これを次のように変えてみます。

..setLooping(false)

動画が最後まで再生されると停止します。

TikTok風アプリでは、基本的に繰り返し再生したいので、最終的には true に戻してください。

手を動かす練習3:音量を変える

次の部分を探してください。

..setVolume(0)

これを次のように変えてみます。

..setVolume(0.5)

環境によっては音が出ます。

ただし、DartPadやブラウザでは自動再生の制限があるため、音あり自動再生は止められる場合があります。

学習中は、まず setVolume(0) のままで進めるのがおすすめです。

手を動かす練習4:グラデーションを濃くする

次の部分を探してください。

Colors.black.withOpacity(0.86),

これを次のように変えてみます。

Colors.black.withOpacity(0.95),

画面下部がさらに暗くなります。

下部の文字が読みやすくなる反面、動画は暗く見えます。

このように、UIでは「見やすさ」と「映像の見え方」のバランスを調整します。

よくあるつまずき1:動画が表示されない

動画が表示されない場合、まず次を確認してください。

- DartPadのPackagesにvideo_playerを追加しているか
- import 'package:video_player/video_player.dart'; があるか
- 動画URLが正しいか
- initializeが終わる前にVideoPlayerを表示していないか

今回のコードでは、isReady を使って、初期化が終わるまでローディングを表示しています。

if (!isReady) {
  return const Scaffold(
    backgroundColor: Colors.black,
    body: Center(
      child: CircularProgressIndicator(
        color: Colors.white,
      ),
    ),
  );
}

よくあるつまずき2:initializeを忘れる

VideoPlayerController は、作っただけでは動画を表示できません。

必ず次のように初期化します。

await controller.initialize();

initialize を忘れると、動画のサイズが分からず、正しく表示できないことがあります。

よくあるつまずき3:disposeを忘れる

controllerを作ったら、使い終わったときに片付けます。

@override
void dispose() {
  controller.dispose();
  super.dispose();
}

動画やアニメーションのcontrollerは、dispose で片付ける習慣をつけるとよいです。

よくあるつまずき4:constをつけすぎる

動画controllerのように、状態が変わるものを扱うWidgetでは、すべてに const をつけられるわけではありません。

たとえば、次のようなコードは controller が変数なので、const にはできません。

VideoBackground(controller: controller)

const でエラーになる場合は、外して大丈夫です。

よくあるつまずき5:全画面にならない

動画を全画面背景にするには、次の組み合わせが大切です。

Positioned.fill(
  child: VideoBackground(controller: controller),
)

さらに、VideoBackground の中で FittedBox を使います。

FittedBox(
  fit: BoxFit.cover,
  child: SizedBox(
    width: size.width,
    height: size.height,
    child: VideoPlayer(controller),
  ),
)

この組み合わせで、動画が画面いっぱいに広がります。

この節の確認問題

確認問題1

video_player は何のために使いますか。

答え

Flutterで動画を再生するために使います。

確認問題2

VideoPlayerController は何をするものですか。

答え

動画を読み込んだり、再生・停止したりするための管理役です。

確認問題3

initialize は何のために必要ですか。

答え

動画を使える状態に準備するためです。

動画のサイズなどを取得するためにも必要です。

確認問題4

VideoPlayer は何をするWidgetですか。

答え

VideoPlayerController が管理している動画を、画面に表示するWidgetです。

確認問題5

FittedBoxBoxFit.cover は何のために使いましたか。

答え

動画を画面全体に広げ、全画面背景のように表示するためです。

確認問題6

dispose は何のために使いますか。

答え

使い終わったcontrollerを片付けるためです。

この節のまとめ

この節では、video_player を使って、DartPad上で動画を再生しました。

最初は1本の動画を中央に表示し、その後、TikTok風に全画面背景として表示しました。

動画再生の基本の流れは、次の通りです。

VideoPlayerControllerを作る
↓
initializeで準備する
↓
setLoopingやsetVolumeを設定する
↓
playで再生する
↓
VideoPlayerで表示する
↓
disposeで片付ける

また、TikTok風UIでは、動画を全画面背景として表示し、その上に文字やボタンを重ねます。

Stack
├─ VideoBackground
├─ VideoGradientOverlay
├─ TopNavigation
└─ BottomVideoInfo

この節で一番大切なのは、次の一文です。

VideoPlayerControllerで動画を管理し、VideoPlayerで画面に表示する。

次の節では、今回作った動画再生と、前回学んだ PageView.builder を組み合わせます。

複数の動画を上下スワイプで切り替え、TikTok風アプリの中心部分を作っていきます。

教材トップへ戻る