Flutter折騰記一(支援橫屏的視訊控制元件)

chavesgu 發表於 2019-12-02

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

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

不用我說,應該都知道flutter是基於dart語言的,我目前的體驗來說,除了元件巢狀比較噁心之外,真的比js舒服,懂的自然懂~~~

場景分析

可能是由於平時比較喜歡看視訊吧,上手flutter之後,還沒多久呢,就想搞一搞視訊播放。中間陸陸續續用了社群好幾個現成的視訊外掛,都感覺沒有達到自己想要的效果,當然也許這些外掛可以滿足正在看這個文章的你,先列一下吧。

  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吧。

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: '示例視訊',
              width: vw, // 這個vw是MediaQueryData.fromWindow(window).size.width螢幕寬度
              height: vw/16*9, // 設定容器為16:9
            ),
          ),
       )
   }
}
複製程式碼

OK,空頁面準備好了。

打造播放器

我這裡選用的是官方的video_play,因為其他外掛都增加了東西缺讓我不滿意,所以我只能選擇最原始的外掛,自己增加滿意的東西。

video_player: ^0.10.2+5
複製程式碼

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

// 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(),
              ),
            ),
          ),
        ],
      ):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;
      });
    });
  }
}
複製程式碼
  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。