Flutter折騰記一(支持橫屏的視頻控件)

又有一段時間沒有寫文章了,閒暇時間比較少。癡迷遊戲。哎~~~前端

最近在玩flutter,對於vuejs感受沒啥可分享的了。每次看到羣友們問的問題,我都只能嘆口氣。vue

不用我說,應該都知道flutter是基於dart語言的,我目前的體驗來講,除了組件嵌套比較噁心以外,真的比js舒服,懂的天然懂~~~ios

場景分析

多是因爲平時比較喜歡看視頻吧,上手flutter以後,還沒多久呢,就想搞一搞視頻播放。中間陸陸續續用了社區好幾個現成的視頻插件,都感受沒有達到本身想要的效果,固然也許這些插件能夠知足正在看這個文章的你,先列一下吧。api

  1. 首推flutter官方的video_player,只有視頻播放,無ui(其實藏了一個帶手勢操做的進度條),無特殊功能。
  2. chewie,在官方的基礎上作了一些ui,不過這個插件全屏(只是單純的豎屏全屏,相似於H5的全屏),並非我要的效果,新增的ui不是個人style
  3. flutter_ijkplayer,基於ijkplayer,增長了ui和橫屏(ui的橫屏,手機仍是豎屏),不過ijkplayer在ios總是報錯,而且這個插件沒有封面圖,因此我。。
  4. fijkplayer,和上面的差很少,也是基於ijkplayer,有封面圖,可是沒有全屏,而且api有點難受。

就說這幾個吧,很少說了,可能這些插件已經知足你了,卻知足不了我,誰叫我是處女座呢。緩存

進入正題

首先準備一個空頁面,就叫media.dart吧。app

class MediaPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return MediaPageState();
  }
}

class MediaPageState extends State<MediaPage> {
  // 記錄當前設備是否橫屏,後續用到
  bool get _isFullScreen => MediaQuery.of(context).orientation == Orientation.landscape;
  
   @override
   Widget build(BuildContext context) {
       return Scaffold(
          appBar: AppBar(
            title: Text('Media'),
          ),
          body: Container(
            child: MyVideo( // 這個是等會兒要編寫的組件
              url: 'http://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4',
              title: '示例視頻',
              // 這個vw是MediaQueryData.fromWindow(window).size.width屏幕寬度
              width: _isFullScreen?vh:vw,
              height: _isFullScreen?vw:vw/16*9, // 豎屏時容器爲16:9
            ),
          ),
       )
   }
}
複製代碼

OK,空頁面準備好了。框架

打造播放器

我這裏選用的是官方的video_play,由於其餘插件都增長了東西缺讓我不滿意,因此我只能選擇最原始的插件,本身增長滿意的東西。async

video_player: ^0.10.2+5
複製代碼

好,開始編寫咱們想要的ui~~ide

// MyVideo.dart
import 'package:video_player/video_player.dart'; // 引入官方插件

class MyVideo extends StatefulWidget {
  MyVideo({
    @required this.url, // 當前須要播放的地址
    @required this.width, // 播放器尺寸(大於等於視頻播放區域)
    @required this.height,
    this.title = '', // 視頻須要顯示的標題
  });

  // 視頻地址
  final String url;
  // 視頻尺寸比例
  final double width;
  final double height;
  // 視頻標題
  final String title;

  @override
  State<MyVideo> createState() {
    return _MyVideoState();
  }
}

class _MyVideoState extends State<MyVideo> {
  // 指示video資源是否加載完成,加載完成後會得到總時長和視頻長寬比等信息
  bool _videoInit = false;
  // video控件管理器
  VideoPlayerController _controller;
  // 記錄video播放進度
  Duration _position = Duration(seconds: 0);
  // 記錄播放控件ui是否顯示(進度條,播放按鈕,全屏按鈕等等)
  Timer _timer; // 計時器,用於延遲隱藏控件ui
  bool _hidePlayControl = true; // 控制是否隱藏控件ui
  double _playControlOpacity = 0; // 經過透明度動畫顯示/隱藏控件ui
  // 記錄是否全屏
  bool get _isFullScreen => MediaQuery.of(context).orientation==Orientation.landscape;
  
  @override
  Widget build(BuildContext context) {
    // 繼續往下看
  }
}
複製代碼

如今已經完成了視頻播放器組件的大框架了,開始編寫渲染播放器和控件ui。佈局

想法:控件的ui我但願分爲上半部分和下半部分,上半部顯示標題,下半部顯示播放按鈕,全屏按鈕,進度條,點擊視頻區域控制控件ui顯示/隱藏。ok。開幹

  1. 先寫視頻播放區
class _MyVideoState extends State<MyVideo> {
  // ......
  @override
  Widget build(BuildContext context) {
    return Container(
      width: widget.width,
      height: widget.height,
      color: Colors.black,
      child: widget.url!=null?Stack( // 由於控件ui和視頻是重疊的,因此要用定位了
        children: <Widget>[
          GestureDetector( // 手勢組件
            onTap: () { // 點擊顯示/隱藏控件ui
              _togglePlayControl();
            },
            child: _videoInit?
            Center(
              child: AspectRatio( // 加載url成功時,根據視頻比例渲染播放器
                aspectRatio: _controller.value.aspectRatio,
                child: VideoPlayer(_controller),
              ),
            ):
            Center( // 沒加載完成時顯示轉圈圈loading
              child: SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(),
              ),
            ),
          ),
          _bottomControl,// 控件ui下半部 看下面
        ],
      ):Center( // 判斷是否傳入了url,沒有的話顯示"暫無視頻信息"
        child: Text(
          '暫無視頻信息',
          style: TextStyle(
            color: Colors.white
          ),
        ),
      ),
    )
  }
  
  @override
  void initState() {
    _urlChange(); // 初始進行一次url加載
    super.initState();
  }

  @override
  void didUpdateWidget(MyVideo oldWidget) {
    if (oldWidget.url != widget.url) {
      _urlChange(); // url變化時從新執行一次url加載
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    if (_controller!=null) { // 慣例。組件銷燬時清理下
      _controller.removeListener(_videoListener);
      _controller.dispose();
    }
    super.dispose();
  }
  
  void _urlChange() {
    if (widget.url==null || widget.url=='') return;
    if (_controller!=null) { // 若是控制器存在,清理掉從新建立
      _controller.removeListener(_videoListener);
      _controller.dispose();
    }
    setState(() { // 重置組件參數
      _hidePlayControl = true;
      _videoInit = false;
      _position = Duration(seconds: 0);
    });
    // 加載network的url,也支持本地文件,自行閱覽官方api
    _controller = VideoPlayerController.network(widget.url)
    ..initialize().then((_) {
      // 加載資源完成時,監聽播放進度,而且標記_videoInit=true加載完成
      _controller.addListener(_videoListener);
      setState(() {
        _videoInit = true;
      });
    });
  }
  void _videoListener() async {
    Duration res = await _controller.position;
    if (res >= _controller.value.duration) {
      _controller.pause();
      _controller.seekTo(Duration(seconds: 0));
    }
    setState(() {
      _position = res;
    });
  }
}
複製代碼
  1. 而後編寫控件ui
// 控件ui下半部
Widget _bottomControl = Positioned( // 須要定位
  left: 0,
  bottom: 0,
  child: Offstage( // 控制是否隱藏
    offstage: _hidePlayControl,
    child: AnimatedOpacity( // 加入透明度動畫
      opacity: _playControlOpacity,
      duration: Duration(milliseconds: 300),
      child: Container( // 底部控件的容器
        width: widget.width,
        height: 40,
        decoration: BoxDecoration(
          gradient: LinearGradient( // 來點黑色到透明的漸變優雅一下
            begin: Alignment.bottomCenter,
            end: Alignment.topCenter,
            colors: [Color.fromRGBO(0, 0, 0, .7), Color.fromRGBO(0, 0, 0, .1)],
          ),
        ),
        child: _videoInit?Row( // 加載完成時才渲染,flex佈局
          children: <Widget>[
            IconButton( // 播放按鈕
              padding: EdgeInsets.zero,
              iconSize: 26,
              icon: Icon( // 根據控制器動態變化播放圖標仍是暫停
                _controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
                color: Colors.white,
              ),
              onPressed: (){
                setState(() { // 一樣的,點擊動態播放或者暫停
                  _controller.value.isPlaying
                    ? _controller.pause()
                    : _controller.play();
                  _startPlayControlTimer(); // 操做控件後,重置延遲隱藏控件的timer
                });
              },
            ),
            Flexible( // 至關於前端的flex: 1
              child: VideoProgressIndicator( // 嘻嘻,這是video_player編寫好的進度條,直接用就是了~~
                _controller,
                allowScrubbing: true, // 容許手勢操做進度條
                padding: EdgeInsets.all(0),
                colors: VideoProgressColors( // 配置進度條顏色,也是video_player現成的,直接用
                  playedColor: Theme.of(context).primaryColor, // 已播放的顏色
                  bufferedColor: Color.fromRGBO(255, 255, 255, .5), // 緩存中的顏色
                  backgroundColor: Color.fromRGBO(255, 255, 255, .2), // 爲緩存的顏色
                ),
              ),
            ),
            Container( // 播放時間
              margin: EdgeInsets.only(left: 10),
              child: Text( // durationToTime是經過Duration轉成hh:mm:ss的格式,本身實現。
                durationToTime(_position)+'/'+durationToTime(_controller.value.duration),
                style: TextStyle(
                  color: Colors.white
                ),
              ),
            ),
            IconButton( // 全屏/橫屏按鈕
              padding: EdgeInsets.zero,
              iconSize: 26,
              icon: Icon( // 根據當前屏幕方向切換圖標
                _isFullScreen?Icons.fullscreen_exit:Icons.fullscreen,
                color: Colors.white,
              ),
              onPressed: (){ // 點擊切換是否全屏
                _toggleFullScreen();
              },
            ),
          ],
        ):Container(),
      ),
    ),
  ),
);
複製代碼
  1. 先看下顯示/隱藏控件ui的方法
void _togglePlayControl() {
    setState(() {
      if (_hidePlayControl) { // 若是隱藏則顯示
        _hidePlayControl = false;
        _playControlOpacity = 1;
        _startPlayControlTimer(); // 開始計時器,計時後隱藏
      } else { // 若是顯示就隱藏
        if (_timer!=null) _timer.cancel(); // 有計時器先移除計時器
        _playControlOpacity = 0;
        Future.delayed(Duration(milliseconds: 300)).whenComplete(() {
          _hidePlayControl = true; // 延遲300ms(透明度動畫結束)後,隱藏
        });
      }
    });
}

void _startPlayControlTimer() { // 計時器,用法和前端js的大同小異
    if (_timer!=null) _timer.cancel();
    _timer = Timer(Duration(seconds: 3), () { // 延遲3s後隱藏
      setState(() {
        _playControlOpacity = 0;
        Future.delayed(Duration(milliseconds: 300)).whenComplete(() {
          _hidePlayControl = true;
        });
      });
    });
}
複製代碼
  1. 再來看下全屏的方法,此處採用了切換橫屏豎屏的插件orientation
void _toggleFullScreen() {
    setState(() {
      if (_isFullScreen) { // 若是是全屏就切換豎屏
        OrientationPlugin.forceOrientation(DeviceOrientation.portraitUp);
      } else {
        OrientationPlugin.forceOrientation(DeviceOrientation.landscapeRight);
      }
      _startPlayControlTimer(); // 操做完控件開始計時隱藏
    });
}
複製代碼

切換成橫屏後,須要用_isFullScreenOffstage把你不想顯示的組件隱藏掉,例如appBar等等。

至此,已經完成我想要的效果。下面看下效果吧。

效果圖: https://cdn.chavesgu.com/flutter/SampleVideo.gif

咳咳。

代碼隨意copy,重在學習,能夠隨時和我一塊兒研究flutter,歡迎關注,後續有時間會繼續分享flutter。

相關文章
相關標籤/搜索