핸드폰에 저장된 동영상을 재생하는 동영상 플레이어를 구현해본다.
샘플 동영상 파일은 안드로이드 에뮬레이터에 전송한다.
Android Studio 실행 - View - Tool Windows - Device File Expoler - sdcard - Download 폴더 오른쪽 클릭 - Upload순으로 파일 업로드 진행
에뮬레이터의 Files 앱 실행 - 내장 메모리 - Download 선택 후 파일 확인
// Info.plist
<key>NSPhotoLibraryUsageDescriptption</key>
<string>갤러리 권한을 허가해주세요.</string>
<manifest>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
</manifest>
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를 설정해주어야 한다. 자세한내용은 안드로이드 스튜디오 공식 링크를 확인하면 된다.
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!),
);
}
}
동영상의 현재 재생 위치와 연동하기
// 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));
}
}
화면을 한번 탭하면 컨트롤이 숨겨지고 다시 탭하면 컨트롤이 올라올 수 있도록 추가
// 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();
}
}
}
어느정도 동영상이 실행이 되고 있는지 표시
// 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를 확인하시면됩니다.