Video Player 실습

pharmDev·2024년 11월 27일
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,
        ),
      ),
    );
  }
}

물론입니다! 각 설명에 해당하는 코드 부분을 포함해 드리겠습니다.

작동 순서

  1. 앱 시작

    • 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));
  2. 비디오 선택

    • 사용자가 _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));
  3. 비디오 재생

    • _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(...),
              ],
            ),
          ),
        ),
      )
  4. 새 비디오 선택

    • 오른쪽 상단에 있는 버튼을 통해 새로운 비디오를 선택할 수 있습니다. 이를 통해 사용자는 다른 비디오를 다시 선택하고 재생할 수 있습니다.

      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,
              ),
            ),
          );
        }
      }
  5. onPressed 콜백: 버튼이 눌렸을 때 실행되는 함수로, 이 콜백이 호출되면 지정된 코드가 실행됩니다. 예를 들어, 오른쪽 상단 버튼을 눌렀을 때 'pickNewVideo()' 함수가 호출되어 사용자가 새로운 비디오를 선택할 수 있는 창이 열립니다.

각 클래스와 함수 설명

1. 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;
      });
    }

2. _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'),
        );
      }
    }

3. _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**를 붙여준 거야.

    예시로 생각해보자!

    1. 기본 상황

    • Dart는 원래 이렇게 생각해:\
      "난 어떤 Widget이든 받을 준비가 돼 있어."

    하지만, 우리 코드에서는:\
    "아니야, 나한텐 **_CustomVideoPlayer** 위젯만 필요해!"\
    라고 말하고 싶어.

    2. 그래서 covariant를 붙여!

    covariant는 이렇게 Dart에게 말하는 거야:\
    "이 함수는 기본적으로 어떤 Widget이든 받을 수 있어야 하지만, 내가 **_CustomVideoPlayer 위젯**만 쓸 거라는 걸 이해해줘!"


    비유로 이해해보자!

    일반적인 상황:

    • Dart는 마치 "난 동물(Widget)을 다룰 수 있어!"라고 말하는 동물병원이야.
    • 그런데 우리는 "우리 병원은 **강아지(_CustomVideoPlayer)**만 치료해!"라고 알려주고 싶어.

    covariant를 쓰는 상황:

    • "내가 동물을 다룰 수 있다는 건 알겠지만, 이 병원에서는 강아지만 다룰 거야."
    • 이렇게 Dart에게 명확하게 알려주는 거지.

    결론적으로

    코드에서 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);
    }

4. _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),
              ),
            ],
          ),
        );
      }
    }

5. _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,
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }

6. _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,
            ),
          ),
        );
      }
    }
profile
코딩을 배우는 초보

0개의 댓글