diff --git a/lib/eval/bridge/m_provider.dart b/lib/eval/bridge/m_provider.dart index 89d9972..504e951 100644 --- a/lib/eval/bridge/m_provider.dart +++ b/lib/eval/bridge/m_provider.dart @@ -191,6 +191,11 @@ class $MProvider extends MProvider with $Bridge { 'url', BridgeTypeAnnotation(BridgeTypeRef(CoreTypes.string)), false), + BridgeParameter( + 'prefix', + BridgeTypeAnnotation(BridgeTypeRef(CoreTypes.string), + nullable: true), + true), ]), ), 'myTvExtractor': BridgeMethodDef( @@ -799,7 +804,7 @@ class $MProvider extends MProvider with $Bridge { }), "substringBeforeLast" => $Function((_, __, List<$Value?> args) { return $String( - MBridge.substringBefore(args[0]!.$value, args[1]!.$value)); + MBridge.substringBeforeLast(args[0]!.$value, args[1]!.$value)); }), "substringAfterLast" => $Function((_, __, List<$Value?> args) { return $String( @@ -808,9 +813,10 @@ class $MProvider extends MProvider with $Bridge { /////////////////////////////////////////////////////////////////////// - "sibnetExtractor" => $Function((_, __, List<$Value?> args) => - $Future.wrap(MBridge.sibnetExtractor(args[0]!.$value).then( - (value) => $List.wrap(value.map((e) => _toMVideo(e)).toList())))), + "sibnetExtractor" => $Function((_, __, List<$Value?> args) => $Future + .wrap(MBridge.sibnetExtractor(args[0]!.$value, args[1]?.$value ?? "") + .then((value) => + $List.wrap(value.map((e) => _toMVideo(e)).toList())))), "myTvExtractor" => $Function((_, __, List<$Value?> args) => $Future.wrap( MBridge.myTvExtractor(args[0]!.$value).then( (value) => $List.wrap(value.map((e) => _toMVideo(e)).toList())))), diff --git a/lib/eval/model/m_bridge.dart b/lib/eval/model/m_bridge.dart index 5e6f2c5..aa4b75c 100644 --- a/lib/eval/model/m_bridge.dart +++ b/lib/eval/model/m_bridge.dart @@ -646,8 +646,8 @@ class MBridge { return Deobfuscator.deobfuscateJsPassword(inputString); } - static Future> sibnetExtractor(String url) async { - return await SibnetExtractor().videosFromUrl(url); + static Future> sibnetExtractor(String url, String prefix) async { + return await SibnetExtractor().videosFromUrl(url, prefix: prefix); } static Future> sendVidExtractor( diff --git a/lib/main.dart b/lib/main.dart index 91cc104..324a8d9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,7 @@ import 'package:mangayomi/modules/more/settings/appearance/providers/theme_mode_ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:media_kit/media_kit.dart'; import 'package:rinf/rinf.dart'; +import 'package:window_manager/window_manager.dart'; // Global instance of the Isar database. late Isar isar; @@ -46,7 +47,7 @@ void main(List args) async { // Ensure widget and media kits are initialized. WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); - + await windowManager.ensureInitialized(); // Initialize the Isar database. isar = await StorageProvider().initDB(null); await StorageProvider().requestPermission(); diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 93dd5c0..108afcc 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:bot_toast/bot_toast.dart'; import 'package:draggable_menu/draggable_menu.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,19 +9,18 @@ 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/anime/widgets/desktop.dart'; +import 'package:mangayomi/modules/anime/widgets/mobile.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'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/get_video_list.dart'; -import 'package:mangayomi/utils/colors.dart'; 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:screen_brightness/screen_brightness.dart'; +import 'package:window_manager/window_manager.dart'; class AnimePlayerView extends riv.ConsumerStatefulWidget { final Chapter episode; @@ -211,7 +211,7 @@ class _AnimeStreamPageState extends riv.ConsumerState { _currentTotalDuration.value = duration; }, ); - double _brightnessValue = 0.0; + @override void initState() { _setCurrentPosition(true); @@ -220,18 +220,6 @@ class _AnimeStreamPageState extends riv.ConsumerState { _player.open(Media(_video.value!.videoTrack!.id, httpHeaders: _video.value!.headers)); _setPlaybackSpeed(ref.read(defaultPlayBackSpeedStateProvider)); - Future.microtask(() async { - try { - _brightnessValue = await ScreenBrightness().current; - ScreenBrightness().onCurrentBrightnessChanged.listen((value) { - if (mounted) { - setState(() { - _brightnessValue = value; - }); - } - }); - } catch (_) {} - }); super.initState(); } @@ -241,7 +229,7 @@ class _AnimeStreamPageState extends riv.ConsumerState { _player.dispose(); _currentPositionSub.cancel(); _currentTotalDurationSub.cancel(); - _setFullscreen(false); + _setLandscapeMode(false); super.dispose(); } @@ -252,7 +240,7 @@ class _AnimeStreamPageState extends riv.ConsumerState { _streamController.setAnimeHistoryUpdate(); } - void _setFullscreen(bool state) { + void _setLandscapeMode(bool state) { if (state) { SystemChrome.setPreferredOrientations( [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); @@ -685,8 +673,11 @@ class _AnimeStreamPageState extends riv.ConsumerState { } _fit.value = fit; _key.currentState?.update(fit: fit); - await Future.delayed(const Duration(seconds: 2)); - _showFitLabel.value = false; + BotToast.showText( + onlyOne: true, + align: const Alignment(0, 0.90), + duration: const Duration(seconds: 1), + text: fit.name.toUpperCase()); } Widget _seekToWidget() { @@ -716,168 +707,28 @@ class _AnimeStreamPageState extends riv.ConsumerState { ); } - List _mobileBottomButtonBar(BuildContext context, bool isFullScreen) { - return [ - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _seekToWidget(), - Row( - children: [ - if (!isFullScreen) - IconButton( - padding: const EdgeInsets.all(5), - onPressed: () => _videoSettingDraggableMenu(context), - icon: const Icon( - Icons.video_settings, - color: Colors.white, - ), - ), - TextButton( - child: ValueListenableBuilder( - valueListenable: _playbackSpeed, - builder: (context, value, child) { - return Text( - "${value}x", - style: const TextStyle(color: Colors.white), - ); - }, - ), - onPressed: () { - _togglePlaybackSpeed(); - }), - IconButton( - icon: const Icon(Icons.fit_screen_outlined, - color: Colors.white), - onPressed: () async { - _changeFitLabel(ref); - }, - ), - ValueListenableBuilder( - valueListenable: _enterFullScreen, - builder: (context, snapshot, _) { - return IconButton( - onPressed: () { - _setFullscreen(!snapshot); - _enterFullScreen.value = !snapshot; - }, - icon: Icon(snapshot - ? Icons.fullscreen_exit - : Icons.fullscreen), - iconSize: 25, - color: Colors.white, - ); - }) - ], - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: CustomSeekBar( - player: _controller.player, - onSeekStart: (start) { - _tempPosition.value = start; - }, - onSeekEnd: (end) { - _tempPosition.value = null; - }, - ), - ), - ], - ), - ) - ]; - } - - List _desktopBottomButtonBar( - BuildContext context, bool isFullScreen) { - bool hasPrevEpisode = _streamController.getEpisodeIndex().$1 + 1 != - _streamController - .getEpisodesLength(_streamController.getEpisodeIndex().$2); - bool hasNextEpisode = _streamController.getEpisodeIndex().$1 != 0; - return [ - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Row( - children: [ - _seekToWidget(), - ], - ), - ), - SizedBox( - height: 20, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), - child: CustomSeekBar( - player: _controller.player, - onSeekStart: (start) { - _tempPosition.value = start; - }, - onSeekEnd: (end) { - _tempPosition.value = null; - }, - ), - )), - Row( + Widget _mobileBottomButtonBar(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 30), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + _seekToWidget(), Row( children: [ - if (hasPrevEpisode) - IconButton( - onPressed: () { - if (isFullScreen) { - _key.currentState?.exitFullscreen(); - } - pushReplacementMangaReaderView( - context: context, - chapter: _streamController.getPrevEpisode()); - }, - icon: const Icon( - Icons.skip_previous, - color: Colors.white, - ), - ), - const MaterialDesktopPlayOrPauseButton(iconSize: 25), - if (hasNextEpisode) - IconButton( - onPressed: () { - if (isFullScreen) { - _key.currentState?.exitFullscreen(); - } - pushReplacementMangaReaderView( - context: context, - chapter: _streamController.getNextEpisode(), - ); - }, - icon: const Icon(Icons.skip_next, color: Colors.white), - ), - const MaterialDesktopVolumeButton(iconSize: 25), - const MaterialDesktopPositionIndicator() - ], - ), - Row( - children: [ - if (!isFullScreen) - IconButton( - onPressed: () => _videoSettingDraggableMenu(context), - icon: const Icon( - Icons.video_settings, - color: Colors.white, - ), + IconButton( + padding: const EdgeInsets.all(5), + onPressed: () => _videoSettingDraggableMenu(context), + icon: const Icon( + Icons.video_settings, + color: Colors.white, ), + ), TextButton( child: ValueListenableBuilder( valueListenable: _playbackSpeed, @@ -898,73 +749,187 @@ class _AnimeStreamPageState extends riv.ConsumerState { _changeFitLabel(ref); }, ), - const MaterialDesktopFullscreenButton() + ValueListenableBuilder( + valueListenable: _enterFullScreen, + builder: (context, snapshot, _) { + return IconButton( + onPressed: () { + _setLandscapeMode(!snapshot); + _enterFullScreen.value = !snapshot; + }, + icon: Icon(snapshot + ? Icons.fullscreen_exit + : Icons.fullscreen), + iconSize: 25, + color: Colors.white, + ); + }) ], ), ], ), - ], - ), - ) - ]; + ), + ], + ), + ); } - List _topButtonBar(BuildContext context, bool isFullScreen) { - return [ - Flexible( - child: Row( + Widget _desktopBottomButtonBar(BuildContext context) { + bool hasPrevEpisode = _streamController.getEpisodeIndex().$1 + 1 != + _streamController + .getEpisodesLength(_streamController.getEpisodeIndex().$2); + bool hasNextEpisode = _streamController.getEpisodeIndex().$1 != 0; + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (isFullScreen && (Platform.isIOS || Platform.isAndroid)) ...[ - MaterialFullscreenButton( - icon: Icon(Platform.isIOS || Platform.isMacOS - ? Icons.arrow_back_ios - : Icons.arrow_back), - ) - ] else ...[ - if (isFullScreen) - MaterialDesktopFullscreenButton( - icon: Icon(Platform.isMacOS - ? Icons.arrow_back_ios - : Icons.arrow_back)) - ], - if (!isFullScreen) - BackButton( - color: Colors.white, - onPressed: () { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, - overlays: SystemUiOverlay.values); - Navigator.pop(context); - }, - ), - Flexible( - child: ListTile( - dense: true, - title: SizedBox( - width: mediaWidth(context, 0.8), - child: Text( - widget.episode.manga.value!.name!, - style: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, + Row( + children: [ + if (hasPrevEpisode) + IconButton( + onPressed: () async { + if (_isDesktop) { + final isFullScreen = await windowManager.isFullScreen(); + if (isFullScreen) { + await setFullScreen(value: false); + } + } + if (mounted) { + pushReplacementMangaReaderView( + context: context, + chapter: _streamController.getPrevEpisode()); + } + }, + icon: const Icon( + Icons.skip_previous, + color: Colors.white, + ), + ), + CustomeMaterialDesktopPlayOrPauseButton( + controller: _controller, + ), + if (hasNextEpisode) + IconButton( + onPressed: () async { + if (_isDesktop) { + final isFullScreen = await windowManager.isFullScreen(); + if (isFullScreen) { + await setFullScreen(value: false); + } + } + if (mounted) { + pushReplacementMangaReaderView( + context: context, + chapter: _streamController.getNextEpisode(), + ); + } + }, + icon: const Icon(Icons.skip_next, color: Colors.white), + ), + CustomMaterialDesktopVolumeButton( + controller: _controller, + ), + ValueListenableBuilder( + valueListenable: _tempPosition, + builder: (context, value, child) => + CustomMaterialDesktopPositionIndicator( + delta: value, controller: _controller), + ) + ], + ), + Row( + children: [ + IconButton( + onPressed: () => _videoSettingDraggableMenu(context), + icon: const Icon( + Icons.video_settings, + color: Colors.white, ), ), - subtitle: SizedBox( - width: mediaWidth(context, 0.8), - child: Text( - widget.episode.name!, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: Colors.white.withOpacity(0.7)), - overflow: TextOverflow.ellipsis, - ), + TextButton( + child: ValueListenableBuilder( + valueListenable: _playbackSpeed, + builder: (context, value, child) { + return Text( + "${value}x", + style: const TextStyle(color: Colors.white), + ); + }, + ), + onPressed: () { + _togglePlaybackSpeed(); + }), + IconButton( + icon: const Icon(Icons.fit_screen_outlined, + color: Colors.white), + onPressed: () async { + _changeFitLabel(ref); + }, ), - ), + CustomMaterialDesktopFullscreenButton( + controller: _controller, + ) + ], ), ], ), - ), - ]; + ], + ); + } + + Widget _topButtonBar(BuildContext context) { + return Row( + children: [ + BackButton( + color: Colors.white, + onPressed: () async { + if (_isDesktop) { + final isFullScreen = await windowManager.isFullScreen(); + if (isFullScreen) { + setFullScreen(value: false); + } else { + if (mounted) { + Navigator.pop(context); + } + } + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: SystemUiOverlay.values); + if (mounted) { + Navigator.pop(context); + } + } + }, + ), + Flexible( + child: ListTile( + dense: true, + title: SizedBox( + width: mediaWidth(context, 0.8), + child: Text( + widget.episode.manga.value!.name!, + style: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: SizedBox( + width: mediaWidth(context, 0.8), + child: Text( + widget.episode.name!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: Colors.white.withOpacity(0.7)), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ); } void _resize(BoxFit fit) async { @@ -980,173 +945,51 @@ class _AnimeStreamPageState extends riv.ConsumerState { Widget _videoPlayer(BuildContext context) { final fit = _fit.value; _resize(fit); - return Stack( - children: [ - Video( - subtitleViewConfiguration: const SubtitleViewConfiguration( - style: TextStyle( - fontSize: 50, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: "", - shadows: [Shadow(offset: Offset(0.2, 0.0), blurRadius: 7.0)], - backgroundColor: Colors.transparent), - ), - fit: fit, - key: _key, - controller: _controller, - width: mediaWidth(context, 1), - height: mediaHeight(context, 1), - resumeUponEnteringForegroundMode: true, - ), - 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()), - ], + return Video( + subtitleViewConfiguration: const SubtitleViewConfiguration( + style: TextStyle( + fontSize: 50, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: "", + shadows: [Shadow(offset: Offset(0.2, 0.0), blurRadius: 7.0)], + backgroundColor: Colors.transparent), + ), + fit: fit, + key: _key, + controls: (state) => _isDesktop + ? DestopControllerWidget( + videoController: _controller, + topButtonBarWidget: _topButtonBar(context), + videoStatekey: _key, + bottomButtonBarWidget: _desktopBottomButtonBar(context), + streamController: _streamController, + seekToWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Row( + children: [ + _seekToWidget(), + ], + ), + ), + tempDuration: (value) { + _tempPosition.value = value; + }, + ) + : MobileControllerWidget( + videoController: _controller, + topButtonBarWidget: _topButtonBar(context), + videoStatekey: _key, + bottomButtonBarWidget: _mobileBottomButtonBar(context), + streamController: _streamController, + ), + controller: _controller, + width: mediaWidth(context, 1), + height: mediaHeight(context, 1), + resumeUponEnteringForegroundMode: true, ); } - Widget _mobilePlayer() { - MaterialVideoControlsThemeData materialVideoControlsThemeData( - bool isFullScreen) => - MaterialVideoControlsThemeData( - visibleOnMount: true, - buttonBarHeight: 100, - seekOnDoubleTap: true, - seekGesture: true, - horizontalGestureSensitivity: 5000, - verticalGestureSensitivity: 500, - controlsHoverDuration: const Duration(seconds: 15), - volumeGesture: true, - brightnessGesture: true, - seekBarThumbSize: 15, - seekBarHeight: 5, - displaySeekBar: false, - volumeIndicatorBuilder: (_, value) => - MediaIndicatorBuilder(value: value, isVolumeIndicator: true), - brightnessIndicatorBuilder: (_, value) => MediaIndicatorBuilder( - value: _brightnessValue, isVolumeIndicator: false), - seekIndicatorBuilder: (context, duration) { - return _seekIndicatorTextWidget(duration, _currentPosition.value); - }, - seekBarPositionColor: primaryColor(context), - seekBarThumbColor: primaryColor(context), - 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), - bottomButtonBar: _mobileBottomButtonBar(context, isFullScreen)); - return MaterialVideoControlsTheme( - normal: materialVideoControlsThemeData(false), - fullscreen: materialVideoControlsThemeData(true), - child: _videoPlayer(context)); - } - - List _mobilePrimaryButtonBar(bool isFullScreen) { - bool hasPrevEpisode = _streamController.getEpisodeIndex().$1 + 1 != - _streamController - .getEpisodesLength(_streamController.getEpisodeIndex().$2); - bool hasNextEpisode = _streamController.getEpisodeIndex().$1 != 0; - return [ - const Spacer(flex: 3), - IconButton( - onPressed: hasPrevEpisode - ? () { - if (isFullScreen) { - _key.currentState?.exitFullscreen(); - } - pushReplacementMangaReaderView( - context: context, - chapter: _streamController.getPrevEpisode()); - } - : null, - icon: Icon( - Icons.skip_previous, - size: 35, - color: hasPrevEpisode ? Colors.white : Colors.grey, - ), - ), - const Spacer(), - const MaterialPlayOrPauseButton(iconSize: 65), - const Spacer(), - IconButton( - onPressed: hasNextEpisode - ? () { - if (isFullScreen) { - _key.currentState?.exitFullscreen(); - } - pushReplacementMangaReaderView( - context: context, - chapter: _streamController.getNextEpisode(), - ); - } - : null, - icon: Icon(Icons.skip_next, - size: 35, color: hasPrevEpisode ? Colors.white : Colors.grey), - ), - const Spacer(flex: 3) - ]; - } - - Widget _desktopPlayer() { - MaterialDesktopVideoControlsThemeData materialVideoControlsThemeData( - bool isFullScreen) => - MaterialDesktopVideoControlsThemeData( - visibleOnMount: true, - controlsHoverDuration: const Duration(seconds: 2), - seekBarPositionColor: primaryColor(context), - seekBarThumbColor: primaryColor(context), - topButtonBarMargin: const EdgeInsets.all(0), - bottomButtonBarMargin: const EdgeInsets.all(0), - topButtonBar: _topButtonBar(context, isFullScreen), - primaryButtonBar: [ - ValueListenableBuilder( - valueListenable: _tempPosition, - builder: (context, snapshot, _) { - return snapshot != null - ? _seekIndicatorTextWidget( - snapshot, _currentPosition.value) - : const SizedBox.shrink(); - }) - ], - buttonBarHeight: 120, - displaySeekBar: false, - seekBarThumbSize: 15, - bottomButtonBar: _desktopBottomButtonBar(context, isFullScreen)); - return MaterialDesktopVideoControlsTheme( - normal: materialVideoControlsThemeData(false), - fullscreen: materialVideoControlsThemeData(true), - child: _videoPlayer(context)); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -1157,13 +1000,13 @@ class _AnimeStreamPageState extends riv.ConsumerState { Navigator.pop(context); return false; }, - child: _isDesktop ? _desktopPlayer() : _mobilePlayer(), + child: _videoPlayer(context), ), ); } } -Widget _seekIndicatorTextWidget(Duration duration, Duration currentPosition) { +Widget seekIndicatorTextWidget(Duration duration, Duration currentPosition) { final swipeDuration = duration.inSeconds; final value = currentPosition.inSeconds + swipeDuration; return Column( diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index 6001e86..ffb7b6b 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -168,4 +168,5 @@ class AnimeStreamController extends _$AnimeStreamController { } } } + } diff --git a/lib/modules/anime/widgets/custom_seekbar.dart b/lib/modules/anime/widgets/custom_seekbar.dart index b1d7389..0914932 100644 --- a/lib/modules/anime/widgets/custom_seekbar.dart +++ b/lib/modules/anime/widgets/custom_seekbar.dart @@ -7,14 +7,16 @@ import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions class CustomSeekBar extends StatefulWidget { final Player player; - final Function(Duration) onSeekStart; - final Function(Duration) onSeekEnd; + final Duration? delta; + final Function(Duration)? onSeekStart; + final Function(Duration)? onSeekEnd; const CustomSeekBar( {super.key, - required this.onSeekStart, - required this.onSeekEnd, - required this.player}); + this.onSeekStart, + this.onSeekEnd, + required this.player, + this.delta}); @override CustomSeekBarState createState() => CustomSeekBarState(); @@ -68,9 +70,8 @@ class CustomSeekBarState extends State { width: 70, child: Center( child: Text( - tempPosition != null - ? tempPosition!.label(reference: duration) - : position.label(reference: duration), + (widget.delta ?? tempPosition ?? position) + .label(reference: duration), style: const TextStyle( height: 1.0, fontSize: 12.0, @@ -86,10 +87,13 @@ class CustomSeekBarState extends State { child: Slider( max: max(duration.inMilliseconds.toDouble(), 0), value: max( - (tempPosition ?? position).inMilliseconds.toDouble(), 0), + (widget.delta ?? tempPosition ?? position) + .inMilliseconds + .toDouble(), + 0), secondaryTrackValue: max(buffer.inMilliseconds.toDouble(), 0), onChanged: (value) { - widget.onSeekStart(Duration( + widget.onSeekStart?.call(Duration( milliseconds: value.toInt() - position.inMilliseconds)); if (mounted) { setState(() { @@ -98,7 +102,7 @@ class CustomSeekBarState extends State { } }, onChangeEnd: (value) async { - widget.onSeekEnd(Duration( + widget.onSeekEnd?.call(Duration( milliseconds: value.toInt() - position.inMilliseconds)); widget.player.seek(Duration(milliseconds: value.toInt())); await Future.delayed(const Duration(milliseconds: 500)); diff --git a/lib/modules/anime/widgets/desktop.dart b/lib/modules/anime/widgets/desktop.dart new file mode 100644 index 0000000..231c738 --- /dev/null +++ b/lib/modules/anime/widgets/desktop.dart @@ -0,0 +1,821 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mangayomi/modules/anime/anime_player_view.dart'; +import 'package:mangayomi/modules/anime/providers/anime_player_controller_provider.dart'; +import 'package:mangayomi/modules/anime/widgets/custom_seekbar.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:window_manager/window_manager.dart'; + +class DestopControllerWidget extends StatefulWidget { + final Function(Duration?) tempDuration; + final AnimeStreamController streamController; + final VideoController videoController; + final Widget topButtonBarWidget; + final GlobalKey videoStatekey; + final Widget bottomButtonBarWidget; + final Widget seekToWidget; + const DestopControllerWidget( + {super.key, + required this.videoController, + required this.topButtonBarWidget, + required this.bottomButtonBarWidget, + required this.streamController, + required this.videoStatekey, + required this.seekToWidget, + required this.tempDuration}); + + @override + State createState() => _DestopControllerWidgetState(); +} + +class _DestopControllerWidgetState extends State { + bool mount = true; + bool visible = true; + Duration controlsTransitionDuration = const Duration(milliseconds: 300); + Color backdropColor = const Color(0x66000000); + Timer? _timer; + + int swipeDuration = 0; // Duration to seek in video + bool showSwipeDuration = false; // Whether to show the seek duration overlay + + late bool buffering = widget.videoController.player.state.buffering; + final controlsHoverDuration = const Duration(seconds: 3); + double buttonBarHeight = 100; + final bottomButtonBarMargin = const EdgeInsets.only(left: 16.0, right: 8.0); + + final List subscriptions = []; + DateTime last = DateTime.now(); + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + widget.videoController.player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + }); + }, + ), + ], + ); + + _timer = Timer( + controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + } + }, + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void onHover() { + setState(() { + mount = true; + visible = true; + }); + + _timer?.cancel(); + _timer = Timer(controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + } + }); + } + + void onEnter() { + setState(() { + mount = true; + visible = true; + }); + + _timer?.cancel(); + _timer = Timer(controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + } + }); + } + + void onExit() { + setState(() { + visible = false; + }); + + _timer?.cancel(); + } + + final bool modifyVolumeOnScroll = true; + final bool toggleFullscreenOnDoublePress = true; + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + // Default key-board shortcuts. + // https://support.google.com/youtube/answer/7631406 + const SingleActivator(LogicalKeyboardKey.mediaPlay): () => + widget.videoController.player.play(), + const SingleActivator(LogicalKeyboardKey.mediaPause): () => + widget.videoController.player.pause(), + const SingleActivator(LogicalKeyboardKey.mediaPlayPause): () => + widget.videoController.player.playOrPause(), + const SingleActivator(LogicalKeyboardKey.mediaTrackNext): () => + widget.videoController.player.next(), + const SingleActivator(LogicalKeyboardKey.mediaTrackPrevious): () => + widget.videoController.player.previous(), + const SingleActivator(LogicalKeyboardKey.space): () => + widget.videoController.player.playOrPause(), + const SingleActivator(LogicalKeyboardKey.keyJ): () { + final rate = widget.videoController.player.state.position - + const Duration(seconds: 10); + widget.videoController.player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.keyI): () { + final rate = widget.videoController.player.state.position + + const Duration(seconds: 10); + widget.videoController.player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.arrowLeft): () { + final rate = widget.videoController.player.state.position - + const Duration(seconds: 2); + widget.videoController.player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.arrowRight): () { + final rate = widget.videoController.player.state.position + + const Duration(seconds: 2); + widget.videoController.player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.arrowUp): () { + final volume = widget.videoController.player.state.volume + 5.0; + widget.videoController.player.setVolume(volume.clamp(0.0, 100.0)); + }, + const SingleActivator(LogicalKeyboardKey.arrowDown): () { + final volume = widget.videoController.player.state.volume - 5.0; + widget.videoController.player.setVolume(volume.clamp(0.0, 100.0)); + }, + const SingleActivator(LogicalKeyboardKey.keyF): () => setFullScreen(), + const SingleActivator(LogicalKeyboardKey.escape): () => + setFullScreen(value: false), + }, + child: Focus( + autofocus: true, + child: Listener( + onPointerSignal: modifyVolumeOnScroll + ? (e) { + if (e is PointerScrollEvent) { + if (e.delta.dy > 0) { + final volume = + widget.videoController.player.state.volume - 5.0; + widget.videoController.player + .setVolume(volume.clamp(0.0, 100.0)); + } + if (e.delta.dy < 0) { + final volume = + widget.videoController.player.state.volume + 5.0; + widget.videoController.player + .setVolume(volume.clamp(0.0, 100.0)); + } + } + } + : null, + child: GestureDetector( + onTapUp: !toggleFullscreenOnDoublePress + ? null + : (e) { + final now = DateTime.now(); + final difference = now.difference(last); + last = now; + if (difference < const Duration(milliseconds: 400)) { + setFullScreen(); + } + }, + onPanUpdate: modifyVolumeOnScroll + ? (e) { + if (e.delta.dy > 0) { + final volume = + widget.videoController.player.state.volume - 5.0; + widget.videoController.player + .setVolume(volume.clamp(0.0, 100.0)); + } + if (e.delta.dy < 0) { + final volume = + widget.videoController.player.state.volume + 5.0; + widget.videoController.player + .setVolume(volume.clamp(0.0, 100.0)); + } + } + : null, + child: MouseRegion( + onHover: (_) => onHover(), + onEnter: (_) => onEnter(), + onExit: (_) => onExit(), + child: Stack( + children: [ + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: controlsTransitionDuration, + onEnd: () { + if (!visible) { + setState(() { + mount = false; + }); + } + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + // Top gradient. + + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [ + 0.0, + 0.2, + ], + colors: [ + Color(0x61000000), + Color(0x00000000), + ], + ), + ), + ), + // Bottom gradient. + + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [ + 0.5, + 1.0, + ], + colors: [ + Color(0x00000000), + Color(0x61000000), + ], + ), + ), + ), + if (mount) + Padding( + padding: ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + widget.topButtonBarWidget, + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering + ? 0.0 + : !showSwipeDuration + ? 0.0 + : 1.0, + duration: controlsTransitionDuration, + child: Center( + child: seekIndicatorTextWidget( + Duration(seconds: swipeDuration), + widget.videoController.player + .state.position))), + ), + widget.seekToWidget, + Transform.translate( + offset: Offset.zero, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 5), + child: CustomSeekBar( + onSeekStart: (value) { + setState(() { + swipeDuration = value.inSeconds; + showSwipeDuration = true; + widget.tempDuration(widget + .videoController + .player + .state + .position + + value); + }); + _timer?.cancel(); + }, + onSeekEnd: (value) { + _timer = Timer( + controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + } + }, + ); + setState(() { + showSwipeDuration = false; + }); + widget.tempDuration(null); + }, + player: widget.videoController.player, + ), + ), + ), + widget.bottomButtonBarWidget + ], + ), + ), + ], + ), + ), + // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: buttonBarHeight, + margin: const EdgeInsets.all(0), + ), + Expanded( + child: Center( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + ), + Container( + height: buttonBarHeight, + margin: bottomButtonBarMargin, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class CustomeMaterialDesktopPlayOrPauseButton extends StatefulWidget { + final VideoController controller; + + const CustomeMaterialDesktopPlayOrPauseButton({ + super.key, + required this.controller, + }); + + @override + CustomeMaterialDesktopPlayOrPauseButtonState createState() => + CustomeMaterialDesktopPlayOrPauseButtonState(); +} + +class CustomeMaterialDesktopPlayOrPauseButtonState + extends State + with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: widget.controller.player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= widget.controller.player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: widget.controller.player.playOrPause, + iconSize: 25, + color: Colors.white, + icon: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: 25, + color: Colors.white, + ), + ); + } +} + +// BUTTON: VOLUME + +/// MaterialDesktop design volume button & slider. +class CustomMaterialDesktopVolumeButton extends StatefulWidget { + final VideoController controller; + + const CustomMaterialDesktopVolumeButton({ + super.key, + required this.controller, + }); + + @override + CustomMaterialDesktopVolumeButtonState createState() => + CustomMaterialDesktopVolumeButtonState(); +} + +class CustomMaterialDesktopVolumeButtonState + extends State + with SingleTickerProviderStateMixin { + late double volume = widget.controller.player.state.volume; + + StreamSubscription? subscription; + + bool hover = false; + + bool mute = false; + double _volume = 0.0; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= widget.controller.player.stream.volume.listen((event) { + setState(() { + volume = event; + }); + }); + } + + @override + void dispose() { + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (e) { + setState(() { + hover = true; + }); + }, + onExit: (e) { + setState(() { + hover = false; + }); + }, + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy < 0) { + widget.controller.player.setVolume( + (volume + 5.0).clamp(0.0, 100.0), + ); + } + if (event.scrollDelta.dy > 0) { + widget.controller.player.setVolume( + (volume - 5.0).clamp(0.0, 100.0), + ); + } + } + }, + child: Row( + children: [ + const SizedBox(width: 4.0), + IconButton( + onPressed: () async { + if (mute) { + await widget.controller.player.setVolume(_volume); + mute = !mute; + } + // https://github.com/media-kit/media-kit/pull/250#issuecomment-1605588306 + else if (volume == 0.0) { + _volume = 100.0; + await widget.controller.player.setVolume(100.0); + mute = false; + } else { + _volume = volume; + await widget.controller.player.setVolume(0.0); + mute = !mute; + } + + setState(() {}); + }, + iconSize: 25, + color: Colors.white, + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: volume == 0.0 + ? const Icon( + Icons.volume_off, + key: ValueKey(Icons.volume_off), + ) + : volume < 50.0 + ? const Icon( + Icons.volume_down, + key: ValueKey(Icons.volume_down), + ) + : const Icon( + Icons.volume_up, + key: ValueKey(Icons.volume_up), + ), + ), + ), + AnimatedOpacity( + opacity: hover ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: AnimatedContainer( + width: hover ? (12.0 + 52.0 + 18.0) : 12.0, + duration: const Duration(milliseconds: 150), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const SizedBox(width: 12.0), + SizedBox( + width: 52.0, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 1.2, + inactiveTrackColor: const Color(0x3DFFFFFF), + activeTrackColor: Colors.white, + thumbColor: Colors.white, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 12 / 2, + elevation: 0.0, + pressedElevation: 0.0, + ), + trackShape: _CustomTrackShape(), + overlayColor: const Color(0x00000000), + ), + child: Slider( + value: volume.clamp(0.0, 100.0), + min: 0.0, + max: 100.0, + onChanged: (value) async { + await widget.controller.player.setVolume(value); + mute = false; + setState(() {}); + }, + ), + ), + ), + const SizedBox(width: 18.0), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// POSITION INDICATOR + +/// MaterialDesktop design position indicator. +class CustomMaterialDesktopPositionIndicator extends StatefulWidget { + final VideoController controller; + final Duration? delta; + + const CustomMaterialDesktopPositionIndicator( + {super.key, required this.controller, this.delta}); + + @override + CustomMaterialDesktopPositionIndicatorState createState() => + CustomMaterialDesktopPositionIndicatorState(); +} + +class CustomMaterialDesktopPositionIndicatorState + extends State { + late Duration position = widget.controller.player.state.position; + late Duration duration = widget.controller.player.state.duration; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + widget.controller.player.stream.position.listen((event) { + setState(() { + position = event; + }); + }), + widget.controller.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 Text( + '${(widget.delta ?? position).label(reference: duration)} / ${duration.label(reference: duration)}', + style: const TextStyle( + height: 1.0, + fontSize: 12.0, + color: Colors.white, + ), + ); + } +} + +class _CustomTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final height = sliderTheme.trackHeight; + final left = offset.dx; + final top = offset.dy + (parentBox.size.height - height!) / 2; + final width = parentBox.size.width; + return Rect.fromLTWH( + left, + top, + width, + height, + ); + } +} + +class CustomMaterialDesktopFullscreenButton extends StatefulWidget { + final VideoController controller; + + const CustomMaterialDesktopFullscreenButton({ + super.key, + required this.controller, + }); + + @override + State createState() => + _CustomMaterialDesktopFullscreenButtonState(); +} + +class _CustomMaterialDesktopFullscreenButtonState + extends State { + bool _isFullscreen = false; + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () async { + final isFullScreen = await setFullScreen(); + setState(() { + _isFullscreen = isFullScreen; + }); + }, + icon: _isFullscreen + ? const Icon(Icons.fullscreen_exit) + : const Icon(Icons.fullscreen), + iconSize: 25, + color: Colors.white, + ); + } +} + +Future setFullScreen({bool? value}) async { + if (value != null) { + await windowManager.setTitleBarStyle( + value == false ? TitleBarStyle.normal : TitleBarStyle.hidden); + await windowManager.setFullScreen(value); + if (value == false) { + await windowManager.center(); + } + await windowManager.show(); + return value; + } + final isFullScreen = await windowManager.isFullScreen(); + if (!isFullScreen) { + await windowManager.setTitleBarStyle(TitleBarStyle.hidden); + await windowManager.setFullScreen(true); + await windowManager.show(); + } else { + await windowManager.setTitleBarStyle(TitleBarStyle.normal); + await windowManager.setFullScreen(false); + await windowManager.center(); + await windowManager.show(); + } + return isFullScreen; +} diff --git a/lib/modules/anime/widgets/indicator_builder.dart b/lib/modules/anime/widgets/indicator_builder.dart index e03cccf..70a446d 100644 --- a/lib/modules/anime/widgets/indicator_builder.dart +++ b/lib/modules/anime/widgets/indicator_builder.dart @@ -2,73 +2,76 @@ import 'package:flutter/material.dart'; class MediaIndicatorBuilder extends StatelessWidget { final bool isVolumeIndicator; - final double value; + final ValueNotifier value; const MediaIndicatorBuilder( {super.key, required this.value, required this.isVolumeIndicator}); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - 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(), - style: const TextStyle(color: Colors.white), + return ValueListenableBuilder( + valueListenable: value, + builder: (context, value, child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Row( + mainAxisAlignment: isVolumeIndicator + ? MainAxisAlignment.start + : MainAxisAlignment.end, + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(100), ), - Padding( - padding: const EdgeInsets.all(5), - child: RotatedBox( - quarterTurns: -1, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(100), - ), - child: SizedBox.fromSize( - size: const Size(130, 20), - child: LinearProgressIndicator( - value: value, - backgroundColor: Colors.transparent)), + width: 30, + child: UnconstrainedBox( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + children: [ + Text( + (value * 100).ceil().toString(), + style: const TextStyle(color: Colors.white), + ), + Padding( + padding: const EdgeInsets.all(5), + child: RotatedBox( + quarterTurns: -1, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(100), + ), + child: SizedBox.fromSize( + size: const Size(130, 20), + 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, + ) + ], ), ), ), - 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/anime/widgets/mobile.dart b/lib/modules/anime/widgets/mobile.dart new file mode 100644 index 0000000..a606357 --- /dev/null +++ b/lib/modules/anime/widgets/mobile.dart @@ -0,0 +1,933 @@ +// ignore_for_file: depend_on_referenced_packages +import 'dart:async'; +import 'package:mangayomi/modules/anime/anime_player_view.dart'; +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:volume_controller/volume_controller.dart'; +import 'package:screen_brightness/screen_brightness.dart'; +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; + +class MobileControllerWidget extends StatefulWidget { + final AnimeStreamController streamController; + final VideoController videoController; + final Widget topButtonBarWidget; + final GlobalKey videoStatekey; + final Widget bottomButtonBarWidget; + const MobileControllerWidget( + {super.key, + required this.videoController, + required this.topButtonBarWidget, + required this.bottomButtonBarWidget, + required this.streamController, + required this.videoStatekey}); + + @override + State createState() => _MobileControllerWidgetState(); +} + +class _MobileControllerWidgetState extends State { + bool mount = true; + bool visible = true; + Duration controlsTransitionDuration = const Duration(milliseconds: 300); + Color backdropColor = const Color(0x66000000); + Timer? _timer; + + final ValueNotifier _brightnessValue = ValueNotifier(0.0); + final ValueNotifier _brightnessIndicator = ValueNotifier(false); + Timer? _brightnessTimer; + + final ValueNotifier _volumeValue = ValueNotifier(0.0); + final ValueNotifier _volumeIndicator = ValueNotifier(false); + Timer? _volumeTimer; + // The default event stream in package:volume_controller is buggy. + bool _volumeInterceptEventStream = false; + + Offset _dragInitialDelta = + Offset.zero; // Initial position for horizontal drag + int swipeDuration = 0; // Duration to seek in video + bool showSwipeDuration = false; // Whether to show the seek duration overlay + + late bool buffering = widget.videoController.player.state.buffering; + final controlsHoverDuration = const Duration(seconds: 3); + bool _mountSeekBackwardButton = false; + bool _mountSeekForwardButton = false; + bool _hideSeekBackwardButton = false; + bool _hideSeekForwardButton = false; + double buttonBarHeight = 100; + final bottomButtonBarMargin = const EdgeInsets.only(left: 16.0, right: 8.0); + + Duration? _seekBarDeltaValueNotifier; + + final List subscriptions = []; + Offset? _tapPosition; + + void _handleTapDown(TapDownDetails details) { + setState(() { + _tapPosition = details.localPosition; + }); + } + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + final horizontalGestureSensitivity = 5000; + final verticalGestureSensitivity = 500; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + widget.videoController.player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + if (event) { + _mountSeekBackwardButton = false; + _mountSeekForwardButton = false; + _hideSeekBackwardButton = false; + _hideSeekForwardButton = false; + } + }); + }, + ), + ], + ); + + _timer = Timer( + controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + } + }, + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + // -------------------------------------------------- + // package:screen_brightness + Future.microtask(() async { + try { + await ScreenBrightness().resetScreenBrightness(); + } catch (_) {} + }); + // -------------------------------------------------- + super.dispose(); + } + + void onTap() { + if (!visible) { + setState(() { + mount = true; + visible = true; + }); + + _timer?.cancel(); + _timer = Timer(controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + } + }); + } else { + setState(() { + visible = false; + }); + + _timer?.cancel(); + } + } + + void onDoubleTapSeekBackward() { + setState(() { + _mountSeekBackwardButton = true; + }); + } + + void onDoubleTapSeekForward() { + setState(() { + _mountSeekForwardButton = true; + }); + } + + void onHorizontalDragUpdate(DragUpdateDetails details) { + if (_dragInitialDelta == Offset.zero) { + _dragInitialDelta = details.localPosition; + return; + } + + final diff = _dragInitialDelta.dx - details.localPosition.dx; + final duration = widget.videoController.player.state.duration.inSeconds; + final position = widget.videoController.player.state.position.inSeconds; + + final seconds = -(diff * duration / horizontalGestureSensitivity).round(); + final relativePosition = position + seconds; + + if (relativePosition <= duration && relativePosition >= 0) { + setState(() { + swipeDuration = seconds; + showSwipeDuration = true; + _seekBarDeltaValueNotifier = Duration( + seconds: widget.videoController.player.state.position.inSeconds + + seconds); + }); + } + } + + void onHorizontalDragEnd() { + if (swipeDuration != 0) { + Duration newPosition = widget.videoController.player.state.position + + Duration(seconds: swipeDuration); + newPosition = newPosition.clamp( + Duration.zero, + widget.videoController.player.state.duration, + ); + widget.videoController.player.seek(newPosition); + } + + setState(() { + _dragInitialDelta = Offset.zero; + showSwipeDuration = false; + _seekBarDeltaValueNotifier = null; + }); + } + + @override + void initState() { + super.initState(); + // -------------------------------------------------- + // package:volume_controller + Future.microtask(() async { + try { + VolumeController().showSystemUI = false; + _volumeValue.value = await VolumeController().getVolume(); + VolumeController().listener((value) { + if (mounted && !_volumeInterceptEventStream) { + _volumeValue.value = value; + } + }); + } catch (_) {} + }); + // -------------------------------------------------- + // -------------------------------------------------- + // package:screen_brightness + Future.microtask(() async { + try { + _brightnessValue.value = await ScreenBrightness().current; + ScreenBrightness().onCurrentBrightnessChanged.listen((value) { + if (mounted) { + _brightnessValue.value = value; + } + }); + } catch (_) {} + }); + // -------------------------------------------------- + } + + Future setVolume(double value) async { + // -------------------------------------------------- + // package:volume_controller + try { + VolumeController().setVolume(value); + } catch (_) {} + _volumeValue.value = value; + _volumeIndicator.value = true; + _volumeInterceptEventStream = true; + _volumeTimer?.cancel(); + _volumeTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + _volumeIndicator.value = false; + _volumeInterceptEventStream = false; + } + }); + // -------------------------------------------------- + } + + Future setBrightness(double value) async { + // -------------------------------------------------- + // package:screen_brightness + try { + await ScreenBrightness().setScreenBrightness(value); + } catch (_) {} + _brightnessIndicator.value = true; + _brightnessTimer?.cancel(); + _brightnessTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + _brightnessIndicator.value = false; + } + }); + // -------------------------------------------------- + } + + @override + Widget build(BuildContext context) { + return Focus( + autofocus: true, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // // Controls: + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: controlsTransitionDuration, + onEnd: () { + setState(() { + if (!visible) { + mount = false; + } + }); + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Container( + color: backdropColor, + ), + ), + // We are adding 16.0 boundary around the actual controls (which contain the vertical drag gesture detectors). + // This will make the hit-test on edges (e.g. swiping to: show status-bar, show navigation-bar, go back in navigation) not activate the swipe gesture annoyingly. + Positioned.fill( + left: 16.0, + top: 16.0, + right: 16.0, + bottom: 16.0, + child: GestureDetector( + onTap: onTap, + onDoubleTapDown: _handleTapDown, + onDoubleTap: () { + if (_tapPosition != null && + _tapPosition!.dx > + MediaQuery.of(context).size.width / 2) { + onDoubleTapSeekForward(); + } else { + onDoubleTapSeekBackward(); + } + }, + onHorizontalDragUpdate: (details) { + onHorizontalDragUpdate(details); + }, + onHorizontalDragEnd: (details) { + onHorizontalDragEnd(); + }, + onVerticalDragUpdate: (e) async { + final delta = e.delta.dy; + final Offset position = e.localPosition; + + if (position.dx <= + MediaQuery.of(context).size.width / 2) { + // Left side of screen swiped + + final brightness = _brightnessValue.value - + delta / verticalGestureSensitivity; + final result = brightness.clamp(0.0, 1.0); + setBrightness(result); + } else { + // Right side of screen swiped + + final volume = _volumeValue.value - + delta / verticalGestureSensitivity; + final result = volume.clamp(0.0, 1.0); + setVolume(result); + } + }, + child: Container( + color: const Color(0x00000000), + ), + ), + ), + if (mount) + Padding( + padding: ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + widget.topButtonBarWidget, + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering + ? 0.0 + : showSwipeDuration + ? 0.0 + : 1.0, + duration: controlsTransitionDuration, + child: Center( + child: Row( + children: mobilePrimaryButtonBar( + context, + widget.videoStatekey, + widget.streamController, + widget.videoController)), + ), + ), + ), + Stack( + alignment: Alignment.bottomCenter, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: CustomSeekBar( + onSeekStart: (value) { + setState(() { + swipeDuration = value.inSeconds; + showSwipeDuration = true; + }); + _timer?.cancel(); + }, + onSeekEnd: (value) { + _timer = Timer( + controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + } + }, + ); + setState(() { + showSwipeDuration = false; + }); + }, + player: widget.videoController.player, + ), + ), + widget.bottomButtonBarWidget + ], + ), + ], + ), + ), + ], + ), + ), + // // Double-Tap Seek Seek-Bar: + if (!mount) + if (_mountSeekBackwardButton || + _mountSeekForwardButton || + showSwipeDuration) + Column( + children: [ + const Spacer(), + Stack( + alignment: Alignment.bottomCenter, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: CustomSeekBar( + delta: _seekBarDeltaValueNotifier, + player: widget.videoController.player), + ), + ], + ), + ], + ), + // // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: buttonBarHeight, + margin: const EdgeInsets.all(0), + ), + Expanded( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + Container( + height: buttonBarHeight, + margin: bottomButtonBarMargin, + ), + ], + ), + ), + ), + // // Volume Indicator. + IgnorePointer( + child: ValueListenableBuilder( + valueListenable: _volumeIndicator, + builder: (context, value, child) => AnimatedOpacity( + curve: Curves.easeInOut, + opacity: value ? 1.0 : 0.0, + duration: controlsTransitionDuration, + child: MediaIndicatorBuilder( + value: _volumeValue, isVolumeIndicator: true)), + ), + ), + // // Brightness Indicator. + IgnorePointer( + child: ValueListenableBuilder( + valueListenable: _brightnessIndicator, + builder: (context, value, child) => AnimatedOpacity( + curve: Curves.easeInOut, + opacity: value ? 1.0 : 0.0, + duration: controlsTransitionDuration, + child: MediaIndicatorBuilder( + value: _brightnessValue, isVolumeIndicator: false)), + ), + ), + // Seek Indicator. + IgnorePointer( + child: AnimatedOpacity( + duration: controlsTransitionDuration, + opacity: showSwipeDuration ? 1 : 0, + child: seekIndicatorTextWidget(Duration(seconds: swipeDuration), + widget.videoController.player.state.position)), + ), + + // Double-Tap Seek Button(s): + if (_mountSeekBackwardButton || _mountSeekForwardButton) + Positioned.fill( + child: Row( + children: [ + Expanded( + child: _mountSeekBackwardButton + ? TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: _hideSeekBackwardButton ? 0.0 : 1.0, + ), + duration: const Duration(milliseconds: 200), + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + onEnd: () { + if (_hideSeekBackwardButton) { + setState(() { + _hideSeekBackwardButton = false; + _mountSeekBackwardButton = false; + }); + } + }, + child: _BackwardSeekIndicator( + onChanged: (value) { + setState(() { + _seekBarDeltaValueNotifier = widget + .videoController + .player + .state + .position - + value; + }); + }, + onSubmitted: (value) { + setState(() { + _hideSeekBackwardButton = true; + }); + var result = widget + .videoController.player.state.position - + value; + result = result.clamp( + Duration.zero, + widget.videoController.player.state.duration, + ); + widget.videoController.player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + Expanded( + child: _mountSeekForwardButton + ? TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: _hideSeekForwardButton ? 0.0 : 1.0, + ), + duration: const Duration(milliseconds: 200), + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + onEnd: () { + if (_hideSeekForwardButton) { + setState(() { + _hideSeekForwardButton = false; + _mountSeekForwardButton = false; + }); + } + }, + child: _ForwardSeekIndicator( + onChanged: (value) { + setState(() { + _seekBarDeltaValueNotifier = widget + .videoController + .player + .state + .position + + value; + }); + }, + onSubmitted: (value) { + setState(() { + _hideSeekForwardButton = true; + }); + var result = widget + .videoController.player.state.position + + value; + result = result.clamp( + Duration.zero, + widget.videoController.player.state.duration, + ); + widget.videoController.player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _BackwardSeekIndicator extends StatefulWidget { + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _BackwardSeekIndicator({ + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_BackwardSeekIndicator> createState() => _BackwardSeekIndicatorState(); +} + +class _BackwardSeekIndicatorState extends State<_BackwardSeekIndicator> { + Duration value = const Duration(seconds: 10); + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x88767676), + Color(0x00767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_rewind, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ForwardSeekIndicator extends StatefulWidget { + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _ForwardSeekIndicator({ + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_ForwardSeekIndicator> createState() => _ForwardSeekIndicatorState(); +} + +class _ForwardSeekIndicatorState extends State<_ForwardSeekIndicator> { + Duration value = const Duration(seconds: 10); + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00767676), + Color(0x88767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_forward, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class CustomMaterialPlayOrPauseButton extends StatefulWidget { + final VideoController controller; + + const CustomMaterialPlayOrPauseButton({ + super.key, + required this.controller, + }); + + @override + CustomMaterialPlayOrPauseButtonState createState() => + CustomMaterialPlayOrPauseButtonState(); +} + +class CustomMaterialPlayOrPauseButtonState + extends State + with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: widget.controller.player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= widget.controller.player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: widget.controller.player.playOrPause, + iconSize: 65, + color: Colors.white, + icon: IgnorePointer( + child: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: 65, + color: Colors.white, + ), + ), + ); + } +} + +List mobilePrimaryButtonBar( + BuildContext context, + GlobalKey key, + AnimeStreamController streamController, + VideoController controller) { + bool hasPrevEpisode = streamController.getEpisodeIndex().$1 + 1 != + streamController.getEpisodesLength(streamController.getEpisodeIndex().$2); + bool hasNextEpisode = streamController.getEpisodeIndex().$1 != 0; + final isFullScreen = isFullscreen(context); + return [ + const Spacer(flex: 3), + IconButton( + onPressed: hasPrevEpisode + ? () { + if (isFullScreen) { + key.currentState?.exitFullscreen(); + } + pushReplacementMangaReaderView( + context: context, chapter: streamController.getPrevEpisode()); + } + : null, + icon: Icon( + Icons.skip_previous, + size: 35, + color: hasPrevEpisode ? Colors.white : Colors.grey, + ), + ), + const Spacer(), + CustomMaterialPlayOrPauseButton(controller: controller), + const Spacer(), + IconButton( + onPressed: hasNextEpisode + ? () { + if (isFullScreen) { + key.currentState?.exitFullscreen(); + } + pushReplacementMangaReaderView( + context: context, + chapter: streamController.getNextEpisode(), + ); + } + : null, + icon: Icon(Icons.skip_next, + size: 35, color: hasPrevEpisode ? Colors.white : Colors.grey), + ), + const Spacer(flex: 3) + ]; +} diff --git a/lib/services/anime_extractors/sendvid_extractor.dart b/lib/services/anime_extractors/sendvid_extractor.dart index 5509217..27f1bb3 100644 --- a/lib/services/anime_extractors/sendvid_extractor.dart +++ b/lib/services/anime_extractors/sendvid_extractor.dart @@ -17,9 +17,7 @@ class SendvidExtractor { final masterUrl = document.querySelector("source#video_source")?.attributes["src"]; - if (masterUrl == null) { - return videoList; - } + if (masterUrl == null) return videoList; final masterHeaders = Map.from(headers) ..addAll({ @@ -34,7 +32,7 @@ class SendvidExtractor { final masterPlaylist = masterPlaylistResponse.body; final masterBase = - "https://${Uri.parse(masterUrl).host}${Uri.parse(masterUrl).pathSegments.join("/")}/"; + "${"https://${Uri.parse(masterUrl).host}${Uri.parse(masterUrl).path}".substringBeforeLast("/")}/"; masterPlaylist .substringAfter("#EXT-X-STREAM-INF:") @@ -52,9 +50,8 @@ class SendvidExtractor { "Origin": "https://${Uri.parse(url).host}", "Referer": "https://${Uri.parse(url).host}/", }); - - videoList.add( - Video(videoUrl, "$prefix - $quality", videoUrl, headers: videoHeaders)); + videoList.add(Video(videoUrl, "$prefix - $quality", videoUrl, + headers: videoHeaders)); }); return videoList; diff --git a/lib/services/anime_extractors/sibnet_extractor.dart b/lib/services/anime_extractors/sibnet_extractor.dart index b8a16a5..3a51156 100644 --- a/lib/services/anime_extractors/sibnet_extractor.dart +++ b/lib/services/anime_extractors/sibnet_extractor.dart @@ -5,7 +5,7 @@ import 'package:mangayomi/utils/extensions.dart'; class SibnetExtractor { final http.Client client = http.Client(); - Future> videosFromUrl(String url) async { + Future> videosFromUrl(String url, {String prefix = ""}) async { List