Flutter 实现朋友圈视频播放效果(下)-- 视频播放

2,083 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第6天,点击查看活动详情

前言

今天用Flutter来实现类似wx朋友圈的视频播放效果,包括拖拽关闭,路由动画,视频播放等相关的处理。

最终效果图

video_iu.gif

(示例视频来自weibo)

视频播放使用的是video_player插件。

上篇讲了拖拽效果的实现:Flutter 实现朋友圈视频播放效果(上)-- 拖拽效果

接下来我们实现他的主体,视频播放效果

视频资源的初始化

使用video_player会更自由一点,可以自己掌控视频播放器的UI

首先是对资源的初始化,可以是network也可以是fileasset

Future<void> initializePlayer() async {
  _videoPlayerController = VideoPlayerController.network(videoUrl);
  await _videoPlayerController.initialize();
  // 初始化完成,刷新,初始化完成才能显示VideoPlayer
  if (mounted) {
    setState(() {});
  }
  // 设置循环
  _videoPlayerController.setLooping(true);
  // 播放
  await _videoPlayerController.play();
}

初始化完成加载VideoPlayer

SizedBox(
  width: 200,
  child: _videoPlayerController.value.isInitialized ? AspectRatio(
    aspectRatio:
    _videoPlayerController.value.aspectRatio,
    child: GestureDetector(
      onTap: onTap,
      child: VideoPlayer(_videoPlayerController),
    ),
  )
      : const SizedBox(),
)

_videoPlayerController.value.aspectRatio可以获取得到视频的比例。记得再套一个Hero

因为组件和视频页的视频都是同步的,同一个视频,所以考虑使用同一个VideoPlayerController,打开视频页时传入。

Navigator.push(
  context,
  DragBottomDismissDialog(
    builder: (context) {
      return VideoPage(
        videoPlayerController: _videoPlayerController,
        heroTag: "video_page_player",
      );
    },
  ),
).then((value) {
  _videoPlayerController.play();
});

DragBottomDismissDialog即是视频页的拖拽退出效果。

视频UI

然后进入到视频的全屏播放页,pyq的视频播放布局是上边视频窗口,宽屏居中,竖屏底部居中,下边再带有一个进度条,可拖拽进度。

进入视频页,保险起见检查一下是否初始化

_videoPlayerController = widget.videoPlayerController;
if (!_videoPlayerController.value.isInitialized) {
  await _videoPlayerController.initialize();
}

绘制下视频播放窗口,根据比例判断,居中占满宽度,或者底部居中

Container(
  width: double.maxFinite,
  height: double.maxFinite,
  alignment: _videoPlayerController.value.aspectRatio >= 1 ? Alignment.center : Alignment.bottomCenter,
  child: DragBottomPopGesture(
    child: Hero(
      tag: widget.heroTag ?? "video_page_player",
      transitionOnUserGestures: true,
      child: _videoPlayerController.value.isInitialized
          ? AspectRatio(
              aspectRatio:
                  _videoPlayerController.value.aspectRatio,
              child: GestureDetector(
                onTap: onTap,
                child: VideoPlayer(_videoPlayerController),
              ),
            )
          : const SizedBox(),
    ),
  ),
)

DragBottomPopGesture规定只能拖拽视频这一区域

加个loading,初始化完成前可以显示加载图

!_videoPlayerController.value.isPlaying &&
    ((!_videoPlayerController.value.isInitialized) ||
        _videoPlayerController.value.isBuffering)
? const Center(
    child: SizedBox(
        width: 53,
        height: 53,
        child: CircularProgressIndicator(
          backgroundColor: Colors.transparent,
          valueColor: AlwaysStoppedAnimation<Color>(
            Colors.white,
          ),
          strokeWidth: 1.5,
        ),
    ),
  )
: const SizedBox(),

进度条

进度条我们可以使用Slider来实现

value控制当前进度,拖拽进度时有三个监听:onChangedonChangeStartonChangeEnd

SliderTheme可以定义它的样式,因为它在拖拽时会变的更清晰圆形更大,所以定义一个isDragging控制它的状态。

它的样式:

SliderTheme(
  data: SliderTheme.of(context).copyWith(
      trackHeight: 2, //trackHeight:滑轨的高度
      activeTrackColor: Colors.grey[400], //已滑过轨道的颜色
      inactiveTrackColor: Colors.grey[700], //未滑过轨道的颜色
      thumbColor: Colors.white, //滑块中心的颜色(小圆头的颜色)/滑块边缘的颜色
      thumbShape: RoundSliderThumbShape(
        //可继承SliderComponentShape自定义形状
        disabledThumbRadius: 5, //禁用时滑块大小
        enabledThumbRadius: _isDragging ? 5 : 3, //滑块大小
      ),
      overlayColor: Colors.transparent,
      trackShape: const RoundSliderTrackShape(radius: 8),
  )

默认的两头的轨道,不是圆形的,所以需要自定义下他的TrackShape,我们自己来画一个轨道。继承SliderTrackShape然后paint中画一下两头的圆角

//进度条两头圆角
final RRect leftTrackSegment = RRect.fromLTRBR(trackRect.left, trackRect.top,
    thumbCenter.dx - horizontalAdjustment, trackRect.bottom, Radius.circular(radius));
context.canvas.drawRRect(leftTrackSegment, leftTrackPaint);
final RRect rightTrackSegment = RRect.fromLTRBR(thumbCenter.dx + horizontalAdjustment, trackRect.top,
    trackRect.right, trackRect.bottom, Radius.circular(radius));
context.canvas.drawRRect(rightTrackSegment, rightTrackPaint);

拖拽时变的清晰,简单,加个Opacity

Opacity(
  opacity:_isDragging
          ? 1
          : 0.5,

拖拽时清晰显示,静默时透明度50%。松手后过几秒要恢复,所以我们在onChangedEnd中加个计时器,到点就半透明。

void readyToHide() {
  _hideTimer = Timer(const Duration(seconds: 5), () {
    setState(() {
      _isDragging = false;
    });
  });
}
onChangeEnd: (double value) async {
  await _videoPlayerController.seekTo(Duration(
      milliseconds: (value * _total).toInt()));
  if (_hasPlay) {
    play();
  }
  readyToHide();
}

结束同时也要设置视频的进度,保持同步

为了避免拖拽进度时视频也在播放,没有同步,所以onChangeStart中先暂停播放。

onChangeStart: (double value) {
  _hideTimer?.cancel();
  _isDragging = true;
  if (_videoPlayerController.value.isPlaying) {
    _hasPlay = true;
    pause();
  } else {
    _hasPlay = false;
  }
  if (mounted) {
    setState(() {});
  }
}

onChanged中保持同步即可

onChanged: (double value) {
  _videoPlayerController.seekTo(Duration(
      milliseconds: (value * _total).toInt()));
  _processNotifier.value = value;
}

使用ValueListenable来控制视频播放进度与进度条的同步

ValueListenableBuilder<double>(
  valueListenable: _processNotifier,
  builder: (context, process, child) {
    return Slider(
      value: process > 1
          ? 1
          : process < 0
              ? 0
              : process,

为了避免一些奇奇怪怪的进度值,做了一下处理。

用个计时器来拿到视频的进度并同步

if (_timer?.isActive ?? false) {
  return;
}
_timer = Timer.periodic(const Duration(milliseconds: 10), (timer) async {
  if (!_videoPlayerController.value.isPlaying) {
    return;
  }
  // debug差距较大会有明显跳动,profile和release基本没有差别
  _timeProcess = _videoPlayerController.value.position.inMilliseconds;
  _processNotifier.value = _timeProcess / _total;
});

结合拖拽处理

基本上视频的功能就实现完成了。然后再处理一些小细节。

比如拖拽视频时,视频应该暂停,我加了个拖拽的回调,使视频页可以拿到

@override
void onDragStart() {
  if (_videoPlayerController.value.isPlaying) {
    _hasPlay = true;
    pause();
  } else {
    _hasPlay = false;
  }
  if (mounted) {
    setState(() {
      _pageDragging = true;
    });
  }
}

Opacity(
  opacity: _pageDragging // 隐藏进度条
      ? 0
      : _isDragging
          ? 1
          : 0.5

拖拽的时候就暂停,并且隐藏进度条

拖拽结束时恢复

@override
void onDragEnd(Offset endStatus) {
  if (_hasPlay) {
    play();
  }
  if (mounted) {
    setState(() {
      _pageDragging = false;
    });
  }
}

_hasPlay是用来判断是否被暂停了,假如点击关闭页面的话不用也可以。

这时候体验一下,会发现进入视频页或者拖拽取消重置,若是在加载的话,动画还没结束就loading了,或者拖拽的时候也可能在loading,体验不好,所以loading再加一点限制,等待一下路由动画,或者拖拽时也不显示

_animationFinish &&
        !_isDragging &&
        !_videoPlayerController.value.isPlaying &&
        ((!_videoPlayerController.value.isInitialized) ||
            _videoPlayerController.value.isBuffering)

最后加个点击关闭(或点击播放暂停)。

void onTap() {
  _videoPlayerController.pause();
  Navigator.pop(context);
  // if (_videoPlayerController.value.isInitialized) {
  //   if (_videoPlayerController.value.isPlaying) {
  //     pause();
  //     _isPlaying = false;
  //   } else {
  //     play();
  //     _isPlaying = true;
  //   }
  //   setState(() {});
  // }
}

若是Hero时想看到原组件区域保留,可以在组件的Hero加个占位图

Hero(
  tag: 'video_page_player',
  placeholderBuilder: (
      BuildContext context,
      Size heroSize,
      Widget child,
      ) {
    return child;
  }

image.png

The End

最后这个pyq视频播放效果就实现了。

还可以加个网络监听是否是wifi来决定视频是否自动播放等功能,若是有什么问题可以评论区讨论下

相关文章

Flutter 实现朋友圈视频播放效果(上)-- 拖拽效果

Demo project

github.com/EchoPuda/wx…