- 주요 스택
- video_player 플러그인
- image_picker 플러그인
- Stack 위젯
- AspectRatio 위젯
image_picker
image_picker 를 사용하기 위해서는 pub get 이후, 특정 권한들을 추가해줘야한다. 아이폰의 경우 Info.plist에 key 추가
<key>NSPhotoLibraryUsageDescription</key> <string>사진첩 권한을 허가해주세요.</string> <key>NSCameraUsageDescription</key> <string>카메라 권한을 허가해주세요.</string> <key>NSMicrophoneUsageDescription</key> <string>마이크 권한을 허가해주세요.</string>
decoration (gradient)
color와 decoration 을 동시에 사용할 수는 없고, 같이 사용하려면 decoration 안으로 넣어줘야함
body: Container( width: MediaQuery .of(context) .size .width, decoration: BoxDecoration( gradient: LinearGradient( // gradient 정의 가능 begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.red, Colors.blue, ], ), ),
HomeScreen 디자인
import 'package:flutter/material.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { final textStyle = TextStyle( color: Colors.white, fontSize: 30.0, fontWeight: FontWeight.w300, ); return Scaffold( body: Container( width: MediaQuery.of(context).size.width, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFF2A3A7C), Color(0xFF000118), ], ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Image.asset( 'asset/image/logo.png', ), SizedBox( // padding 대신 사용하는 경우가 많음. (padding은 위젯으로 한번 더 감싸야 하므로) height: 30.0, ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'VIDEO', style: textStyle, ), Text( 'PLAYER', style: textStyle.copyWith( fontWeight: FontWeight.w700, ), //기존 정의된 값은 유지하고, 추가로 값을 세팅하고 싶을때 사용 ), ], ), ], ), ), ); } }
코드 정리
import 'package:flutter/material.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Container( width: MediaQuery.of(context).size.width, decoration: getBoxDecoration(), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _Logo(), SizedBox( height: 30.0, ), _AppName(), ], ), ), ); } BoxDecoration getBoxDecoration() { return BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFF2A3A7C), Color(0xFF000118), ], ), ); } } class _Logo extends StatelessWidget { const _Logo({super.key}); @override Widget build(BuildContext context) { return Image.asset( 'asset/image/logo.png', ); } } 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, ), Text( 'PLAYER', style: textStyle.copyWith( fontWeight: FontWeight.w700, ), //기존 정의된 값은 유지하고, 추가로 값을 세팅하고 싶을때 사용 ), ], ); } }
조건에 따라 다른 위젯 렌더링하기
import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../component/custom_video_player.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { XFile? video; @override Widget build(BuildContext context) { return Scaffold( body: video == null ? renderEmpty() : renderVideo(), // 조건에 따른 다른 위젯 렌더링 ); } Widget renderVideo() { return Center( child: CustomVideoPlayer( video: video!, ), ); } Widget renderEmpty() { return Container( width: MediaQuery.of(context).size.width, decoration: getBoxDecoration(), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _Logo( onTap: onLogoTap, ), SizedBox( height: 30.0, ), _AppName(), ], ), ); } void onLogoTap() async { // 이미지를 골라올때까지 기다려야하므로 async await 사용 final video = await ImagePicker().pickVideo( // image picker 플러그인 사용 source: ImageSource.gallery, // 갤러리부터 가져오도록 ); if (video != null) { // 사용자가 video 를 선택했을때만 setState(() { this.video = video; // Xfile (플러그인 제공)에 등록 }); } } BoxDecoration getBoxDecoration() { return BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFF2A3A7C), Color(0xFF000118), ], ), ); } } class _Logo extends StatelessWidget { final VoidCallback onTap; const _Logo({ required this.onTap, super.key, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Image.asset( 'asset/image/logo.png', ), ); } } 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, ), Text( 'PLAYER', style: textStyle.copyWith( fontWeight: FontWeight.w700, ), //기존 정의된 값은 유지하고, 추가로 값을 세팅하고 싶을때 사용 ), ], ); } }
video player component 만들기
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, }); @override State<CustomVideoPlayer> createState() => _CustomVideoPlayerState(); } class _CustomVideoPlayerState extends State<CustomVideoPlayer> { VideoPlayerController? videoController; @override void initState() { super.initState(); initializeController(); } initializeController() async { videoController = VideoPlayerController.file( File(widget.video.path), ); await videoController!.initialize(); setState(() {}); // controller가 init 되면 build() 호출을 위해 setState실행 } @override Widget build(BuildContext context) { if (videoController == null) { return CircularProgressIndicator(); // init 되지 않았다면 로딩바 } return VideoPlayer( videoController!, ); } }
CustomVideoPlayer 에 버튼 추가하기
버튼을 비디오 플레이어 위젯 위에 올리기 위해서는 stack 위젯을 사용해야한다.
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, }); @override State<CustomVideoPlayer> createState() => _CustomVideoPlayerState(); } class _CustomVideoPlayerState extends State<CustomVideoPlayer> { VideoPlayerController? videoController; @override void initState() { super.initState(); initializeController(); } initializeController() async { videoController = VideoPlayerController.file( File(widget.video.path), ); await videoController!.initialize(); setState(() {}); // controller가 init 되면 build() 호출을 위해 setState실행 } @override Widget build(BuildContext context) { if (videoController == null) { return CircularProgressIndicator(); // init 되지 않았다면 로딩바 } return AspectRatio( aspectRatio: videoController!.value.aspectRatio, // video player 녹화된 비율로 보여줌 child: Stack( children: [ VideoPlayer( videoController!, ), _Controls( onReversePressed: onReversePressed, onPlayPressed: onPlayPressed, onForwardPressed: onForwardPressed, isPlaying: videoController!.value.isPlaying, ), Positioned( // Stack에서 많이 쓰는 방법으로, 위치를 잘 잡을 수 있음 right: 0, child: IconButton( onPressed: () {}, color: Colors.white, iconSize: 30.0, icon: Icon(Icons.photo_camera_back), ), ) ], ), ); } void onReversePressed() { final currentPosition = videoController!.value.position; Duration position = Duration(); if(currentPosition.inSeconds > 3) { position = currentPosition - Duration(seconds: 3); // 영상 재생시간이 3초보다 길경우에만 } videoController!.seekTo(position);//뒤로 3초 가기 } void onPlayPressed() { // 이미 실행중이면 중지 // 실행중이 아니면 실행 setState(() { // 상태에 따라 아이콘이 변경되어야하므로 build() 를 다시 실행해줘야함 if (videoController!.value.isPlaying) { videoController!.pause(); } else { videoController!.play(); } }); } void onForwardPressed() { final maxPosition = videoController!.value.duration; final currentPosition = videoController!.value.position; Duration position = maxPosition; if((maxPosition - Duration(seconds: 3)).inSeconds > currentPosition.inSeconds) { position = currentPosition + Duration(seconds: 3); // 영상 재생시간이 앞으로 3초 미만으로 남았을경우 } videoController!.seekTo(position);//앞으로 3초 가기 } } class _Controls extends StatelessWidget { final VoidCallback onPlayPressed; // 콜백함수는 state 위로 올려주기 final VoidCallback onReversePressed; final VoidCallback onForwardPressed; final bool isPlaying; const _Controls({ required this.onPlayPressed, required this.onReversePressed, required this.onForwardPressed, required this.isPlaying, super.key, }); @override Widget build(BuildContext context) { return Container( color: Colors.black.withOpacity(0.5), // 영상 색깔도 흰색이면 잘 안보일 수 있으므로 Container로 감싼뒤 투명도를 준다. child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ renderIconButton( onPressed: onReversePressed, iconData: Icons.rotate_left, ), renderIconButton( onPressed: onPlayPressed, iconData: isPlaying ? Icons.pause : Icons.play_arrow, ), renderIconButton( onPressed: onForwardPressed, iconData: Icons.rotate_right, ), ], ), ); } Widget renderIconButton({ // 중복 widget 분리 required VoidCallback onPressed, required IconData iconData, }) { return IconButton( onPressed: onPressed, iconSize: 30.0, color: Colors.white, icon: Icon(iconData), ); } }
아래 Slider 추가하기
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, }); @override State<CustomVideoPlayer> createState() => _CustomVideoPlayerState(); } class _CustomVideoPlayerState extends State<CustomVideoPlayer> { VideoPlayerController? videoController; Duration currentPosition = Duration(); @override void initState() { super.initState(); initializeController(); } initializeController() async { videoController = VideoPlayerController.file( File(widget.video.path), ); videoController!.addListener(() async { // videoController 의 값이 변경될때마다 호출되는 콜백함수 전달 final currentPosition = videoController!.value.position; setState(() { this.currentPosition = currentPosition; // 영상이 진행될때마다(position이 변경) }); }); await videoController!.initialize(); setState(() {}); // controller가 init 되면 build() 호출을 위해 setState실행 } @override Widget build(BuildContext context) { if (videoController == null) { return CircularProgressIndicator(); // init 되지 않았다면 로딩바 } return AspectRatio( aspectRatio: videoController!.value.aspectRatio, // video player 녹화된 비율로 보여줌 child: Stack( children: [ VideoPlayer( videoController!, ), _Controls( onReversePressed: onReversePressed, onPlayPressed: onPlayPressed, onForwardPressed: onForwardPressed, isPlaying: videoController!.value.isPlaying, ), _NewVideo( onPressed: onNewVideoPressed, ), Positioned( bottom: 0, right: 0, left: 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( children: [ Text( /* * padLeft : 왼쪽에다 글자로 패딩을 넣는다. * ex) 최소 2개를 보여주고 글자가 모자라면 0을 보여줌 * 60 으로 나눠주는 이유는 60초를 넘어가면 안되기 때문 * */ '${currentPosition.inMinutes}: ${(currentPosition.inSeconds % 60).toString().padLeft(2, '0')}', // 진행된 시간 style: TextStyle( color: Colors.white, ), ), Expanded( child: Slider( // slider value: currentPosition.inSeconds.toDouble(), // slider의 기준이 되는 값 onChanged: (double val) { // 슬라이더를 움직일때마다 video도 같이 따라가도록 videoController!.seekTo(Duration( seconds: val.toInt(), )); }, max: videoController!.value.duration.inSeconds.toDouble(), // 초 단위의 영상 전체 길이 min: 0, ), ), Text( '${videoController!.value.duration.inMinutes}: ${(videoController!.value.duration.inSeconds % 60).toString().padLeft(2, '0')}', style: TextStyle( color: Colors.white, ), ), ], ), ), ), ], ), ); } void onNewVideoPressed() {} void onReversePressed() { final currentPosition = videoController!.value.position; Duration position = Duration(); if (currentPosition.inSeconds > 3) { position = currentPosition - Duration(seconds: 3); // 영상 재생시간이 3초보다 길경우에만 } videoController!.seekTo(position); //뒤로 3초 가기 } void onPlayPressed() { // 이미 실행중이면 중지 // 실행중이 아니면 실행 setState(() { // 상태에 따라 아이콘이 변경되어야하므로 build() 를 다시 실행해줘야함 if (videoController!.value.isPlaying) { videoController!.pause(); } else { videoController!.play(); } }); } void onForwardPressed() { final maxPosition = videoController!.value.duration; final currentPosition = videoController!.value.position; Duration position = maxPosition; if ((maxPosition - Duration(seconds: 3)).inSeconds > currentPosition.inSeconds) { position = currentPosition + Duration(seconds: 3); // 영상 재생시간이 앞으로 3초 미만으로 남았을경우 } videoController!.seekTo(position); //앞으로 3초 가기 } } class _Controls extends StatelessWidget { final VoidCallback onPlayPressed; // 콜백함수는 state 위로 올려주기 final VoidCallback onReversePressed; final VoidCallback onForwardPressed; final bool isPlaying; const _Controls({ required this.onPlayPressed, required this.onReversePressed, required this.onForwardPressed, required this.isPlaying, super.key, }); @override Widget build(BuildContext context) { return Container( color: Colors.black.withOpacity(0.5), // 영상 색깔도 흰색이면 잘 안보일 수 있으므로 Container로 감싼뒤 투명도를 준다. child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ renderIconButton( onPressed: onReversePressed, iconData: Icons.rotate_left, ), renderIconButton( onPressed: onPlayPressed, iconData: isPlaying ? Icons.pause : Icons.play_arrow, ), renderIconButton( onPressed: onForwardPressed, iconData: Icons.rotate_right, ), ], ), ); } Widget renderIconButton({ // 중복 widget 분리 required VoidCallback onPressed, required IconData iconData, }) { return IconButton( onPressed: onPressed, iconSize: 30.0, color: Colors.white, icon: Icon(iconData), ); } } class _NewVideo extends StatelessWidget { final VoidCallback onPressed; const _NewVideo({ required this.onPressed, super.key, }); @override Widget build(BuildContext context) { return Positioned( // Stack에서 많이 쓰는 방법으로, 위치를 잘 잡을 수 있음 right: 0, child: IconButton( onPressed: onPressed, color: Colors.white, iconSize: 30.0, icon: Icon(Icons.photo_camera_back), ), ); } }
탭할때만 컨트롤러가 보이도록
child: GestureDetector( onTap: (){ setState(() { showControls = !showControls; }); }, child: Stack( children: [ VideoPlayer( videoController!, ), if (showControls) _Controls( onReversePressed: onReversePressed, onPlayPressed: onPlayPressed, onForwardPressed: onForwardPressed, isPlaying: videoController!.value.isPlaying, ), if (showControls) _NewVideo( onPressed: onNewVideoPressed, ), _SliderBottom( currentPosition: currentPosition, maxPosition: videoController!.value.duration, onSliderChanged: onSliderChanged) ], ), ),
GestureDetector 로 감싸고, 탭을 했을때 bool 값을 변화시켜 컨트롤러를 보여줄지 말지 결정한다.
영상 다시 고르기 버튼 (우 상단)
이미 스플래시에서 해당 기능을 구현했으므로 외부에서 콜백함수를 받을 수 있도록 구조를 변경한다. 또 영상을 다시 고르면 기존 controller 와 새로운 영상이 싱크가 되지 않기때문에 controller를 다시 init()해줘야한다.
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; final VoidCallback onNewVideoPressed; const CustomVideoPlayer({ required this.video, required this.onNewVideoPressed, super.key, }); @override State<CustomVideoPlayer> createState() => _CustomVideoPlayerState(); } class _CustomVideoPlayerState extends State<CustomVideoPlayer> { VideoPlayerController? videoController; Duration currentPosition = Duration(); bool showControls = false; @override void initState() { super.initState(); initializeController(); } /* * 우상단 버튼으로 다시 영상을 골랐을때 controller 가 다시 init()되지 않으므로 * 현재 Contoller는 state가 init될때만 init이 되므로, 내부 위젯이 변경되었을때 해당 영상의 controller를 다시 init 해줘야함 * */ @override void didUpdateWidget(covariant CustomVideoPlayer oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.video.path != widget.video.path) { initializeController(); } } initializeController() async { currentPosition = Duration(); videoController = VideoPlayerController.file( File(widget.video.path), ); videoController!.addListener(() async { // videoController 의 값이 변경될때마다 호출되는 콜백함수 전달 final currentPosition = videoController!.value.position; setState(() { this.currentPosition = currentPosition; // 영상이 진행될때마다(position이 변경) }); }); await videoController!.initialize(); setState(() {}); // controller가 init 되면 build() 호출을 위해 setState실행 } @override Widget build(BuildContext context) { if (videoController == null) { return CircularProgressIndicator(); // init 되지 않았다면 로딩바 } return AspectRatio( aspectRatio: videoController!.value.aspectRatio, // video player 녹화된 비율로 보여줌 child: GestureDetector( onTap: () { setState(() { showControls = !showControls; }); }, child: Stack( children: [ VideoPlayer( videoController!, ), if (showControls) _Controls( onReversePressed: onReversePressed, onPlayPressed: onPlayPressed, onForwardPressed: onForwardPressed, isPlaying: videoController!.value.isPlaying, ), if (showControls) _NewVideo( onPressed: widget.onNewVideoPressed, ), _SliderBottom( currentPosition: currentPosition, maxPosition: videoController!.value.duration, onSliderChanged: onSliderChanged) ], ), ), ); } void onSliderChanged(double val) { videoController!.seekTo(Duration( seconds: val.toInt(), )); } void onNewVideoPressed() {} void onReversePressed() { final currentPosition = videoController!.value.position; Duration position = Duration(); if (currentPosition.inSeconds > 3) { position = currentPosition - Duration(seconds: 3); // 영상 재생시간이 3초보다 길경우에만 } videoController!.seekTo(position); //뒤로 3초 가기 } void onPlayPressed() { // 이미 실행중이면 중지 // 실행중이 아니면 실행 setState(() { // 상태에 따라 아이콘이 변경되어야하므로 build() 를 다시 실행해줘야함 if (videoController!.value.isPlaying) { videoController!.pause(); } else { videoController!.play(); } }); } void onForwardPressed() { final maxPosition = videoController!.value.duration; final currentPosition = videoController!.value.position; Duration position = maxPosition; if ((maxPosition - Duration(seconds: 3)).inSeconds > currentPosition.inSeconds) { position = currentPosition + Duration(seconds: 3); // 영상 재생시간이 앞으로 3초 미만으로 남았을경우 } videoController!.seekTo(position); //앞으로 3초 가기 } } class _Controls extends StatelessWidget { final VoidCallback onPlayPressed; // 콜백함수는 state 위로 올려주기 final VoidCallback onReversePressed; final VoidCallback onForwardPressed; final bool isPlaying; const _Controls({ required this.onPlayPressed, required this.onReversePressed, required this.onForwardPressed, required this.isPlaying, super.key, }); @override Widget build(BuildContext context) { return Container( color: Colors.black.withOpacity(0.5), height: MediaQuery.of(context).size.height, // 영상 색깔도 흰색이면 잘 안보일 수 있으므로 Container로 감싼뒤 투명도를 준다. child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ renderIconButton( onPressed: onReversePressed, iconData: Icons.rotate_left, ), renderIconButton( onPressed: onPlayPressed, iconData: isPlaying ? Icons.pause : Icons.play_arrow, ), renderIconButton( onPressed: onForwardPressed, iconData: Icons.rotate_right, ), ], ), ); } Widget renderIconButton({ // 중복 widget 분리 required VoidCallback onPressed, required IconData iconData, }) { return IconButton( onPressed: onPressed, iconSize: 30.0, color: Colors.white, icon: Icon(iconData), ); } } class _NewVideo extends StatelessWidget { final VoidCallback onPressed; const _NewVideo({ required this.onPressed, super.key, }); @override Widget build(BuildContext context) { return Positioned( // Stack에서 많이 쓰는 방법으로, 위치를 잘 잡을 수 있음 right: 0, child: IconButton( onPressed: onPressed, color: Colors.white, iconSize: 30.0, icon: Icon(Icons.photo_camera_back), ), ); } } class _SliderBottom extends StatelessWidget { final Duration currentPosition; final Duration maxPosition; final ValueChanged<double> onSliderChanged; const _SliderBottom({ required this.currentPosition, required this.maxPosition, required this.onSliderChanged, super.key, }); @override Widget build(BuildContext context) { return Positioned( bottom: 0, right: 0, left: 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( children: [ Text( /* * padLeft : 왼쪽에다 글자로 패딩을 넣는다. * ex) 최소 2개를 보여주고 글자가 모자라면 0을 보여줌 * 60 으로 나눠주는 이유는 60초를 넘어가면 안되기 때문 * */ '${currentPosition.inMinutes}: ${(currentPosition.inSeconds % 60).toString().padLeft(2, '0')}', // 진행된 시간 style: TextStyle( color: Colors.white, ), ), Expanded( child: Slider( // slider value: currentPosition.inSeconds.toDouble(), // slider의 기준이 되는 값 onChanged: onSliderChanged, max: maxPosition.inSeconds.toDouble(), // 초 단위의 영상 전체 길이 min: 0, ), ), Text( '${maxPosition.inMinutes}: ${(maxPosition.inSeconds % 60).toString().padLeft(2, '0')}', style: TextStyle( color: Colors.white, ), ), ], ), ), ); } }