custom_material_controls.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  1. import 'dart:async';
  2. import 'package:chewie/src/chewie_player.dart';
  3. import 'package:chewie/src/chewie_progress_colors.dart';
  4. import 'package:chewie/src/helpers/utils.dart';
  5. import 'package:chewie/src/material/color_compat_extensions.dart';
  6. import 'package:chewie/src/material/material_progress_bar.dart';
  7. import 'package:chewie/src/material/widgets/options_dialog.dart';
  8. import 'package:chewie/src/material/widgets/playback_speed_dialog.dart';
  9. import 'package:chewie/src/models/option_item.dart';
  10. import 'package:chewie/src/models/subtitle_model.dart';
  11. import 'package:chewie/src/notifiers/index.dart';
  12. import 'package:flutter/material.dart';
  13. import 'package:flutter_screenutil/flutter_screenutil.dart';
  14. import 'package:provider/provider.dart';
  15. import 'package:video_player/video_player.dart';
  16. import '../../resource/assets.gen.dart';
  17. /// 自定义播放器UI
  18. class CustomMaterialControls extends StatefulWidget {
  19. final bool showPlayButton;
  20. const CustomMaterialControls({super.key, this.showPlayButton = true});
  21. @override
  22. State<StatefulWidget> createState() {
  23. return _CustomMaterialControlsState();
  24. }
  25. }
  26. class _CustomMaterialControlsState extends State<CustomMaterialControls>
  27. with SingleTickerProviderStateMixin {
  28. late PlayerNotifier notifier;
  29. late VideoPlayerValue _latestValue;
  30. double? _latestVolume;
  31. Timer? _hideTimer;
  32. Timer? _initTimer;
  33. late var _subtitlesPosition = Duration.zero;
  34. bool _subtitleOn = false;
  35. Timer? _showAfterExpandCollapseTimer;
  36. bool _dragging = false;
  37. bool _displayTapped = false;
  38. Timer? _bufferingDisplayTimer;
  39. bool _displayBufferingIndicator = false;
  40. final barHeight = 48.0 * 1.5;
  41. final marginSize = 5.0;
  42. late VideoPlayerController controller;
  43. ChewieController? _chewieController;
  44. // We know that _chewieController is set in didChangeDependencies
  45. ChewieController get chewieController => _chewieController!;
  46. @override
  47. void initState() {
  48. super.initState();
  49. notifier = Provider.of<PlayerNotifier>(context, listen: false);
  50. }
  51. @override
  52. Widget build(BuildContext context) {
  53. if (_latestValue.hasError) {
  54. return chewieController.errorBuilder?.call(
  55. context,
  56. chewieController.videoPlayerController.value.errorDescription!,
  57. ) ??
  58. const Center(child: Icon(Icons.error, color: Colors.white, size: 42));
  59. }
  60. return MouseRegion(
  61. onHover: (_) {
  62. _cancelAndRestartTimer();
  63. },
  64. child: GestureDetector(
  65. onTap: () => _cancelAndRestartTimer(),
  66. child: AbsorbPointer(
  67. absorbing: notifier.hideStuff,
  68. child: Stack(
  69. children: [
  70. if (_displayBufferingIndicator)
  71. _chewieController?.bufferingBuilder?.call(context) ??
  72. const Center(child: CircularProgressIndicator())
  73. else
  74. _buildHitArea(),
  75. _buildActionBar(),
  76. Column(
  77. mainAxisAlignment: MainAxisAlignment.end,
  78. children: <Widget>[
  79. if (_subtitleOn)
  80. Transform.translate(
  81. offset: Offset(
  82. 0.0,
  83. notifier.hideStuff ? barHeight * 0.8 : 0.0,
  84. ),
  85. child: _buildSubtitles(
  86. context,
  87. chewieController.subtitle!,
  88. ),
  89. ),
  90. _buildBottomBar(context),
  91. ],
  92. ),
  93. ],
  94. ),
  95. ),
  96. ),
  97. );
  98. }
  99. @override
  100. void dispose() {
  101. _dispose();
  102. super.dispose();
  103. }
  104. void _dispose() {
  105. controller.removeListener(_updateState);
  106. _hideTimer?.cancel();
  107. _initTimer?.cancel();
  108. _showAfterExpandCollapseTimer?.cancel();
  109. }
  110. @override
  111. void didChangeDependencies() {
  112. final oldController = _chewieController;
  113. _chewieController = ChewieController.of(context);
  114. controller = chewieController.videoPlayerController;
  115. if (oldController != chewieController) {
  116. _dispose();
  117. _initialize();
  118. }
  119. super.didChangeDependencies();
  120. }
  121. Widget _buildActionBar() {
  122. return Positioned(
  123. top: 0,
  124. right: 0,
  125. child: SafeArea(
  126. child: AnimatedOpacity(
  127. opacity: notifier.hideStuff ? 0.0 : 1.0,
  128. duration: const Duration(milliseconds: 250),
  129. child: Row(
  130. children: [
  131. _buildSubtitleToggle(),
  132. if (chewieController.showOptions) _buildOptionsButton(),
  133. ],
  134. ),
  135. ),
  136. ),
  137. );
  138. }
  139. List<OptionItem> _buildOptions(BuildContext context) {
  140. final options = <OptionItem>[
  141. OptionItem(
  142. onTap: (context) async {
  143. Navigator.pop(context);
  144. _onSpeedButtonTap();
  145. },
  146. iconData: Icons.speed,
  147. title:
  148. chewieController.optionsTranslation?.playbackSpeedButtonText ??
  149. 'Playback speed',
  150. ),
  151. ];
  152. if (chewieController.additionalOptions != null &&
  153. chewieController.additionalOptions!(context).isNotEmpty) {
  154. options.addAll(chewieController.additionalOptions!(context));
  155. }
  156. return options;
  157. }
  158. Widget _buildOptionsButton() {
  159. return AnimatedOpacity(
  160. opacity: notifier.hideStuff ? 0.0 : 1.0,
  161. duration: const Duration(milliseconds: 250),
  162. child: IconButton(
  163. onPressed: () async {
  164. _hideTimer?.cancel();
  165. if (chewieController.optionsBuilder != null) {
  166. await chewieController.optionsBuilder!(
  167. context,
  168. _buildOptions(context),
  169. );
  170. } else {
  171. await showModalBottomSheet<OptionItem>(
  172. context: context,
  173. isScrollControlled: true,
  174. useRootNavigator: chewieController.useRootNavigator,
  175. builder:
  176. (context) => OptionsDialog(
  177. options: _buildOptions(context),
  178. cancelButtonText:
  179. chewieController.optionsTranslation?.cancelButtonText,
  180. ),
  181. );
  182. }
  183. if (_latestValue.isPlaying) {
  184. _startHideTimer();
  185. }
  186. },
  187. icon: const Icon(Icons.more_vert, color: Colors.white),
  188. ),
  189. );
  190. }
  191. Widget _buildSubtitles(BuildContext context, Subtitles subtitles) {
  192. if (!_subtitleOn) {
  193. return const SizedBox();
  194. }
  195. final currentSubtitle = subtitles.getByPosition(_subtitlesPosition);
  196. if (currentSubtitle.isEmpty) {
  197. return const SizedBox();
  198. }
  199. if (chewieController.subtitleBuilder != null) {
  200. return chewieController.subtitleBuilder!(
  201. context,
  202. currentSubtitle.first!.text,
  203. );
  204. }
  205. return Padding(
  206. padding: EdgeInsets.all(marginSize),
  207. child: Container(
  208. padding: const EdgeInsets.all(5),
  209. decoration: BoxDecoration(
  210. color: const Color(0x96000000),
  211. borderRadius: BorderRadius.circular(10.0),
  212. ),
  213. child: Text(
  214. currentSubtitle.first!.text.toString(),
  215. style: const TextStyle(fontSize: 18),
  216. textAlign: TextAlign.center,
  217. ),
  218. ),
  219. );
  220. }
  221. AnimatedOpacity _buildBottomBar(BuildContext context) {
  222. final iconColor = Theme.of(context).textTheme.labelLarge!.color;
  223. return AnimatedOpacity(
  224. opacity: notifier.hideStuff ? 0.0 : 1.0,
  225. duration: const Duration(milliseconds: 300),
  226. child: Container(
  227. height: barHeight + (chewieController.isFullScreen ? 10.0 : 0),
  228. padding: EdgeInsets.only(
  229. left: 20,
  230. right: 20,
  231. bottom: !chewieController.isFullScreen ? 10.0 : 0,
  232. ),
  233. child: SafeArea(
  234. top: false,
  235. bottom: chewieController.isFullScreen,
  236. minimum: chewieController.controlsSafeAreaMinimum,
  237. child: Column(
  238. mainAxisSize: MainAxisSize.min,
  239. mainAxisAlignment: MainAxisAlignment.center,
  240. children: [
  241. Flexible(
  242. child: Row(
  243. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  244. children: <Widget>[
  245. if (chewieController.isLive)
  246. const Expanded(child: Text('LIVE'))
  247. else
  248. _buildPosition(iconColor),
  249. if (chewieController.allowMuting)
  250. _buildMuteButton(controller),
  251. const Spacer(),
  252. if (chewieController.allowFullScreen) _buildExpandButton(),
  253. ],
  254. ),
  255. ),
  256. SizedBox(height: chewieController.isFullScreen ? 15.0 : 0),
  257. if (!chewieController.isLive)
  258. Expanded(
  259. child: Container(
  260. // padding: const EdgeInsets.symmetric(horizontal: 20),
  261. child: Row(children: [_buildProgressBar()]),
  262. ),
  263. ),
  264. ],
  265. ),
  266. ),
  267. ),
  268. );
  269. }
  270. GestureDetector _buildMuteButton(VideoPlayerController controller) {
  271. return GestureDetector(
  272. onTap: () {
  273. _cancelAndRestartTimer();
  274. if (_latestValue.volume == 0) {
  275. controller.setVolume(_latestVolume ?? 0.5);
  276. } else {
  277. _latestVolume = controller.value.volume;
  278. controller.setVolume(0.0);
  279. }
  280. },
  281. child: AnimatedOpacity(
  282. opacity: notifier.hideStuff ? 0.0 : 1.0,
  283. duration: const Duration(milliseconds: 300),
  284. child: ClipRect(
  285. child: Container(
  286. height: barHeight,
  287. padding: const EdgeInsets.only(left: 6.0),
  288. child: Icon(
  289. _latestValue.volume > 0 ? Icons.volume_up : Icons.volume_off,
  290. color: Colors.white,
  291. ),
  292. ),
  293. ),
  294. ),
  295. );
  296. }
  297. GestureDetector _buildExpandButton() {
  298. return GestureDetector(
  299. onTap: _onExpandCollapse,
  300. child: AnimatedOpacity(
  301. opacity: notifier.hideStuff ? 0.0 : 1.0,
  302. duration: const Duration(milliseconds: 300),
  303. child: Container(
  304. height: barHeight + (chewieController.isFullScreen ? 15.0 : 0),
  305. margin: const EdgeInsets.only(right: 12.0),
  306. padding: const EdgeInsets.only(left: 8.0, right: 8.0),
  307. child: Center(
  308. child: Icon(
  309. chewieController.isFullScreen
  310. ? Icons.fullscreen_exit
  311. : Icons.fullscreen,
  312. color: Colors.white,
  313. ),
  314. ),
  315. ),
  316. ),
  317. );
  318. }
  319. Widget _buildHitArea() {
  320. final bool isFinished =
  321. (_latestValue.position >= _latestValue.duration) &&
  322. _latestValue.duration.inSeconds > 0;
  323. final bool showPlayButton =
  324. widget.showPlayButton && !_dragging && !notifier.hideStuff;
  325. return GestureDetector(
  326. onTap: () {
  327. if (_latestValue.isPlaying) {
  328. if (_chewieController?.pauseOnBackgroundTap ?? false) {
  329. _playPause();
  330. _cancelAndRestartTimer();
  331. } else {
  332. if (_displayTapped) {
  333. setState(() {
  334. notifier.hideStuff = true;
  335. });
  336. } else {
  337. _cancelAndRestartTimer();
  338. }
  339. }
  340. } else {
  341. _playPause();
  342. setState(() {
  343. notifier.hideStuff = true;
  344. });
  345. }
  346. },
  347. child: Container(
  348. alignment: Alignment.center,
  349. color: Colors.transparent,
  350. // The Gesture Detector doesn't expand to the full size of the container without this; Not sure why!
  351. child: Row(
  352. mainAxisAlignment: MainAxisAlignment.center,
  353. children: [
  354. // 快进按钮
  355. // if (!isFinished && !chewieController.isLive)
  356. // CenterSeekButton(
  357. // iconData: Icons.replay_10,
  358. // backgroundColor: Colors.black54,
  359. // iconColor: Colors.white,
  360. // show: showPlayButton,
  361. // fadeDuration: chewieController.materialSeekButtonFadeDuration,
  362. // iconSize: chewieController.materialSeekButtonSize,
  363. // onPressed: _seekBackward,
  364. // ),
  365. // 中间的播放、暂停按钮
  366. Container(
  367. margin: EdgeInsets.symmetric(horizontal: marginSize),
  368. child: _buildActionBtn(showPlayButton),
  369. ),
  370. // 第三方库中原始的播放、暂停按钮
  371. // Container(
  372. // margin: EdgeInsets.symmetric(
  373. // horizontal: marginSize,
  374. // ),
  375. // child: CenterPlayButton(
  376. // backgroundColor: Colors.black54,
  377. // iconColor: Colors.white,
  378. // isFinished: isFinished,
  379. // isPlaying: controller.value.isPlaying,
  380. // show: showPlayButton,
  381. // onPressed: _playPause,
  382. // ),
  383. // ),
  384. // 快退按钮
  385. // if (!isFinished && !chewieController.isLive)
  386. // CenterSeekButton(
  387. // iconData: Icons.forward_10,
  388. // backgroundColor: Colors.black54,
  389. // iconColor: Colors.white,
  390. // show: showPlayButton,
  391. // fadeDuration: chewieController.materialSeekButtonFadeDuration,
  392. // iconSize: chewieController.materialSeekButtonSize,
  393. // onPressed: _seekForward,
  394. // ),
  395. ],
  396. ),
  397. ),
  398. );
  399. }
  400. /// 操作按钮
  401. Widget _buildActionBtn(bool showPlayButton) {
  402. return Visibility(
  403. // 是否显示操作按钮
  404. visible: showPlayButton,
  405. child: Stack(
  406. children: [
  407. Visibility(
  408. visible: !controller.value.isPlaying,
  409. child: _buildPlayBtn(),
  410. ),
  411. Visibility(
  412. visible: controller.value.isPlaying,
  413. child: _buildPauseBtn(),
  414. ),
  415. ],
  416. ),
  417. );
  418. }
  419. /// 播放按钮
  420. Widget _buildPlayBtn() {
  421. return GestureDetector(
  422. onTap: () {
  423. _playPause();
  424. },
  425. child: Assets.images.iconPlay.image(width: 52.w, height: 52.w),
  426. );
  427. }
  428. /// 暂停按钮
  429. Widget _buildPauseBtn() {
  430. return GestureDetector(
  431. onTap: () {
  432. _playPause();
  433. },
  434. child: Assets.images.iconPause.image(width: 52.w, height: 52.w),
  435. );
  436. }
  437. Future<void> _onSpeedButtonTap() async {
  438. _hideTimer?.cancel();
  439. final chosenSpeed = await showModalBottomSheet<double>(
  440. context: context,
  441. isScrollControlled: true,
  442. useRootNavigator: chewieController.useRootNavigator,
  443. builder:
  444. (context) => PlaybackSpeedDialog(
  445. speeds: chewieController.playbackSpeeds,
  446. selected: _latestValue.playbackSpeed,
  447. ),
  448. );
  449. if (chosenSpeed != null) {
  450. controller.setPlaybackSpeed(chosenSpeed);
  451. }
  452. if (_latestValue.isPlaying) {
  453. _startHideTimer();
  454. }
  455. }
  456. Widget _buildPosition(Color? iconColor) {
  457. final position = _latestValue.position;
  458. final duration = _latestValue.duration;
  459. return RichText(
  460. text: TextSpan(
  461. text: '${formatDuration(position)} ',
  462. children: <InlineSpan>[
  463. TextSpan(
  464. text: '/ ${formatDuration(duration)}',
  465. style: TextStyle(
  466. fontSize: 14.0,
  467. color: Colors.white.withOpacityCompat(.75),
  468. fontWeight: FontWeight.normal,
  469. ),
  470. ),
  471. ],
  472. style: const TextStyle(
  473. fontSize: 14.0,
  474. color: Colors.white,
  475. fontWeight: FontWeight.bold,
  476. ),
  477. ),
  478. );
  479. }
  480. Widget _buildSubtitleToggle() {
  481. //if don't have subtitle hiden button
  482. if (chewieController.subtitle?.isEmpty ?? true) {
  483. return const SizedBox();
  484. }
  485. return GestureDetector(
  486. onTap: _onSubtitleTap,
  487. child: Container(
  488. height: barHeight,
  489. color: Colors.transparent,
  490. padding: const EdgeInsets.only(left: 12.0, right: 12.0),
  491. child: Icon(
  492. _subtitleOn
  493. ? Icons.closed_caption
  494. : Icons.closed_caption_off_outlined,
  495. color: _subtitleOn ? Colors.white : Colors.grey[700],
  496. ),
  497. ),
  498. );
  499. }
  500. void _onSubtitleTap() {
  501. setState(() {
  502. _subtitleOn = !_subtitleOn;
  503. });
  504. }
  505. void _cancelAndRestartTimer() {
  506. _hideTimer?.cancel();
  507. _startHideTimer();
  508. setState(() {
  509. notifier.hideStuff = false;
  510. _displayTapped = true;
  511. });
  512. }
  513. Future<void> _initialize() async {
  514. _subtitleOn =
  515. chewieController.showSubtitles &&
  516. (chewieController.subtitle?.isNotEmpty ?? false);
  517. controller.addListener(_updateState);
  518. _updateState();
  519. if (controller.value.isPlaying || chewieController.autoPlay) {
  520. _startHideTimer();
  521. }
  522. if (chewieController.showControlsOnInitialize) {
  523. _initTimer = Timer(const Duration(milliseconds: 200), () {
  524. setState(() {
  525. notifier.hideStuff = false;
  526. });
  527. });
  528. }
  529. }
  530. void _onExpandCollapse() {
  531. setState(() {
  532. notifier.hideStuff = true;
  533. chewieController.toggleFullScreen();
  534. _showAfterExpandCollapseTimer = Timer(
  535. const Duration(milliseconds: 300),
  536. () {
  537. setState(() {
  538. _cancelAndRestartTimer();
  539. });
  540. },
  541. );
  542. });
  543. }
  544. void _playPause() {
  545. final bool isFinished =
  546. (_latestValue.position >= _latestValue.duration) &&
  547. _latestValue.duration.inSeconds > 0;
  548. setState(() {
  549. if (controller.value.isPlaying) {
  550. notifier.hideStuff = false;
  551. _hideTimer?.cancel();
  552. controller.pause();
  553. } else {
  554. _cancelAndRestartTimer();
  555. if (!controller.value.isInitialized) {
  556. controller.initialize().then((_) {
  557. controller.play();
  558. });
  559. } else {
  560. if (isFinished) {
  561. controller.seekTo(Duration.zero);
  562. }
  563. controller.play();
  564. }
  565. }
  566. });
  567. }
  568. void _seekRelative(Duration relativeSeek) {
  569. _cancelAndRestartTimer();
  570. final position = _latestValue.position + relativeSeek;
  571. final duration = _latestValue.duration;
  572. if (position < Duration.zero) {
  573. controller.seekTo(Duration.zero);
  574. } else if (position > duration) {
  575. controller.seekTo(duration);
  576. } else {
  577. controller.seekTo(position);
  578. }
  579. }
  580. void _seekBackward() {
  581. _seekRelative(const Duration(seconds: -10));
  582. }
  583. void _seekForward() {
  584. _seekRelative(const Duration(seconds: 10));
  585. }
  586. void _startHideTimer() {
  587. final hideControlsTimer =
  588. chewieController.hideControlsTimer.isNegative
  589. ? ChewieController.defaultHideControlsTimer
  590. : chewieController.hideControlsTimer;
  591. _hideTimer = Timer(hideControlsTimer, () {
  592. setState(() {
  593. notifier.hideStuff = true;
  594. });
  595. });
  596. }
  597. void _bufferingTimerTimeout() {
  598. _displayBufferingIndicator = true;
  599. if (mounted) {
  600. setState(() {});
  601. }
  602. }
  603. void _updateState() {
  604. if (!mounted) return;
  605. final bool buffering = getIsBuffering(controller);
  606. // display the progress bar indicator only after the buffering delay if it has been set
  607. if (chewieController.progressIndicatorDelay != null) {
  608. if (buffering) {
  609. _bufferingDisplayTimer ??= Timer(
  610. chewieController.progressIndicatorDelay!,
  611. _bufferingTimerTimeout,
  612. );
  613. } else {
  614. _bufferingDisplayTimer?.cancel();
  615. _bufferingDisplayTimer = null;
  616. _displayBufferingIndicator = false;
  617. }
  618. } else {
  619. _displayBufferingIndicator = buffering;
  620. }
  621. setState(() {
  622. _latestValue = controller.value;
  623. _subtitlesPosition = controller.value.position;
  624. });
  625. }
  626. Widget _buildProgressBar() {
  627. return Expanded(
  628. child: MaterialVideoProgressBar(
  629. controller,
  630. onDragStart: () {
  631. setState(() {
  632. _dragging = true;
  633. });
  634. _hideTimer?.cancel();
  635. },
  636. onDragUpdate: () {
  637. _hideTimer?.cancel();
  638. },
  639. onDragEnd: () {
  640. setState(() {
  641. _dragging = false;
  642. });
  643. _startHideTimer();
  644. },
  645. colors:
  646. chewieController.materialProgressColors ??
  647. ChewieProgressColors(
  648. playedColor: Theme.of(context).colorScheme.secondary,
  649. handleColor: Theme.of(context).colorScheme.secondary,
  650. bufferedColor: Theme.of(
  651. context,
  652. ).colorScheme.surface.withOpacityCompat(0.5),
  653. backgroundColor: Theme.of(
  654. context,
  655. ).disabledColor.withOpacityCompat(.5),
  656. ),
  657. draggableProgressBar: chewieController.draggableProgressBar,
  658. ),
  659. );
  660. }
  661. }