From 2e1c29ae8a845595044dba9145ad88f8dcf787e5 Mon Sep 17 00:00:00 2001 From: kodjomoustapha <107993382+kodjodevf@users.noreply.github.com> Date: Mon, 25 Dec 2023 19:37:24 +0100 Subject: [PATCH] + --- lib/modules/anime/anime_player_view.dart | 338 ++++++++---------- .../anime_player_controller_provider.dart | 4 +- lib/modules/anime/widgets/custom_seekbar.dart | 130 +++++++ .../anime/widgets/indicator_builder.dart | 72 ++++ .../browse/sources/sources_screen.dart | 2 +- 5 files changed, 359 insertions(+), 187 deletions(-) create mode 100644 lib/modules/anime/widgets/custom_seekbar.dart create mode 100644 lib/modules/anime/widgets/indicator_builder.dart diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 117eae3b..44f10664 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart' as riv; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/video.dart' as vid; import 'package:mangayomi/modules/anime/providers/anime_player_controller_provider.dart'; +import 'package:mangayomi/modules/anime/widgets/custom_seekbar.dart'; +import 'package:mangayomi/modules/anime/widgets/indicator_builder.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; import 'package:mangayomi/modules/more/settings/player/providers/player_state_provider.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; @@ -18,7 +20,6 @@ import 'package:mangayomi/utils/media_query.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; -import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; class AnimePlayerView extends riv.ConsumerStatefulWidget { final Chapter episode; @@ -177,13 +178,15 @@ class _AnimeStreamPageState extends riv.ConsumerState { final ValueNotifier _playbackSpeed = ValueNotifier(1.0); bool _seekToCurrentPosition = true; bool _initSubtitle = true; - late Duration _currentPosition = _streamController.geTCurrentPosition(); - Duration? _currentTotalDuration; - final _showFitLabel = StateProvider((ref) => false); - final _showSeekTo = StateProvider((ref) => false); + late final ValueNotifier _currentPosition = + ValueNotifier(_streamController.geTCurrentPosition()); + final ValueNotifier _currentTotalDuration = ValueNotifier(null); + final ValueNotifier _showFitLabel = ValueNotifier(false); + final ValueNotifier _showSeekTo = ValueNotifier(false); final ValueNotifier _isCompleted = ValueNotifier(false); - final _fit = StateProvider((ref) => BoxFit.contain); - final _seekTo = StateProvider((ref) => 0); + final ValueNotifier _tempPosition = ValueNotifier(null); + final ValueNotifier _fit = ValueNotifier(BoxFit.contain); + final ValueNotifier _seekTo = ValueNotifier(0); final bool _isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; @@ -191,14 +194,15 @@ class _AnimeStreamPageState extends riv.ConsumerState { late StreamSubscription _currentPositionSub = _player.stream.position.listen( (position) async { - if (_seekToCurrentPosition && _currentPosition != Duration.zero) { + if (_seekToCurrentPosition && _currentPosition.value != Duration.zero) { await _player.stream.buffer.first; - _player.seek(_currentPosition); - _isCompleted.value = - _player.state.duration.inSeconds - _currentPosition.inSeconds <= 10; + _player.seek(_currentPosition.value); + _isCompleted.value = _player.state.duration.inSeconds - + _currentPosition.value.inSeconds <= + 10; _seekToCurrentPosition = false; } else { - _currentPosition = position; + _currentPosition.value = position; } if ((_firstVid.subtitles ?? []).isNotEmpty) { if (_initSubtitle) { @@ -213,8 +217,8 @@ class _AnimeStreamPageState extends riv.ConsumerState { late final StreamSubscription _currentTotalDurationSub = _player.stream.duration.listen( - (position) { - _currentTotalDuration = position; + (duration) { + _currentTotalDuration.value = duration; }, ); @@ -240,7 +244,7 @@ class _AnimeStreamPageState extends riv.ConsumerState { void _setCurrentPosition(bool save) { _streamController.setCurrentPosition( - _currentPosition, _currentTotalDuration, + _currentPosition.value, _currentTotalDuration.value, save: save); _streamController.setAnimeHistoryUpdate(); } @@ -310,16 +314,16 @@ class _AnimeStreamPageState extends riv.ConsumerState { _currentPositionSub = _player.stream.position.listen( (position) async { if (_seekToCurrentPosition && - _currentPosition != Duration.zero) { + _currentPosition.value != Duration.zero) { await _player.stream.buffer.first; - _player.seek(_currentPosition); + _player.seek(_currentPosition.value); try { _player.setSubtitleTrack(_subtitle.value!); } catch (_) {} _seekToCurrentPosition = false; } else { - _currentPosition = position; + _currentPosition.value = position; } }, ); @@ -655,35 +659,39 @@ class _AnimeStreamPageState extends riv.ConsumerState { BoxFit.fitWidth, BoxFit.none ]; - ref.read(_showFitLabel.notifier).state = true; + _showFitLabel.value = true; BoxFit? fit; - if (fitList.indexOf(ref.watch(_fit)) < fitList.length - 1) { - fit = fitList[fitList.indexOf(ref.watch(_fit)) + 1]; + if (fitList.indexOf(_fit.value) < fitList.length - 1) { + fit = fitList[fitList.indexOf(_fit.value) + 1]; } else { fit = fitList[0]; } - ref.read(_fit.notifier).state = fit; + _fit.value = fit; _key.currentState?.update(fit: fit); await Future.delayed(const Duration(seconds: 2)); - ref.read(_showFitLabel.notifier).state = false; + _showFitLabel.value = false; } Widget _seekToWidget() { final defaultSkipIntroLength = ref.watch(defaultSkipIntroLengthStateProvider); return SizedBox( - height: 30, + height: 35, child: ElevatedButton( onPressed: () async { - ref.read(_seekTo.notifier).state = defaultSkipIntroLength; - ref.read(_showSeekTo.notifier).state = true; + _seekTo.value = defaultSkipIntroLength; + _showSeekTo.value = true; await _player.seek(Duration( - seconds: _currentPosition.inSeconds + defaultSkipIntroLength)); - ref.read(_seekTo.notifier).state = 0; - ref.read(_showSeekTo.notifier).state = false; + seconds: + _currentPosition.value.inSeconds + defaultSkipIntroLength)); + _seekTo.value = 0; + _showSeekTo.value = false; }, - child: Text("+$defaultSkipIntroLength", - style: const TextStyle(fontWeight: FontWeight.bold))), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text("+$defaultSkipIntroLength", + style: const TextStyle(fontWeight: FontWeight.w100)), + )), ); } @@ -736,26 +744,16 @@ class _AnimeStreamPageState extends riv.ConsumerState { ], ), ), - const Padding( - padding: EdgeInsets.only(bottom: 20), - child: Row( - children: [ - SizedBox( - width: 70, - child: Center( - child: MaterialMobilePositionIndicator(left: true))), - Expanded( - child: SizedBox( - height: 20, - child: Padding( - padding: EdgeInsets.only(bottom: 7), - child: MaterialSeekBar())), - ), - SizedBox( - width: 70, - child: Center( - child: MaterialMobilePositionIndicator(left: false))) - ], + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: CustomSeekBar( + player: _controller.player, + onSeekStart: (start) { + _tempPosition.value = start; + }, + onSeekEnd: (end) { + _tempPosition.value = null; + }, ), ), ], @@ -783,7 +781,17 @@ class _AnimeStreamPageState extends riv.ConsumerState { ], ), ), - const SizedBox(height: 20, child: MaterialDesktopSeekBar()), + SizedBox( + height: 20, + child: CustomSeekBar( + player: _controller.player, + onSeekStart: (start) { + _tempPosition.value = start; + }, + onSeekEnd: (end) { + _tempPosition.value = null; + }, + )), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -931,9 +939,8 @@ class _AnimeStreamPageState extends riv.ConsumerState { } Widget _videoPlayer(BuildContext context) { - final fit = ref.watch(_fit); + final fit = _fit.value; _resize(fit); - final seekTo = ref.watch(_seekTo); return Stack( children: [ Video( @@ -953,38 +960,42 @@ class _AnimeStreamPageState extends riv.ConsumerState { height: mediaHeight(context, 1), resumeUponEnteringForegroundMode: true, ), - if (ref.watch(_showSeekTo)) - Positioned.fill( - child: UnconstrainedBox( - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(64.0), - ), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 17, vertical: 8), - child: Text( - "+ $seekTo", - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 20), - ), - ), - ), - ), - ), - if (ref.watch(_showFitLabel)) - Positioned.fill( - child: Center( - child: Text( - fit.name.toUpperCase(), - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20), - ))) + ValueListenableBuilder( + valueListenable: _showSeekTo, + builder: (context, showSeekTo, child) => showSeekTo + ? ValueListenableBuilder( + valueListenable: _seekTo, + builder: (context, seekTo, child) => Positioned.fill( + child: UnconstrainedBox( + child: Text( + "[+${Duration(seconds: seekTo).label()}]", + style: const TextStyle( + fontSize: 40.0, + color: Colors.white, + fontWeight: FontWeight.bold), + ), + ), + ), + ) + : Container()), + ValueListenableBuilder( + valueListenable: _showFitLabel, + builder: (context, showFitLabel, child) => showFitLabel + ? ValueListenableBuilder( + valueListenable: _fit, + builder: (context, fit, child) => Positioned.fill( + child: Positioned.fill( + child: Center( + child: Text( + fit.name.toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 40.0), + ))), + ), + ) + : Container()), ], ); } @@ -998,37 +1009,36 @@ class _AnimeStreamPageState extends riv.ConsumerState { seekOnDoubleTap: true, seekGesture: true, horizontalGestureSensitivity: 5000, - verticalGestureSensitivity: 300, - controlsHoverDuration: const Duration(seconds: 10), + verticalGestureSensitivity: 1000, + controlsHoverDuration: const Duration(seconds: 10000), volumeGesture: true, brightnessGesture: true, seekBarThumbSize: 15, seekBarHeight: 5, displaySeekBar: false, + volumeIndicatorBuilder: (_, value) => + MediaIndicatorBuilder(value: value, isVolumeIndicator: true), + brightnessIndicatorBuilder: (_, value) => + MediaIndicatorBuilder(value: value, isVolumeIndicator: false), seekIndicatorBuilder: (context, duration) { - final swipeDuration = duration.inSeconds; - final value = _currentPosition.inSeconds + swipeDuration; - return Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(64.0), - ), - height: 52.0, - width: 108.0, - child: Text( - Duration(seconds: value).label(), - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14.0, - color: Color(0xFFFFFFFF), - ), - ), - ); + return _seekIndicatorTextWidget(duration, _currentPosition.value); }, seekBarPositionColor: primaryColor(context), seekBarThumbColor: primaryColor(context), - primaryButtonBar: _mobilePrimaryButtonBar(isFullScreen), + primaryButtonBar: [ + ValueListenableBuilder( + valueListenable: _tempPosition, + builder: (context, snapshot, _) { + return snapshot != null + ? _seekIndicatorTextWidget( + snapshot, _currentPosition.value) + : Expanded( + child: Row( + children: _mobilePrimaryButtonBar(isFullScreen), + ), + ); + }) + ], topButtonBarMargin: const EdgeInsets.all(0), topButtonBar: _topButtonBar(context, isFullScreen), bottomButtonBarMargin: const EdgeInsets.only(left: 8, right: 8), @@ -1057,14 +1067,14 @@ class _AnimeStreamPageState extends riv.ConsumerState { chapter: _streamController.getPrevEpisode()); } : null, - icon: const Icon( + icon: Icon( Icons.skip_previous, - size: 30, - color: Colors.white, + size: 35, + color: hasPrevEpisode ? Colors.white : Colors.grey, ), ), const Spacer(), - const MaterialPlayOrPauseButton(iconSize: 55), + const MaterialPlayOrPauseButton(iconSize: 65), const Spacer(), IconButton( onPressed: hasNextEpisode @@ -1078,7 +1088,8 @@ class _AnimeStreamPageState extends riv.ConsumerState { ); } : null, - icon: const Icon(Icons.skip_next, size: 30, color: Colors.white), + icon: Icon(Icons.skip_next, + size: 35, color: hasPrevEpisode ? Colors.white : Colors.grey), ), const Spacer(flex: 3) ]; @@ -1095,7 +1106,17 @@ class _AnimeStreamPageState extends riv.ConsumerState { topButtonBarMargin: const EdgeInsets.all(0), bottomButtonBarMargin: const EdgeInsets.all(0), topButtonBar: _topButtonBar(context, isFullScreen), - buttonBarHeight: 100, + primaryButtonBar: [ + ValueListenableBuilder( + valueListenable: _tempPosition, + builder: (context, snapshot, _) { + return snapshot != null + ? _seekIndicatorTextWidget( + snapshot, _currentPosition.value) + : const SizedBox.shrink(); + }) + ], + buttonBarHeight: 110, displaySeekBar: false, seekBarThumbSize: 15, bottomButtonBar: _desktopBottomButtonBar(context, isFullScreen)); @@ -1121,77 +1142,24 @@ class _AnimeStreamPageState extends riv.ConsumerState { } } -class MaterialMobilePositionIndicator extends StatefulWidget { - final bool left; - - /// Overriden [TextStyle] for the [MaterialMobilePositionIndicator]. - final TextStyle? style; - const MaterialMobilePositionIndicator( - {super.key, this.style, required this.left}); - - @override - MaterialMobilePositionIndicatorState createState() => - MaterialMobilePositionIndicatorState(); -} - -class MaterialMobilePositionIndicatorState - extends State { - late Duration position = controller(context).player.state.position; - late Duration duration = controller(context).player.state.duration; - - final List subscriptions = []; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (subscriptions.isEmpty) { - subscriptions.addAll( - [ - controller(context).player.stream.position.listen((event) { - setState(() { - position = event; - }); - }), - controller(context).player.stream.duration.listen((event) { - setState(() { - duration = event; - }); - }), - ], - ); - } - } - - @override - void dispose() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return widget.left - ? Text( - position.label(reference: duration), - style: widget.style ?? - const TextStyle( - height: 1.0, - fontSize: 12.0, - color: Colors.white, - ), - ) - : Text( - duration.label(reference: duration), - style: widget.style ?? - const TextStyle( - height: 1.0, - fontSize: 12.0, - color: Colors.white, - ), - ); - } +Widget _seekIndicatorTextWidget(Duration duration, Duration currentPosition) { + final swipeDuration = duration.inSeconds; + final value = currentPosition.inSeconds + swipeDuration; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + Duration(seconds: value).label(), + style: const TextStyle( + fontSize: 65.0, fontWeight: FontWeight.bold, color: Colors.white), + ), + Text( + "[${swipeDuration > 0 ? "+${Duration(seconds: swipeDuration).label()}" : "-${Duration(seconds: swipeDuration).label()}"}]", + style: const TextStyle( + fontSize: 40.0, color: Colors.white, fontWeight: FontWeight.bold), + ), + ], + ); } class VideoPrefs { diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index 1fcd0793..6001e864 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -150,7 +150,9 @@ class AnimeStreamController extends _$AnimeStreamController { if (episode.isRead!) return; if (incognitoMode) return; final markEpisodeAsSeenType = ref.watch(markEpisodeAsSeenTypeStateProvider); - final isWatch = totalDuration != null + final isWatch = totalDuration != null && + totalDuration != Duration.zero && + duration != Duration.zero ? duration.inSeconds >= ((totalDuration.inSeconds * markEpisodeAsSeenType) / 100).ceil() : false; diff --git a/lib/modules/anime/widgets/custom_seekbar.dart b/lib/modules/anime/widgets/custom_seekbar.dart new file mode 100644 index 00000000..b1d73896 --- /dev/null +++ b/lib/modules/anime/widgets/custom_seekbar.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; + +class CustomSeekBar extends StatefulWidget { + final Player player; + final Function(Duration) onSeekStart; + final Function(Duration) onSeekEnd; + + const CustomSeekBar( + {super.key, + required this.onSeekStart, + required this.onSeekEnd, + required this.player}); + + @override + CustomSeekBarState createState() => CustomSeekBarState(); +} + +class CustomSeekBarState extends State { + Duration? tempPosition; + late Player player = widget.player; + Duration position = Duration.zero; + late Duration duration = player.state.duration; + Duration buffer = Duration.zero; + + @override + void initState() { + player.stream.position.listen((event) { + if (mounted) { + setState(() { + position = event; + }); + } + }); + player.stream.duration.listen((event) { + if (mounted) { + setState(() { + duration = event; + }); + } + }); + player.stream.buffer.listen((event) { + if (mounted) { + setState(() { + buffer = event; + }); + } + }); + position = player.state.position; + duration = player.state.duration; + buffer = player.state.buffer; + super.initState(); + } + + final isDesktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; + @override + Widget build(BuildContext context) { + return SizedBox( + height: 20, + child: Row( + children: [ + if (!isDesktop) + SizedBox( + width: 70, + child: Center( + child: Text( + tempPosition != null + ? tempPosition!.label(reference: duration) + : position.label(reference: duration), + style: const TextStyle( + height: 1.0, + fontSize: 12.0, + color: Colors.white, + ), + ))), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: isDesktop ? null : 3, + overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0), + ), + child: Slider( + max: max(duration.inMilliseconds.toDouble(), 0), + value: max( + (tempPosition ?? position).inMilliseconds.toDouble(), 0), + secondaryTrackValue: max(buffer.inMilliseconds.toDouble(), 0), + onChanged: (value) { + widget.onSeekStart(Duration( + milliseconds: value.toInt() - position.inMilliseconds)); + if (mounted) { + setState(() { + tempPosition = Duration(milliseconds: value.toInt()); + }); + } + }, + onChangeEnd: (value) async { + widget.onSeekEnd(Duration( + milliseconds: value.toInt() - position.inMilliseconds)); + widget.player.seek(Duration(milliseconds: value.toInt())); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { + setState(() { + tempPosition = null; + }); + } + }, + ), + ), + ), + if (!isDesktop) + SizedBox( + width: 70, + child: Center( + child: Text( + duration.label(reference: duration), + style: const TextStyle( + height: 1.0, + fontSize: 12.0, + color: Colors.white, + ), + ))), + ], + ), + ); + } +} diff --git a/lib/modules/anime/widgets/indicator_builder.dart b/lib/modules/anime/widgets/indicator_builder.dart new file mode 100644 index 00000000..39e6a294 --- /dev/null +++ b/lib/modules/anime/widgets/indicator_builder.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +class MediaIndicatorBuilder extends StatelessWidget { + final bool isVolumeIndicator; + final double value; + const MediaIndicatorBuilder( + {super.key, required this.value, required this.isVolumeIndicator}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: + isVolumeIndicator ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(100), + ), + width: 30, + child: UnconstrainedBox( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + children: [ + Text((value * 100).ceil().toString()), + Padding( + padding: const EdgeInsets.all(5), + child: RotatedBox( + quarterTurns: -1, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white54, + borderRadius: BorderRadius.circular(100), + ), + child: SizedBox.fromSize( + size: const Size(170, 10), + child: LinearProgressIndicator( + value: value, + backgroundColor: Colors.transparent), + ), + ), + ), + ), + Icon( + isVolumeIndicator + ? switch (value) { + == 0.0 => Icons.volume_off, + < 0.5 => Icons.volume_down, + _ => Icons.volume_up, + } + : switch (value) { + < 1.0 / 3.0 => Icons.brightness_low, + < 2.0 / 3.0 => Icons.brightness_medium, + _ => Icons.brightness_high, + }, + color: Colors.white, + ) + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/browse/sources/sources_screen.dart b/lib/modules/browse/sources/sources_screen.dart index 4674b438..1d0a30d8 100644 --- a/lib/modules/browse/sources/sources_screen.dart +++ b/lib/modules/browse/sources/sources_screen.dart @@ -39,7 +39,7 @@ class SourcesScreen extends ConsumerWidget { List sources = snapshot.data! .where((element) => showNSFW ? true : element.isNsfw == false) .toList(); - if (sources.isEmpty) { + if (sources.isEmpty && !useTestSourceCode) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [