Never give up

Flutter - Custom Video player(Feat. Transform) 본문

Flutter

Flutter - Custom Video player(Feat. Transform)

대기만성 개발자 2022. 1. 28. 23:48
반응형

많은 유저들이 사용하는 Chewie video player가 없데이트 중이어서

 

다른 패키지 업데이트할 때 충돌이 나기도 하고, 간단한 기능만 사용하는데

 

너무 많은것들(?)이 붙어 있어서 video player를 사용해서 한번 만들어봤습니다

 

video player widget

import 'package:custom_video_player/src/util/enum.dart';
import 'package:custom_video_player/src/util/extension.dart';
import 'package:custom_video_player/src/controller/video_controller.dart';
import 'package:custom_video_player/src/view/full_screen_video.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';

class VideoPlayerWidget extends StatefulWidget {
  const VideoPlayerWidget(
      {Key? key,
      required this.videoController,
      this.videoOption = VideoOption.none,
      this.isFullScreen = false,
      this.placeHolder,
      this.widgetSize})
      : super(key: key);

  final CustomVideoController videoController;
  final VideoOption videoOption;
  final bool isFullScreen;
  final Widget? placeHolder;
  final Size? widgetSize;

  @override
  _VideoPlayerWidgetState createState() => _VideoPlayerWidgetState();
}

class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
  /// video controller
  late CustomVideoController _controller;

  /// 플레이/일시정지 상태변경
  ValueNotifier<PlayerButtonState>? _playButtonNotifier;

  /// 볼륨 상태 변경 true = mute
  ValueNotifier<bool>? _volumeNotifier;

  /// indicator, 시간 갱신
  ValueNotifier<Duration>? _timelineNotifier;

  // ValueNotifier<bool>? _visibilityNotifier;

  late Size _size;

  @override
  void initState() {
    super.initState();
    _controller = widget.videoController;

    // timeline 처리
    if (widget.videoOption == VideoOption.full ||
        widget.videoOption == VideoOption.bottomBarOnly) {
      _volumeNotifier = ValueNotifier(_controller.muteSound);
      _timelineNotifier = ValueNotifier(Duration.zero);
      // _visibilityNotifier = ValueNotifier(true);
    }

    // play/stop 토글
    if (widget.videoOption != VideoOption.none) {
      _playButtonNotifier = ValueNotifier(PlayerButtonState.stopped);

      _controller.addListener(_listener);
    }
  }

  /// timeline slider, text 업데이트용
  void _listener() {
    _timelineNotifier!.value = _controller.getVideoValue.position;

    if (!_controller.isLooping &&
        !_controller.getVideoValue.isPlaying &&
        _controller.getVideoValue.position ==
            _controller.getVideoValue.duration) {
      _playButtonNotifier!.value = PlayerButtonState.stopped;
    }
  }

  @override
  void dispose() {
    _controller.removeListener(_listener);
    _playButtonNotifier?.dispose();
    _volumeNotifier?.dispose();
    _timelineNotifier?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<bool>(
      valueListenable: _controller.videoStateNotifier,
      builder: (_, isLoaded, __) {
        return _videoOptionWidget(isLoaded);
      },
    );
  }

  /// video player부분
  Widget _videoWidget(bool isLoaded) {
    _size = widget.widgetSize ?? MediaQuery.of(context).size;

    return isLoaded
        ? widget.isFullScreen
            ? AspectRatio(
                aspectRatio: _controller.aspectRatio,
                child: VideoPlayer(_controller.videoController),
              )
            : SizedBox(
                height: _size.height,
                width: _size.width,
                child: AspectRatio(
                  aspectRatio: _controller.aspectRatio,
                  child: VideoPlayer(_controller.videoController),
                ),
              )
        : (widget.placeHolder ?? Container());
  }

  /// 선택된 옵션에 따라 widget변경
  Widget _videoOptionWidget(bool isLoaded) {
    switch (widget.videoOption) {
      case VideoOption.none:
        return _videoWidget(isLoaded);
      case VideoOption.centerButtonOnly:
        return Stack(
          fit: StackFit.expand,
          alignment: Alignment.center,
          children: [
            _videoWidget(isLoaded),
            _toggleButton(),
          ],
        );
      case VideoOption.bottomBarOnly:
        return Column(
          children: [
            _videoWidget(isLoaded),
            Align(
              alignment: Alignment.bottomCenter,
              child: _bottomBar(),
            ),
          ],
        );
      case VideoOption.full:
        return Stack(
          alignment: Alignment.center,
          fit: StackFit.expand,
          children: [
            _videoWidget(isLoaded),
            _toggleButton(),
            Align(
              alignment: Alignment.bottomCenter,
              child: _bottomBar(),
            ),
          ],
        );
    }
  }

  /// 재생/정지 토글 버튼 bottomBar, center button에 사용
  Widget _toggleButton({bool isBottomButton = false, double iconSize = 30}) {
    return ValueListenableBuilder<PlayerButtonState>(
        valueListenable: _playButtonNotifier!,
        builder: (_, state, __) {
          switch (state) {
            case PlayerButtonState.stopped:
              return IconButton(
                iconSize: iconSize,
                icon: const Padding(
                  padding: EdgeInsets.all(8.0),
                  child: Icon(
                    Icons.play_arrow_rounded,
                    color: Colors.white,
                  ),
                ),
                onPressed: () async {
                  await _controller.playVideo();
                  _playButtonNotifier!.value = PlayerButtonState.playing;
                },
              );
            case PlayerButtonState.playing:
              // 하단부분에 들어가는 정지 버튼
              if (isBottomButton) {
                return IconButton(
                  iconSize: iconSize,
                  icon: const Padding(
                    padding: EdgeInsets.all(8.0),
                    child: Icon(
                      Icons.pause_rounded,
                      color: Colors.white,
                    ),
                  ),
                  onPressed: () async {
                    await _controller.pauseVideo();
                    _playButtonNotifier!.value = PlayerButtonState.paused;
                  },
                );
              }

              return Center(
                child: InkWell(
                  radius: 20,
                  onTap: () async {
                    await _controller.pauseVideo();
                    _playButtonNotifier!.value = PlayerButtonState.paused;
                  },
                ),
              );
            case PlayerButtonState.paused:
              return IconButton(
                iconSize: iconSize,
                icon: const Padding(
                  padding: EdgeInsets.all(8.0),
                  child: Icon(
                    Icons.play_arrow_rounded,
                    color: Colors.white,
                  ),
                ),
                onPressed: () async {
                  await _controller.playVideo();
                  _playButtonNotifier!.value = PlayerButtonState.playing;
                },
              );
          }
        });
  }

  /// 동영상 플레이 indicator/ (재생/정지) / 사운드/뮤트 / 확대축소
  Widget _bottomBar() {
    return SizedBox(
      width: _size.width,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          ValueListenableBuilder<Duration>(
              valueListenable: _timelineNotifier!,
              builder: (_, duration, __) {
                return Transform.translate(
                  offset: const Offset(0, 30),
                  child: SliderTheme(
                    data: SliderTheme.of(context).copyWith(trackHeight: 2),
                    child: Slider(
                      activeColor: Colors.redAccent,
                      inactiveColor: Colors.black54,
                      min: 0.0,
                      max: 1.0,
                      value: (duration.inMilliseconds /
                                  _controller
                                      .getVideoValue.duration.inMilliseconds) <
                              1.0
                          ? (duration.inMilliseconds /
                              _controller.getVideoValue.duration.inMilliseconds)
                          : 0.0,
                      onChanged: (value) {
                        double touchedPosition = value *
                            _controller.getVideoValue.duration.inMilliseconds;

                        _controller.seekTo(
                            Duration(milliseconds: touchedPosition.round()));
                      },
                    ),
                  ),
                );
              }),
          Row(
            children: [
              _toggleButton(isBottomButton: true, iconSize: 14),
              Expanded(
                child: ValueListenableBuilder<Duration>(
                    valueListenable: _timelineNotifier!,
                    builder: (_, duration, __) {
                      return Text(
                        duration.getTime,
                        style:
                            const TextStyle(fontSize: 14, color: Colors.white),
                      );
                    }),
              ),
              IconButton(
                onPressed: () async {
                  await _controller
                      .setVolume(_volumeNotifier!.value ? 1.0 : 0.0);
                  _volumeNotifier!.value = !_volumeNotifier!.value;
                },
                icon: ValueListenableBuilder<bool>(
                    valueListenable: _volumeNotifier!,
                    builder: (_, isMute, __) {
                      return Icon(
                        isMute
                            ? Icons.volume_off_rounded
                            : Icons.volume_up_rounded,
                        color: Colors.white,
                        size: 14,
                      );
                    }),
              ),
              IconButton(
                onPressed: () async {
                  if (widget.isFullScreen) {
                    Navigator.pop(context);
                  } else {
                    await _controller.pauseVideo();
                    _playButtonNotifier!.value = PlayerButtonState.paused;

                    Navigator.push(
                      context,
                      expandVideoRoute(
                        pushPage: FullScreenVideoWidget(
                          videoUrl: _controller.videoUrl,
                          aspectRatio: MediaQuery.of(context).size.aspectRatio,
                        ),
                      ),
                    );
                  }
                },
                icon: Icon(
                  widget.isFullScreen
                      ? Icons.close_fullscreen_rounded
                      : Icons.fullscreen_rounded,
                  color: Colors.white,
                  size: 14,
                ),
              )
            ],
          )
        ],
      ),
    );
  }

  Route expandVideoRoute({required Widget pushPage}) {
    return PageRouteBuilder(
      transitionDuration: const Duration(milliseconds: 300),
      reverseTransitionDuration: Duration.zero,
      pageBuilder: (context, animation, secondaryAnimation) => pushPage,
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return RotationTransition(
          turns: Tween<double>(
            begin: -0.05,
            end: 0.0,
          ).animate(
            CurvedAnimation(
              parent: animation,
              curve: Curves.linear,
            ),
          ),
          child: child,
        );
      },
    );
  }
}

Full screen video widget

import 'package:custom_video_player/src/controller/video_controller.dart';
import 'package:custom_video_player/src/util/enum.dart';
import 'package:custom_video_player/src/view/video_player_widget.dart';
import 'package:flutter/material.dart';

/// 전체화면 위젯
class FullScreenVideoWidget extends StatefulWidget {
  const FullScreenVideoWidget(
      {Key? key, required this.videoUrl, required this.aspectRatio})
      : super(key: key);

  final String videoUrl;
  final double aspectRatio;

  @override
  _FullScreenVideoWidgetState createState() => _FullScreenVideoWidgetState();
}

class _FullScreenVideoWidgetState extends State<FullScreenVideoWidget>
    with SingleTickerProviderStateMixin {
  late CustomVideoController _controller;

  @override
  void initState() {
    _controller = CustomVideoController(
        videoUrl: widget.videoUrl,
        muteSound: false,
        isLooping: false,
        autoPlay: false,
        aspectRatio: 1 / widget.aspectRatio);

    super.initState();
  }

  @override
  void dispose() async {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: FutureBuilder<void>(
        future: Future.delayed(
          const Duration(milliseconds: 500),
        ),
        builder: (_, snapshot) {
          if (snapshot.connectionState == ConnectionState.done) {
            return RotatedBox(
              quarterTurns: 1,
              child: VideoPlayerWidget(
                videoController: _controller,
                videoOption: VideoOption.full,
                isFullScreen: true,
              ),
            );
          }
          return Container();
        },
      ),
    );
  }
}

enum, extension

/// none : 아무 UI없음 (메인화면에서 사용)
/// CenterButtonOnly : 중앙 재생/정지 토글버튼
/// BottomBarOnly : 하단 indicator, 재생/정지 토글, 사운드 토글
/// Full = CenterButtonOnly + BottomBarOnly
enum VideoOption { none, centerButtonOnly, bottomBarOnly, full }

/// 버튼 토글용
enum PlayerButtonState { stopped, playing, paused }

/// Duration 시간변환 유틸
extension DurationToTime on Duration {
  String get getTime {
    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String twoDigitMinutes = twoDigits(inMinutes.remainder(60));
    String twoDigitSeconds = twoDigits(inSeconds.remainder(60));
    if (inHours == 0) {
      return '$twoDigitMinutes:$twoDigitSeconds';
    } else {
      return "${twoDigits(inHours)}:$twoDigitMinutes:$twoDigitSeconds";
    }
  }
}

video controller

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';

class CustomVideoController {
  /// video 관련된 모든 동작을 가진녀석(?)
  late VideoPlayerController videoController;

  /// 동영상 url
  final String videoUrl;

  /// true일 때 음소거
  final bool muteSound;

  /// true일 때 연속재생
  final bool isLooping;

  /// true일 때 자동재생
  final bool autoPlay;

  /// 동영상 비율
  final double aspectRatio;

  CustomVideoController({
    required this.videoUrl,
    required this.aspectRatio,
    this.muteSound = false,
    this.isLooping = false,
    this.autoPlay = false,
  }) {
    // 처음에 스냅샷 이미지 보여주기 용도
    videoStateNotifier = ValueNotifier(false);

    // auto play 처리부분
    initVideoController().whenComplete(() {
      if (autoPlay) {
        playVideo();
      }
    });
  }

  /// video loading후 상태변경 용도로 사용
  late ValueNotifier<bool> videoStateNotifier;

  /// video 관련 값들, ex: duration(동영상 전체 길이), position(현재 위치)
  VideoPlayerValue get getVideoValue => videoController.value;

  /// video controller 초기화 로직
  /// videoStateNotifier가 true가 되는순간 스냅샷이미지에서 동영상으로 변신
  Future<void> initVideoController() async {
    videoController = VideoPlayerController.network(videoUrl,
        videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true));

    await videoController.setVolume(muteSound ? 0.0 : 1.0);
    await videoController.setLooping(isLooping);

    await videoController.initialize();

    videoStateNotifier.value = getVideoValue.isInitialized;

    debugPrint('[video] init done : ${videoStateNotifier.value}');
  }

  /// 비디오 실행 일시정지 이후 재생 시 이전 위치에서 실행
  Future<void> playVideo() async {
    // video가 실행중인지 체크
    if (!(getVideoValue.isPlaying)) {
      if (getVideoValue.position == Duration.zero) {
        await videoController.play();
      } else {
        await seekTo(getVideoValue.position);
      }
    }
  }

  /// 비디오 정지
  Future<void> pauseVideo() async {
    await videoController.pause();
  }

  /// 특정 위치에서 시작
  Future<void> seekTo(Duration duration) async {
    await videoController.seekTo(duration);
  }

  /// from 0.0(음소거) to 1.0
  Future<void> setVolume(double volume) async {
    await videoController.setVolume(volume);
  }

  /// controller 초기화
  Future<void> dispose() async {
    await videoController.dispose();
    videoStateNotifier.dispose();
  }

  /// full mode일때만 사용
  /// timeline 업데이트에 사용
  void addListener(Function() listener) {
    videoController.addListener(listener);
  }

  /// dispose에 사용 필요
  void removeListener(Function() listener) {
    videoController.removeListener(listener);
  }
}

&lt; video player &gt;

 

사실 뭐 별거는 없는데 video player api가져다가 필요한 부분에 적당히(?) 붙여주고

 

화면부분은 Stack에 만들어주는 형태로 만들었습니다

(chewie, youtube player 등도 같은 방식)

 

여기서 몇가지 짚고 넘어가자면

 

1. Slider 위젯은 기본 높이가 있고 별다른 옵션이 없어서 높이조정이 필요함

 

처음에 이 부분을 SizedBox height 1로 넣으니 높이는 줄어들었으나

 

slider부분 터치가 정말 힘들어서 고민을 하던중

 

transform translate를 이용해서 offset을 줘서 위젯 기본 크기에 상관없이

 

높이를 조정할 수 있었습니다

 

2. 풀화면 적용

 

기존에 다른분들과 동일하게 orientation 옵션을 줬는데

SystemChrome.setPreferredOrientations([
      DeviceOrientation.landscapeRight,
      DeviceOrientation.landscapeLeft,
  ]);

생각보다 화면이 부자연스럽게 넘어가는거 같고 IOS에서는 특정한 셋팅을 해줘야돼서

 

위젯으로 해결 가능하지 않을까 해서

 

transform rotate를 이용해서 90도 돌려봤고 그냥 페이지를 넘기기는 조금 심심해서

 

page transition에 rotation을 조금 넣어줘봤는데 괜찮아 보이더군요

(아마도 똥 손, 똥 디자인감각을 가진 본인만..)

&lt; rotatiing screen &gt;

추가로 transform scale를 이용해서 checkbox위젯처럼 크기가 정해져있는 위젯을 작게 만들어 줄 수도 있습니다

(이쯤되면.. transform은 만능인가에 관한 포스트도 해야될거 같기도..)

 

그 다음으로

 

정지/재생, 소리, indicator 상태관리용도로 value notifier, value listenable builder를 사용했는데

 

이유는 동일한 value가 들어오면 notify를 하지 않다보니 불필요한 setstate를 방지할 수 있고,

(streambuilder에 비해 상대적으로 가볍다 라는 글을 어디선가 봐서 애용하고 있고..)

 

상태관리 패키지를 사용할만큼의 필요성도 못느꼈고, chewie가 상태관리 패키지 물고있어서

 

패키지 업데이트 할 때 귀찮았던거 생각해보면 사용 안하는게 낫겠다 싶어서 사용했습니다

 

마지막으로 패키지로 만들어서 올릴까 고민했는데

 

굳이..? 라는 생각이 들어서 일단 개인 github에 올려만 놨습니다

(링크 : https://github.com/devmemory/custom_video_player)

반응형
Comments