플러터(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"/>
...
프로젝트 파일 구조

개인적인 편의상 소스 코드(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() 함수를 이용하여 동영상이 변경되었을 때 처리를 하였다.