Flutter – 동영상 플레이어 (토이 프로젝트)

  • 주요 스택
    • 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,
              ),
            ),
          ],
        ),
      ),
    );
  }
}