11단계 동영상 플레이어

송기영·2023년 12월 17일
0

플러터

목록 보기
13/25

핸드폰에 저장된 동영상을 재생하는 동영상 플레이어를 구현해본다.

11.1. 준비하기

11.1.1. 동영상 파일 넣기

샘플 동영상 파일은 안드로이드 에뮬레이터에 전송한다.

Android Studio 실행 - View - Tool Windows - Device File Expoler - sdcard - Download 폴더 오른쪽 클릭 - Upload순으로 파일 업로드 진행

에뮬레이터의 Files 앱 실행 - 내장 메모리 - Download 선택 후 파일 확인

11.1.2. 권한 추가하기

  • IOS
// Info.plist
<key>NSPhotoLibraryUsageDescriptption</key>
<string>갤러리 권한을 허가해주세요.</string>
  • Android
<manifest>
	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
</manifest>

11.2. 구현하기

11.2.1. HomeScreen 위젯 구현하기

import "package:flutter/material.dart";
import "package:image_picker/image_picker.dart";
import "package:vid_player/component/custom_video_player.dart";

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
  
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  // 동영상 저장 변수
  XFile? video;

  
  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()
        ],
      ),
    );
  }

  void onNewVideoPressed() async {
    final video = await ImagePicker().pickVideo(source: ImageSource.gallery);

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

  BoxDecoration getBoxDecoration() {
    return BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          Color(0xFF2A3A7C),
          Color(0xFF000118),
        ],
      ),
    );
  }

  Widget renderVideo() {
    return Center(
      // video가 null일경우 ! 처리함
      child: CustomVideoPlayer(video: video!),
    );
  }
}

class _Logo extends StatelessWidget {
  final GestureTapCallback onTap;
  const _Logo({required this.onTap, super.key});
  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Image.asset(
        "asset/img/logo.png",
      ),
    );
  }
}

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

  
  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,
        ),
        Text(
          "PLAYER",
          style: textStyle.copyWith(
              // 폰트 두께 변경
              fontWeight: FontWeight.w700),
        )
      ],
    ));
  }
}

⚠️주의: 여기서 minSdk버전이 20 버전 이하를 사용할 경우 다음과 같은 에러가 발생한다. Multidex support is required for your android app to build since the number of methods has exceeded 64k.이때는 21버전 이상으로 변경해주거나 따로 Multidex를 설정해주어야 한다. 자세한내용은 안드로이드 스튜디오 공식 링크를 확인하면 된다.

11.2.2. CustomVideoPlayer 위젯 구현하기

import 'dart:io';

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

class CustomVideoPlayer extends StatefulWidget {
  // 선택한 동영상을 저장할 변수
  final XFile video;
  const CustomVideoPlayer({required this.video, super.key});

  State<CustomVideoPlayer> createState() => _CustomVideoPlayer();
}

class _CustomVideoPlayer extends State<CustomVideoPlayer> {
  VideoPlayerController? videoController;

  
  void initState() {
    super.initState();
    initializeController();
  }

  initializeController() async {
    final videoController = VideoPlayerController.file(File(widget.video.path));
    await videoController.initialize();
    setState(() {
      this.videoController = videoController;
    });
  }

  
  Widget build(BuildContext context) {
    if (videoController == null) {
      return Center(
        child: CircularProgressIndicator(),
      );
    }

    return AspectRatio(
      aspectRatio: videoController!.value.aspectRatio,
      child: VideoPlayer(videoController!),
    );
  }
}

11.2.3. Slider 위젯 동영상과 연동하기

동영상의 현재 재생 위치와 연동하기

// lib/component/custom_video_player.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:vid_player/component/custom_icon_button.dart';
import 'package:video_player/video_player.dart';

class CustomVideoPlayer extends StatefulWidget {
  // 선택한 동영상을 저장할 변수
  final XFile video;
  final GestureTapCallback onNewVideoPressed;
  const CustomVideoPlayer(
      {required this.video, required this.onNewVideoPressed, super.key});

  State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}

class _CustomVideoPlayerState extends State<CustomVideoPlayer> {
  VideoPlayerController? videoController;

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

    if (oldWidget.video.path != widget.video.path) {
      initializeController();
    }
  }

  
  void initState() {
    super.initState();
    initializeController();
  }

  initializeController() async {
    // StatefulWidget에서 선언된 변수 접근 widget를 사용
    final videoController = VideoPlayerController.file(File(widget.video.path));
    await videoController.initialize();

    // initState가 build시 한번만 실행되기 때문에 listener를 추가해줘 setState()를 발생시켜 build가 추가적으로 실행되도록 함
    videoController.addListener(videoControllerListener);
    setState(() {
      this.videoController = videoController;
    });
  }

  void videoControllerListener() {
    setState(() {});
  }

  
  void dispose() {
    videoController?.removeListener(videoControllerListener);
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    if (videoController == null) {
      return Center(
        child: CircularProgressIndicator(),
      );
    }

    return AspectRatio(
      aspectRatio: videoController!.value.aspectRatio,
      child: Stack(
        children: [
          VideoPlayer(videoController!),
          Positioned(
            bottom: 0,
            right: 0,
            left: 0,
            child: Slider(
              // 슬라이더가 이동할 때마다 실행할 함수
              onChanged: (double val) {
                videoController!.seekTo(Duration(seconds: val.toInt()));
              },
              // 동영상 재생 위치를 초 단위로 표현
              value: videoController!.value.position.inSeconds.toDouble(),
              min: 0,
              max: videoController!.value.duration.inSeconds.toDouble(),
            ),
          ),
          Align(
            // 오른쪽 위 새 동영상 아이콘 위치
            alignment: Alignment.topRight,
            child: CustomIconButton(
              onPressed: widget.onNewVideoPressed,
              iconData: Icons.photo_camera_back,
            ),
          ),
          Align(
            // 동영상 재생 관련 아이콘 중앙에 위치
            alignment: Alignment.center,
            child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  CustomIconButton(
                      onPressed: onReversePressed, iconData: Icons.rotate_left),
                  CustomIconButton(
                      onPressed: onPlayPressed,
                      iconData: videoController!.value.isPlaying
                          ? Icons.pause
                          : Icons.play_arrow),
                  CustomIconButton(
                      onPressed: onForwardPressed, iconData: Icons.rotate_right)
                ]),
          )
        ],
      ),
    );
  }

  void onReversePressed() {
    final currentPosition = videoController!.value.position;
    Duration position = Duration(); // 0초로 위치 초기화
    if (currentPosition.inSeconds > 3) {
      position = currentPosition - Duration(seconds: 3);
    }
    videoController!.seekTo(position);
  }

  void onForwardPressed() {
    // 동영상 길이
    final maxPosition = videoController!.value.duration;
    final currentPosition = videoController!.value.position;
    Duration position = maxPosition;

    // 동영상 최대 길이에서 3초를 뺀 값보다 현재 위치가 짧을 때만 3초 더하기
    if ((maxPosition - Duration(seconds: 3)).inSeconds >
        currentPosition.inSeconds) {
      position = currentPosition + Duration(seconds: 3);
    }

    videoController!.seekTo(position);
  }

  void onPlayPressed() {
    if (videoController!.value.isPlaying) {
      videoController!.pause();
    } else {
      videoController!.play();
    }
  }
}
// lib/component/custom_icon_button.dart

import 'package:flutter/material.dart';

class CustomIconButton extends StatelessWidget {
  final GestureTapCallback onPressed;
  final IconData iconData;
  const CustomIconButton(
      {required this.onPressed, required this.iconData, super.key});

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

11.2.4. 컨트롤러 감추기 기능 만들기

화면을 한번 탭하면 컨트롤이 숨겨지고 다시 탭하면 컨트롤이 올라올 수 있도록 추가

// lib/component/custom_video_player.dart
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:vid_player/component/custom_icon_button.dart';
import 'package:video_player/video_player.dart';

class CustomVideoPlayer extends StatefulWidget {
  // 선택한 동영상을 저장할 변수
  final XFile video;
  final GestureTapCallback onNewVideoPressed;
  const CustomVideoPlayer(
      {required this.video, required this.onNewVideoPressed, super.key});

  State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}

class _CustomVideoPlayerState extends State<CustomVideoPlayer> {
  bool showControls = true;
  VideoPlayerController? videoController;

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

    if (oldWidget.video.path != widget.video.path) {
      initializeController();
    }
  }

  
  void initState() {
    super.initState();
    initializeController();
  }

  initializeController() async {
    // StatefulWidget에서 선언된 변수 접근 widget를 사용
    final videoController = VideoPlayerController.file(File(widget.video.path));
    await videoController.initialize();

    // initState가 build시 한번만 실행되기 때문에 listener를 추가해줘 setState()를 발생시켜 build가 추가적으로 실행되도록 함
    videoController.addListener(videoControllerListener);
    setState(() {
      this.videoController = videoController;
    });
  }

  void videoControllerListener() {
    setState(() {});
  }

  
  void dispose() {
    videoController?.removeListener(videoControllerListener);
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    if (videoController == null) {
      return Center(
        child: CircularProgressIndicator(),
      );
    }

    return GestureDetector(
      onTap: () {
        setState(() {
          showControls = !showControls;
        });
      },
      child: AspectRatio(
        aspectRatio: videoController!.value.aspectRatio,
        child: Stack(
          children: [
            VideoPlayer(videoController!),
            if (showControls)
              Container(
                color: Colors.black.withOpacity(0.5),
              ),
            Positioned(
              bottom: 0,
              right: 0,
              left: 0,
              child: Slider(
                // 슬라이더가 이동할 때마다 실행할 함수
                onChanged: (double val) {
                  videoController!.seekTo(Duration(seconds: val.toInt()));
                },
                // 동영상 재생 위치를 초 단위로 표현
                value: videoController!.value.position.inSeconds.toDouble(),
                min: 0,
                max: videoController!.value.duration.inSeconds.toDouble(),
              ),
            ),
            if (showControls)
              Align(
                // 오른쪽 위 새 동영상 아이콘 위치
                alignment: Alignment.topRight,
                child: CustomIconButton(
                  onPressed: widget.onNewVideoPressed,
                  iconData: Icons.photo_camera_back,
                ),
              ),
            if (showControls)
              Align(
                // 동영상 재생 관련 아이콘 중앙에 위치
                alignment: Alignment.center,
                child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      CustomIconButton(
                          onPressed: onReversePressed,
                          iconData: Icons.rotate_left),
                      CustomIconButton(
                          onPressed: onPlayPressed,
                          iconData: videoController!.value.isPlaying
                              ? Icons.pause
                              : Icons.play_arrow),
                      CustomIconButton(
                          onPressed: onForwardPressed,
                          iconData: Icons.rotate_right)
                    ]),
              )
          ],
        ),
      ),
    );
  }

  void onReversePressed() {
    final currentPosition = videoController!.value.position;
    Duration position = Duration(); // 0초로 위치 초기화
    if (currentPosition.inSeconds > 3) {
      position = currentPosition - Duration(seconds: 3);
    }
    videoController!.seekTo(position);
  }

  void onForwardPressed() {
    // 동영상 길이
    final maxPosition = videoController!.value.duration;
    final currentPosition = videoController!.value.position;
    Duration position = maxPosition;

    // 동영상 최대 길이에서 3초를 뺀 값보다 현재 위치가 짧을 때만 3초 더하기
    if ((maxPosition - Duration(seconds: 3)).inSeconds >
        currentPosition.inSeconds) {
      position = currentPosition + Duration(seconds: 3);
    }

    videoController!.seekTo(position);
  }

  void onPlayPressed() {
    if (videoController!.value.isPlaying) {
      videoController!.pause();
    } else {
      videoController!.play();
    }
  }
}

11.2.5. 타임스탬프 추가하기

어느정도 동영상이 실행이 되고 있는지 표시

// lib/component/custom_video_player.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:vid_player/component/custom_icon_button.dart';
import 'package:video_player/video_player.dart';

class CustomVideoPlayer extends StatefulWidget {
  // 선택한 동영상을 저장할 변수
  final XFile video;
  final GestureTapCallback onNewVideoPressed;
  const CustomVideoPlayer(
      {required this.video, required this.onNewVideoPressed, super.key});

  State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}

class _CustomVideoPlayerState extends State<CustomVideoPlayer> {
  bool showControls = true;
  VideoPlayerController? videoController;

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

    if (oldWidget.video.path != widget.video.path) {
      initializeController();
    }
  }

  
  void initState() {
    super.initState();
    initializeController();
  }

  initializeController() async {
    // StatefulWidget에서 선언된 변수 접근 widget를 사용
    final videoController = VideoPlayerController.file(File(widget.video.path));
    await videoController.initialize();

    // initState가 build시 한번만 실행되기 때문에 listener를 추가해줘 setState()를 발생시켜 build가 추가적으로 실행되도록 함
    videoController.addListener(videoControllerListener);
    setState(() {
      this.videoController = videoController;
    });
  }

  void videoControllerListener() {
    setState(() {});
  }

  
  void dispose() {
    videoController?.removeListener(videoControllerListener);
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    if (videoController == null) {
      return Center(
        child: CircularProgressIndicator(),
      );
    }

    return GestureDetector(
      onTap: () {
        setState(() {
          showControls = !showControls;
        });
      },
      child: AspectRatio(
        aspectRatio: videoController!.value.aspectRatio,
        child: Stack(
          children: [
            VideoPlayer(videoController!),
            if (showControls)
              Container(
                color: Colors.black.withOpacity(0.5),
              ),
            Positioned(
                bottom: 0,
                right: 0,
                left: 0,
                child: Padding(
                  padding: EdgeInsets.symmetric(horizontal: 8.0),
                  child: Row(
                    children: [
                      renderTimeTextFromDuration(
                          videoController!.value.position),
                      // 슬라이더가 남는 공간을 모두 차지하도록 구현
                      Expanded(
                        child: Slider(
                          // 슬라이더가 이동할 때마다 실행할 함수
                          onChanged: (double val) {
                            videoController!
                                .seekTo(Duration(seconds: val.toInt()));
                          },
                          // 동영상 재생 위치를 초 단위로 표현
                          value: videoController!.value.position.inSeconds
                              .toDouble(),
                          min: 0,
                          max: videoController!.value.duration.inSeconds
                              .toDouble(),
                        ),
                      ),
                      renderTimeTextFromDuration(
                          videoController!.value.duration),
                    ],
                  ),
                )),
            if (showControls)
              Align(
                // 오른쪽 위 새 동영상 아이콘 위치
                alignment: Alignment.topRight,
                child: CustomIconButton(
                  onPressed: widget.onNewVideoPressed,
                  iconData: Icons.photo_camera_back,
                ),
              ),
            if (showControls)
              Align(
                // 동영상 재생 관련 아이콘 중앙에 위치
                alignment: Alignment.center,
                child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      CustomIconButton(
                          onPressed: onReversePressed,
                          iconData: Icons.rotate_left),
                      CustomIconButton(
                          onPressed: onPlayPressed,
                          iconData: videoController!.value.isPlaying
                              ? Icons.pause
                              : Icons.play_arrow),
                      CustomIconButton(
                          onPressed: onForwardPressed,
                          iconData: Icons.rotate_right)
                    ]),
              )
          ],
        ),
      ),
    );
  }

  Widget renderTimeTextFromDuration(Duration duration) {
    // padLeft 최소길이를 2로두고 2보다 작으면 왼쪽에 0을 붙여주는 작업
    return Text(
        "${duration.inMinutes.toString().padLeft(2, "0")}:${(duration.inSeconds % 60).toString().padLeft(2, "0")}",
        style: TextStyle(color: Colors.white));
  }

  void onReversePressed() {
    final currentPosition = videoController!.value.position;
    Duration position = Duration(); // 0초로 위치 초기화
    if (currentPosition.inSeconds > 3) {
      position = currentPosition - Duration(seconds: 3);
    }
    videoController!.seekTo(position);
  }

  void onForwardPressed() {
    // 동영상 길이
    final maxPosition = videoController!.value.duration;
    final currentPosition = videoController!.value.position;
    Duration position = maxPosition;

    // 동영상 최대 길이에서 3초를 뺀 값보다 현재 위치가 짧을 때만 3초 더하기
    if ((maxPosition - Duration(seconds: 3)).inSeconds >
        currentPosition.inSeconds) {
      position = currentPosition + Duration(seconds: 3);
    }

    videoController!.seekTo(position);
  }

  void onPlayPressed() {
    if (videoController!.value.isPlaying) {
      videoController!.pause();
    } else {
      videoController!.play();
    }
  }
}

번외

회면회전하기

화면 회전방법은 사실 어려운건 없었다. OrientationBuilder을 이용해서 처리하면 간편하게 구현이 가능했다.

import "package:flutter/material.dart";
import "package:image_picker/image_picker.dart";
import "package:vid_player/component/custom_video_player.dart";

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
  
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  // 동영상 저장 변수
  XFile? video;

  
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.black,
        body: OrientationBuilder(
            builder: (context, orientation) =>
                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()
        ],
      ),
    );
  }

  void onNewVideoPressed() async {
    final video = await ImagePicker().pickVideo(source: ImageSource.gallery);

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

  BoxDecoration getBoxDecoration() {
    return BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          Color(0xFF2A3A7C),
          Color(0xFF000118),
        ],
      ),
    );
  }

  Widget renderVideo() {
    return Center(
      // video가 null일경우 ! 처리함
      child: CustomVideoPlayer(
          video: video!, onNewVideoPressed: onNewVideoPressed),
    );
  }
}

class _Logo extends StatelessWidget {
  final GestureTapCallback onTap;
  const _Logo({required this.onTap, super.key});
  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Image.asset(
        "asset/img/logo.png",
      ),
    );
  }
}

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

  
  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,
        ),
        Text(
          "PLAYER",
          style: textStyle.copyWith(
              // 폰트 두께 변경
              fontWeight: FontWeight.w700),
        )
      ],
    ));
  }
}

갑작스럽게 코드량이 많아져서 정리하면서 글을 작성하기에는 무리가 있었다... 자세한 코드는 링크로 공유하니 자세한 내용이 궁금하신분들은 vid_player를 확인하시면됩니다.

profile
업무하면서 쌓인 노하우를 정리하는 블로그🚀 풀스택 개발자를 지향하고 있습니다👻

0개의 댓글