플러터(Flutter) 예제(Example) – 동영상 플레이어 | video_player, image_picker 플러그인 사용

플러터(Flutter) 예제(Example)로 동영상 플레이어를 만들어 보자.

필수 사용 플러터 플러그인은 video_player, image_picker이다.

네이티브(Native) 권한 설정

갤러리에서 동영상을 불러오기위해 안드로이드와 iOS에 권한을 추가해 주어야 한다.

iOS 권한 추가

iOS 권한은 Info.plist 파일에 추가한다. NSPhtoLibraryUsageDescription 권한을 등록해 주어야 갤러리 파일 접근이 가능하다.

...
	<key>NSPhotoLibraryUsageDescription</key>
	<string>갤러리 권한을 허가해주세요.</string>
</dict>
</plist>

안드로이드 권한 추가

안드로이드 권한은 AndroidManifest.xml 파일에 추가할 수 있다. android.permission.READ_EXTERNAL_STORAGE 권한을 추가하면 갤러이 파일을 읽을 수 있다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
...

프로젝트 파일 구조

myplayer-file-structure
프로젝트 파일 구조

개인적인 편의상 소스 코드(Dart) 파일들을 Root와 Screen, Component로 분류했다.

root에는 앱의 시작인 main.dart만 두고 screen 폴더에 랜딩 페이지인 HomeScreen을 두었다. 그 외 HomeScreen에서 사용되는 여러 부품 위젯들은 Component 폴더에 두었다.

구현

main.dart

import 'package:flutter/material.dart';
import 'package:my_player/screen/home_screen.dart';

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

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My Player',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: HomeScreen(),
    );
  }
}

screen/home_screen.dart

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:my_player/Component//app_name.dart';
import 'package:my_player/Component/my_video_player.dart';

import '../Component//logo.dart';

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

  @override
  State<StatefulWidget> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  // image_picker 플러그인에서 XFile 클래스 형태로 동영상을 받는다.
  XFile? video;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: video == null ? renderEmpty() : renderVideo(),
    );
  }

  // 선택된 동영상이 없는 경우
  Widget renderEmpty() {
    return Container(
      width: MediaQuery.of(context).size.width,
      decoration: getBoxDecoration(),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Logo(onTap: onNewVideoPressed),
          SizedBox(height: 30.0),
          AppName(),
        ],
      ),
    );
  }

  BoxDecoration getBoxDecoration() {
    return BoxDecoration(
      // 그라데이션으로 색상 적용하기
      gradient: LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [Color(0xFF2A3A7D), Color(0xFF000119)],
      ),
    );
  }

  // 비디오 파일을 선택 해서 가져올 수 있게 ImagePicker를 사용한 함수
  void onNewVideoPressed() async {
    final video = await ImagePicker().pickVideo(source: ImageSource.gallery);

    if (video != null) {
      setState(() {
        this.video = video;
      });
    }
  }

  // 선택된 동영상이 있는 경우
  Widget renderVideo() {
    return Center(
      child: MyVideoPlayer(video: video!, onTapNewVideo: onNewVideoPressed,),
    );
  }
}

image_picker 플러그인의 ImagePicker 클래스를 이용하여 XFile 클래스 형태로 동영상을 변수(메모리)에 저장한다.

선택된 동영상이 없으면 renderEmpty() 함수가 반환하는 위젯을 보여주고 있으면 renderVideo() 함수의 반환 위젯을 표시한다.

Component/app_name.dart

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

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

  @override
  Widget build(BuildContext context) {
    final textStyle = TextStyle(
      color: Colors.white,
      fontSize: 30.0,
      fontWeight: FontWeight.w300,
    );

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('VIDEO', style: textStyle),
        // textStule에서 두께만 700으로 변경
        Text('PLAYER', style: textStyle.copyWith(fontWeight: FontWeight.w700)),
      ],
    );
  }
}

앱 이름을 표시하는 간단한 텍스트 위젯을 갖고 있는 클래스이다.

Component/logo.dart

import 'package:flutter/cupertino.dart';

class Logo extends StatelessWidget {
  // 탭했을 때 호출될 함수
  final GestureTapCallback onTap;

  const Logo({super.key, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Image.asset('asset/img/logo.png'),
    );
  }
}

onTap GestureTabCallback을 이용하여 동영상을 선택하는 처리를 갖고 있는 간단한 로고 이미지 클래스이다.

Component/my_icon_button.dart

import 'package:flutter/material.dart';

class MyIconButton extends StatelessWidget {
  final GestureTapCallback onTap;
  final IconData iconData;

  const MyIconButton({super.key, required this.onTap, required this.iconData});

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: onTap,
      icon: Icon(iconData),
      iconSize: 30.0,
      color: Colors.white,
    );
  }
}

onTap GestureTabCallBack을 구현한 간단한 아이콘 버튼 클래스이다.

Component/my_video_player.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:my_player/Component/my_icon_button.dart';
import 'package:video_player/video_player.dart';

class MyVideoPlayer extends StatefulWidget {
  final XFile video;
  final GestureTapCallback onTapNewVideo;

  const MyVideoPlayer({
    super.key,
    required this.video,
    required this.onTapNewVideo,
  });

  @override
  State<StatefulWidget> createState() => _MyVideoPlayerState();
}

class _MyVideoPlayerState extends State<MyVideoPlayer> {
  bool showController = false;
  VideoPlayerController? videoPlayerController;

  // convariant 키워드는 MyVideoPlayer 클래스의 상속된 값도 허가해준다.
  @override
  void didUpdateWidget(covariant MyVideoPlayer oldWidget) {
    super.didUpdateWidget(oldWidget);

    // 새로 선택한 동영상이 같은 동영상이 아니면 새 동영상 정보로 실행
    if (oldWidget.video.path != widget.video.path) {
      unInitializeController();
      initializeController();
    }
  }

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

    initializeController();
  }

  initializeController() async {
    final videoController = VideoPlayerController.file(File(widget.video.path));

    await videoController.initialize();
    videoController.addListener(videoPlayerControllerListener);

    setState(() {
      videoPlayerController = videoController;
    });
  }

  unInitializeController() async {
    videoPlayerController?.removeListener(videoPlayerControllerListener);
    await videoPlayerController?.dispose();
  }

  void videoPlayerControllerListener() {
    setState(() {});
  }

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

  @override
  Widget build(BuildContext context) {
    if (videoPlayerController == null) {
      return Center(child: CircularProgressIndicator());
    }

    return GestureDetector(
      onTap: () {
        setState(() {
          showController = !showController;
        });
      },
      child: AspectRatio(
        aspectRatio: videoPlayerController!.value.aspectRatio,
        child: Stack(
          children: [
            VideoPlayer(videoPlayerController!),
            if (showController)
              Container(color: Colors.black.withValues(alpha: 0.5)),
            // child 위젯의 위치 설정이 가능한 위젯
            Positioned(
              left: 0,
              right: 0,
              bottom: 0,
              child: Padding(
                padding: EdgeInsets.symmetric(horizontal: 8.0),
                child: Row(
                  children: [
                    renderTimeText(videoPlayerController!.value.position),
                    Expanded(
                      child: Slider(
                        // 동영상 재생 위치 표시 (초단위)
                        value: videoPlayerController!.value.position.inSeconds
                            .toDouble(),
                        min: 0,
                        max: videoPlayerController!.value.duration.inSeconds
                            .toDouble(),
                        // 슬라이더 이동시 호출되는 핸들러
                        onChanged: (double val) {
                          videoPlayerController!.seekTo(
                            Duration(seconds: val.toInt()),
                          );
                        },
                      ),
                    ),
                    renderTimeText(videoPlayerController!.value.duration),
                  ],
                ),
              ),
            ),
            if (showController)
              Align(
                alignment: Alignment.topRight,
                // 동영상 선택
                child: MyIconButton(
                  onTap: widget.onTapNewVideo,
                  iconData: Icons.photo_camera_back,
                ),
              ),
            if (showController)
              Align(
                alignment: Alignment.center,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    // 뒤로
                    MyIconButton(onTap: onTapBack, iconData: Icons.rotate_left),
                    // 재생/일시 정지
                    MyIconButton(
                      onTap: onTapPlay,
                      iconData: videoPlayerController!.value.isPlaying
                          ? Icons.pause
                          : Icons.play_arrow,
                    ),
                    // 앞으로
                    MyIconButton(
                      onTap: onTapFoward,
                      iconData: Icons.rotate_right,
                    ),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget renderTimeText(Duration duration) {
    return Text(
      '${duration.inMinutes.toString().padLeft(2, '0')}: ${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
      style: TextStyle(color: Colors.white),
    );
  }

  void onTapBack() {
    final currPos = videoPlayerController!.value.position;
    Duration pos = Duration();

    if (currPos.inSeconds > 3) {
      pos = currPos - Duration(seconds: 3);
    }

    videoPlayerController!.seekTo(pos);
  }

  void onTapFoward() {
    final maxPos = videoPlayerController!.value.duration;
    final currPos = videoPlayerController!.value.position;
    Duration pos = Duration();

    if ((maxPos - Duration(seconds: 3)).inSeconds > currPos.inSeconds) {
      pos = currPos + Duration(seconds: 3);
    }

    videoPlayerController!.seekTo(pos);
  }

  void onTapPlay() {
    if (videoPlayerController!.value.isPlaying) {
      videoPlayerController!.pause();
    } else {
      videoPlayerController!.play();
    }
  }
}

선택된 동영상을 재생, 정지, 되감기, 빨리감기 기능을 가진 이번 프로젝트의 핵심 동영상 재생 클래스이다.

특히 재생 중에 동영상을 변경할 때, unInitializeController() 함수 안의 await videoPlayerController?.dispose(); 함수를 반드시 호출해 주어야 이전에 재생중이던 동영상이 닫힌다.

Stack, Positioned 위젯과 Align 위젯을 사용하여 위젯들의 정렬을 처리하였고 StatefulWidget의 didUpdateWidget() 함수를 이용하여 동영상이 변경되었을 때 처리를 하였다.


참고

티스토리 – 윈도우에 플러터 개발환경 세팅하기

댓글 남기기