import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:video_player/video_player.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
XFile? selectedVideo;
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: selectedVideo != null
? _CustomVideoPlayer(
video: selectedVideo!,
onPickNewVideo: pickNewVideo,
)
: _VideoPicker(onPickVideo: pickNewVideo));
}
pickNewVideo() async {
final video = await ImagePicker().pickVideo(
source: ImageSource.gallery,
);
setState(() {
selectedVideo = video;
});
}
}
class _VideoPicker extends StatelessWidget {
final VoidCallback onPickVideo;
const _VideoPicker({
required this.onPickVideo,
super.key,
});
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF3B4A89),
Color(0xFF000022),
],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LogoWidget(
onTap: onPickVideo,
),
SizedBox(
height: 32.0,
),
_AppTitle(),
],
),
);
}
}
class _LogoWidget extends StatelessWidget {
final VoidCallback onTap;
const _LogoWidget({required this.onTap, super.key});
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Image.asset('assets/images/app_logo.png'),
);
}
}
class _AppTitle extends StatelessWidget {
const _AppTitle({super.key});
Widget build(BuildContext context) {
final textStyle = TextStyle(
color: Colors.white,
fontSize: 32.0,
fontWeight: FontWeight.w300,
);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'VIDEO',
style: textStyle,
),
Text(
'PLAYER',
style: textStyle.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
}
class _CustomVideoPlayer extends StatefulWidget {
final XFile video;
final VoidCallback onPickNewVideo;
const _CustomVideoPlayer({
required this.video,
required this.onPickNewVideo,
super.key,
});
State<_CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}
class _CustomVideoPlayerState extends State<_CustomVideoPlayer> {
late final VideoPlayerController _videoController;
bool showControls = true;
void initState() {
super.initState();
initializeController();
}
didUpdateWidget(covariant _CustomVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.video.path != widget.video.path) {
initializeController();
}
}
initializeController() async {
_videoController = VideoPlayerController.file(
File(
widget.video.path,
),
);
await _videoController.initialize();
_videoController.addListener(() {
setState(() {});
});
}
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
showControls = !showControls;
});
},
child: Center(
child: AspectRatio(
aspectRatio: _videoController.value.aspectRatio,
child: Stack(
children: [
AspectRatio(
aspectRatio: _videoController.value.aspectRatio,
child: VideoPlayer(
_videoController,
),
),
Container(
width: double.infinity,
height: double.infinity,
color: Colors.black.withOpacity(0.5),
),
if (showControls)
_VideoControls(
onRewind: rewindVideo,
onPlayPause: togglePlayPause,
onForward: forwardVideo,
isPlaying: _videoController.value.isPlaying,
),
if (showControls)
_VideoProgress(
position: _videoController.value.position,
duration: _videoController.value.duration,
onSliderChanged: onSliderChanged,
),
if (showControls)
_SelectNewVideoButton(
onPressed: widget.onPickNewVideo,
),
],
),
),
),
);
}
onSliderChanged(double value) {
final newPosition = Duration(seconds: value.toInt());
_videoController.seekTo(newPosition);
}
rewindVideo() {
final currentPosition = _videoController.value.position;
Duration newPosition = Duration();
if (currentPosition.inSeconds > 3) {
newPosition = currentPosition - Duration(seconds: 3);
}
_videoController.seekTo(newPosition);
}
togglePlayPause() {
setState(() {
if (_videoController.value.isPlaying) {
_videoController.pause();
} else {
_videoController.play();
}
});
}
forwardVideo() {
final maxDuration = _videoController.value.duration;
final currentPosition = _videoController.value.position;
Duration newPosition = maxDuration;
if ((maxDuration - Duration(seconds: 3)).inSeconds >
currentPosition.inSeconds) {
newPosition = currentPosition + Duration(seconds: 3);
}
_videoController.seekTo(newPosition);
}
}
class _VideoControls extends StatelessWidget {
final VoidCallback onRewind;
final VoidCallback onPlayPause;
final VoidCallback onForward;
final bool isPlaying;
const _VideoControls({
required this.onForward,
required this.onPlayPause,
required this.onRewind,
required this.isPlaying,
super.key,
});
Widget build(BuildContext context) {
return Align(
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
onPressed: onRewind,
icon: Icon(Icons.rotate_left),
),
IconButton(
color: Colors.white,
onPressed: onPlayPause,
icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
),
),
IconButton(
color: Colors.white,
onPressed: onForward,
icon: Icon(Icons.rotate_right),
),
],
),
);
}
}
class _VideoProgress extends StatelessWidget {
final Duration position;
final Duration duration;
final ValueChanged<double> onSliderChanged;
const _VideoProgress({
required this.position,
required this.duration,
required this.onSliderChanged,
super.key,
});
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 8.0,
),
child: Row(
children: [
Text(
'${position.inMinutes.toString().padLeft(2, '0')}:${(position.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(
color: Colors.white,
),
),
Expanded(
child: Slider(
value: position.inSeconds.toDouble(),
max: duration.inSeconds.toDouble(),
onChanged: onSliderChanged,
),
),
Text(
'${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(
color: Colors.white,
),
),
],
),
),
);
}
}
class _SelectNewVideoButton extends StatelessWidget {
final VoidCallback onPressed;
const _SelectNewVideoButton({required this.onPressed, super.key});
Widget build(BuildContext context) {
return Positioned(
right: 0,
child: IconButton(
color: Colors.white,
onPressed: onPressed,
icon: Icon(
Icons.photo_camera_back,
),
),
);
}
}
물론입니다! 각 설명에 해당하는 코드 부분을 포함해 드리겠습니다.
앱 시작
MainScreen이 처음 로드됩니다.
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
State<MainScreen> createState() => _MainScreenState();
}
비디오가 선택되지 않은 상태로 _VideoPicker 위젯이 보여집니다. 이 화면에서 사용자에게 비디오를 선택하도록 요청합니다.
body: selectedVideo != null
? _CustomVideoPlayer(
video: selectedVideo!,
onPickNewVideo: pickNewVideo,
)
: _VideoPicker(onPickVideo: pickNewVideo));
비디오 선택
_LogoWidget을 탭하면 pickNewVideo() 함수가 호출되어 사용자의 갤러리에서 비디오를 선택할 수 있는 창이 열립니다.pickNewVideo() async {
final video = await ImagePicker().pickVideo(
source: ImageSource.gallery,
);
setState(() {
selectedVideo = video;
});
}selectedVideo 변수에 저장되고, 화면에 _CustomVideoPlayer 위젯이 나타납니다.body: selectedVideo != null
? _CustomVideoPlayer(
video: selectedVideo!,
onPickNewVideo: pickNewVideo,
)
: _VideoPicker(onPickVideo: pickNewVideo));비디오 재생
_CustomVideoPlayer는 비디오를 재생하기 위한 화면을 구성합니다.
class _CustomVideoPlayer extends StatefulWidget {
final XFile video;
final VoidCallback onPickNewVideo;
const _CustomVideoPlayer({
required this.video,
required this.onPickNewVideo,
super.key,
});
State<_CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}
사용자는 화면을 탭하여 컨트롤러 버튼을 보이거나 숨길 수 있습니다.
GestureDetector(
onTap: () {
setState(() {
showControls = !showControls;
});
},
child: Center(
child: AspectRatio(
aspectRatio: _videoController.value.aspectRatio,
child: Stack(
children: [
AspectRatio(
aspectRatio: _videoController.value.aspectRatio,
child: VideoPlayer(
_videoController,
),
),
if (showControls) _VideoControls(...),
if (showControls) _VideoProgress(...),
if (showControls) _SelectNewVideoButton(...),
],
),
),
),
)
새 비디오 선택
오른쪽 상단에 있는 버튼을 통해 새로운 비디오를 선택할 수 있습니다. 이를 통해 사용자는 다른 비디오를 다시 선택하고 재생할 수 있습니다.
class _SelectNewVideoButton extends StatelessWidget {
final VoidCallback onPressed;
const _SelectNewVideoButton({required this.onPressed, super.key});
Widget build(BuildContext context) {
return Positioned(
right: 0,
child: IconButton(
color: Colors.white, // 버튼의 색상을 지정합니다.
onPressed: onPressed, // 버튼이 눌렸을 때 호출되는 콜백 함수입니다. 예를 들어, 사용자가 이 버튼을 눌렀을 때 새로운 비디오를 선택하도록 'pickNewVideo()' 함수를 호출하여 비디오 선택 창이 열리도록 합니다.
icon: Icon(
Icons.photo_camera_back,
),
),
);
}
}
onPressed 콜백: 버튼이 눌렸을 때 실행되는 함수로, 이 콜백이 호출되면 지정된 코드가 실행됩니다. 예를 들어, 오른쪽 상단 버튼을 눌렀을 때 'pickNewVideo()' 함수가 호출되어 사용자가 새로운 비디오를 선택할 수 있는 창이 열립니다.
MainScreen 클래스**MainScreen**은 앱의 진입점이며, 전체 비디오 선택 및 재생 흐름을 관리합니다.
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
State<MainScreen> createState() => _MainScreenState();
}
**_MainScreenState**는 상태를 관리하는 클래스이며, 여기서 비디오 선택, 재생 여부 등의 상태를 다룹니다.
class _MainScreenState extends State<MainScreen> {
XFile? selectedVideo;
Widget build(BuildContext context) {
...
}
pickNewVideo() async {
...
}
}
pickNewVideo()** 함수**는 사용자가 비디오를 선택하도록 ImagePicker를 사용하는 비동기 함수입니다. 선택한 비디오는 selectedVideo 변수에 저장됩니다.
pickNewVideo() async {
final video = await ImagePicker().pickVideo(
source: ImageSource.gallery,
);
setState(() {
selectedVideo = video;
});
}
_VideoPicker 클래스비디오가 선택되지 않았을 때 표시되는 UI를 담당합니다.
class _VideoPicker extends StatelessWidget {
final VoidCallback onPickVideo;
const _VideoPicker({
required this.onPickVideo,
super.key,
});
Widget build(BuildContext context) {
...
}
}
onPickVideo 콜백을 통해 사용자가 비디오 선택을 요청하면 호출됩니다.
_LogoWidget: 사용자에게 비디오를 선택하라는 이미지를 표시하는 위젯입니다. 사용자가 이 이미지를 탭하면 비디오 선택 창이 열립니다.
class _LogoWidget extends StatelessWidget {
final VoidCallback onTap;
const _LogoWidget({required this.onTap, super.key});
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Image.asset('assets/images/app_logo.png'),
);
}
}
_CustomVideoPlayer 클래스비디오 재생을 관리하는 클래스입니다.
class _CustomVideoPlayer extends StatefulWidget {
final XFile video;
final VoidCallback onPickNewVideo;
const _CustomVideoPlayer({
required this.video,
required this.onPickNewVideo,
super.key,
});
State<_CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}
initState(): initState() 메서드는 위젯이 처음 생성될 때 호출되며, 초기 상태를 설정하는 역할을 합니다. 이 코드에서는 비디오 플레이어 컨트롤러를 초기화하기 위해 사용됩니다. 즉, 선택한 비디오 파일을 재생할 준비를 하기 위해 컨트롤러를 설정하는 작업을 initState()에서 수행합니다. 이는 위젯의 생명주기 중 가장 처음에 호출되므로, 비디오가 선택되자마자 자동으로 재생 준비가 이루어지도록 합니다.
void initState() {
super.initState();
initializeController();
}
initializeController(): 비디오 컨트롤러를 초기화하고 선택된 비디오 파일을 재생 준비합니다.
initializeController() async {
_videoController = VideoPlayerController.file(
File(
widget.video.path,
),
);
await _videoController.initialize();
_videoController.addListener(() {
setState(() {});
});
}
didUpdateWidget(): didUpdateWidget이라는 함수는 Flutter에서 위젯이 변경됐을 때 호출되는 함수야. 기본적으로 이 함수는 어떤 위젯이든 다 받을 수 있지만, **우리 코드는 특별히 ****_CustomVideoPlayer**라는 위젯만 다룰 거야! 라고 Dart에게 알려줘야 해. 그래서 **covariant**를 붙여준 거야.
didUpdateWidget(covariant _CustomVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.video.path != widget.video.path) {
initializeController();
}
}
covariant**가 뭐야?covariant는 "이 변수는 특정한 타입으로 더 자세히 다뤄질 거야!"라고 Dart에게 알려주는 특별한 단어야.
예를 들어:
covariant**를 써야 해.covariant**가 뭐 하는 거야?didUpdateWidget이라는 함수는 Flutter에서 위젯이 변경됐을 때 호출되는 함수야. 기본적으로 이 함수는 어떤 위젯이든 다 받을 수 있지만, **우리 코드는 특별히 ****_CustomVideoPlayer**라는 위젯만 다룰 거야! 라고 Dart에게 알려줘야 해. 그래서 **covariant**를 붙여준 거야.
하지만, 우리 코드에서는:\
"아니야, 나한텐 **_CustomVideoPlayer** 위젯만 필요해!"\
라고 말하고 싶어.
covariant를 붙여!covariant는 이렇게 Dart에게 말하는 거야:\
"이 함수는 기본적으로 어떤 Widget이든 받을 수 있어야 하지만, 내가 **_CustomVideoPlayer 위젯**만 쓸 거라는 걸 이해해줘!"
covariant를 쓰는 상황:코드에서 covariant는 **"이 함수는 _CustomVideoPlayer라는 특별한 위젯을 쓸 거야!"**라고 Dart에게 알려주는 거야.\
그 덕분에 oldWidget.video.path 같은 코드를 안전하게 쓸 수 있는 거지.\
만약 covariant를 안 쓰면 Dart는 "그거 Widget인데, 거기에 .video라는 게 있어?" 하면서 에러를 낼 거야. 😊
togglePlayPause(): 비디오 재생 및 일시 정지 토글을 처리합니다.
togglePlayPause() {
setState(() {
if (_videoController.value.isPlaying) {
_videoController.pause();
} else {
_videoController.play();
}
});
}
rewindVideo()** 및 ****forwardVideo()**: 비디오를 3초씩 뒤로 또는 앞으로 이동합니다.
rewindVideo() {
final currentPosition = _videoController.value.position;
Duration newPosition = Duration();
if (currentPosition.inSeconds > 3) {
newPosition = currentPosition - Duration(seconds: 3);
}
_videoController.seekTo(newPosition);
}
forwardVideo() {
final maxDuration = _videoController.value.duration;
final currentPosition = _videoController.value.position;
Duration newPosition = maxDuration;
if ((maxDuration - Duration(seconds: 3)).inSeconds >
currentPosition.inSeconds) {
newPosition = currentPosition + Duration(seconds: 3);
}
_videoController.seekTo(newPosition);
}
onSliderChanged(double value): 슬라이더를 조정했을 때 비디오의 위치를 변경합니다.
onSliderChanged(double value) {
final newPosition = Duration(seconds: value.toInt());
_videoController.seekTo(newPosition);
}
_VideoControls 클래스재생 컨트롤 버튼들을 포함하는 UI를 제공합니다.
class _VideoControls extends StatelessWidget {
final VoidCallback onRewind;
final VoidCallback onPlayPause;
final VoidCallback onForward;
final bool isPlaying;
const _VideoControls({
required this.onForward,
required this.onPlayPause,
required this.onRewind,
required this.isPlaying,
super.key,
});
Widget build(BuildContext context) {
return Align(
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
onPressed: onRewind,
icon: Icon(Icons.rotate_left),
),
IconButton(
color: Colors.white,
onPressed: onPlayPause,
icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
),
),
IconButton(
color: Colors.white,
onPressed: onForward,
icon: Icon(Icons.rotate_right),
),
],
),
);
}
}
_VideoProgress 클래스비디오의 재생 진행 상태를 표시합니다.
class _VideoProgress extends StatelessWidget {
final Duration position;
final Duration duration;
final ValueChanged<double> onSliderChanged;
const _VideoProgress({
required this.position,
required this.duration,
required this.onSliderChanged,
super.key,
});
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 8.0,
),
child: Row(
children: [
Text(
'\${position.inMinutes.toString().padLeft(2, '0')}:${(position.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(
color: Colors.white,
),
),
Expanded(
child: Slider(
value: position.inSeconds.toDouble(),
max: duration.inSeconds.toDouble(),
onChanged: onSliderChanged,
),
),
Text(
'\${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(
color: Colors.white,
),
),
],
),
),
);
}
}
_SelectNewVideoButton 클래스새로운 비디오를 선택할 수 있는 아이콘 버튼입니다.
class _SelectNewVideoButton extends StatelessWidget {
final VoidCallback onPressed;
const _SelectNewVideoButton({required this.onPressed, super.key});
Widget build(BuildContext context) {
return Positioned(
right: 0,
child: IconButton(
color: Colors.white,
onPressed: onPressed,
icon: Icon(
Icons.photo_camera_back,
),
),
);
}
}