import 'dart:async'; import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/manga/reader/providers/push_router.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/get_anime_servers.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:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; class AnimePlayerView extends riv.ConsumerStatefulWidget { final Chapter episode; const AnimePlayerView({ super.key, required this.episode, }); @override riv.ConsumerState createState() => _AnimePlayerViewState(); } class _AnimePlayerViewState extends riv.ConsumerState { @override void dispose() { SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight ]); super.dispose(); } @override Widget build(BuildContext context) { SystemChrome.setPreferredOrientations( [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []); final serversData = ref.watch(getAnimeServersProvider( episode: widget.episode, )); return serversData.when( data: (data) { if (data.$1.isEmpty && (widget.episode.manga.value!.isLocalArchive ?? false) == false) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( title: const Text(''), leading: BackButton( onPressed: () { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); }, ), ), body: WillPopScope( onWillPop: () async { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); return false; }, child: const Center( child: Text("Error"), ), ), ); } data.$1.sort( (a, b) => a.quality.compareTo(b.quality), ); return AnimeStreamPage( episode: widget.episode, videos: data.$1, isLocal: data.$2, ); }, error: (error, stackTrace) => Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( title: const Text(''), leading: BackButton( onPressed: () { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); }, ), ), body: WillPopScope( onWillPop: () async { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); return false; }, child: Center( child: Text(error.toString()), ), ), ), loading: () { return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: AppBar( title: const Text(''), leading: BackButton( color: Colors.white, onPressed: () { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); }, ), ), body: WillPopScope( onWillPop: () async { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); return false; }, child: const ProgressCenter(), ), ); }, ); } } class AnimeStreamPage extends riv.ConsumerStatefulWidget { final List videos; final Chapter episode; final bool isLocal; const AnimeStreamPage( {Key? key, required this.isLocal, required this.videos, required this.episode}) : super(key: key); @override riv.ConsumerState createState() => _AnimeStreamPageState(); } class _AnimeStreamPageState extends riv.ConsumerState { late final Player _player = Player(); late final VideoController _controller = VideoController(_player); late final _streamController = AnimeStreamController(episode: widget.episode); final ValueNotifier _video = ValueNotifier(null); final ValueNotifier _subtitle = ValueNotifier(null); final ValueNotifier _playbackSpeed = ValueNotifier(1.0); bool _seekToCurrentPosition = true; late Duration _currentPosition = _streamController.geTCurrentPosition(); final _showFitLabel = StateProvider((ref) => false); final ValueNotifier _isCompleted = ValueNotifier(false); final _fit = StateProvider((ref) => BoxFit.contain); final bool _isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; late StreamSubscription _currentPositionSub = _player.stream.position.listen( (position) { if (_seekToCurrentPosition && _currentPosition != Duration.zero) { _player.seek(_currentPosition); _seekToCurrentPosition = false; _isCompleted.value = _player.state.duration.inSeconds - _currentPosition.inSeconds <= 10; } else { _currentPosition = position; _streamController.setCurrentPosition(position.inMilliseconds); _streamController.setAnimeHistoryUpdate(); } }, ); late final _firstVid = widget.videos.first; @override void initState() { super.initState(); _currentPositionSub; _video.value = VideoPrefs( videoTrack: VideoTrack( _firstVid.originalUrl, _firstVid.quality, _firstVid.quality), headers: _firstVid.headers); _player.open(Media(_video.value!.videoTrack!.id, httpHeaders: _video.value!.headers)); if (_firstVid.subtitles!.isNotEmpty) { _player.setSubtitleTrack(SubtitleTrack.uri( _firstVid.subtitles!.first.file!, title: _firstVid.subtitles!.first.label, language: _firstVid.subtitles!.first.label)); } } @override void dispose() { _player.dispose(); _currentPositionSub.cancel(); super.dispose(); } void _onChangeVideoQuality() { List videoQuality = []; List videoTracks = _player.state.tracks.video .where((element) => element.w != null && element.h != null) .toList(); for (var track in videoTracks) { videoQuality.add(VideoPrefs(videoTrack: track, isLocal: true)); } if (widget.videos.isNotEmpty && !widget.isLocal) { for (var video in widget.videos) { videoQuality.add(VideoPrefs( videoTrack: VideoTrack( video.url, video.quality, video.quality, ), headers: video.headers, isLocal: false)); } } final l10n = l10nLocalizations(context)!; showCupertinoModalPopup( context: context, builder: (_) => CupertinoActionSheet( title: Text(l10n.change_video_quality, style: TextStyle( fontSize: 20, color: Theme.of(context) .textTheme .bodyLarge! .color! .withOpacity(0.8)), textAlign: TextAlign.center), actions: videoQuality .map((quality) => CupertinoActionSheetAction( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Text( quality.isLocal && !widget.isLocal ? "${_firstVid.quality} ${quality.videoTrack!.h}p" : widget.isLocal ? _firstVid.quality : quality.videoTrack!.title!, style: TextStyle( fontSize: 16, color: Theme.of(context) .textTheme .bodyLarge! .color), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox( width: 7, ), Icon( Icons.check, color: quality.isLocal && !widget.isLocal && "${quality.videoTrack!.title}${quality.videoTrack!.h}p" == "${_video.value!.videoTrack!.title}${_video.value!.videoTrack!.h}p" || "${_video.value!.videoTrack!.id}${_video.value!.videoTrack!.title}" == "${quality.videoTrack!.id}${quality.videoTrack!.title}" ? Theme.of(context).iconTheme.color : Colors.transparent, ), ], ), onPressed: () { _video.value = quality; // change the video quality if (quality.isLocal) { if (widget.isLocal) { _player.setVideoTrack(quality.videoTrack!); } else { _player.open(Media(quality.videoTrack!.id, httpHeaders: quality.headers)); } } else { _player.open(Media(quality.videoTrack!.id, httpHeaders: quality.headers)); } _seekToCurrentPosition = true; _currentPositionSub = _player.stream.position.listen( (position) { if (_seekToCurrentPosition && _currentPosition != Duration.zero) { _player.seek(_currentPosition); _seekToCurrentPosition = false; } else { _currentPosition = position; _streamController.setCurrentPosition( position.inMilliseconds); _streamController.setAnimeHistoryUpdate(); } }, ); Navigator.maybePop(_); }, )) .toList(), cancelButton: CupertinoActionSheetAction( onPressed: () => Navigator.maybePop(_), isDestructiveAction: true, child: Text(l10n.cancel), ), )); } void _onChangeVideoSubtitle() { List videoSubtitle = []; List videoSubs = _player.state.tracks.subtitle .where((element) => element.title != null && element.language != null) .toList(); for (var sub in videoSubs) { videoSubtitle.add(VideoPrefs(isLocal: true, subtitle: sub)); } List subs = []; if (widget.videos.isNotEmpty && !widget.isLocal) { for (var video in widget.videos) { for (var sub in video.subtitles!) { if (!subs.contains(sub.file)) { videoSubtitle.add(VideoPrefs( isLocal: false, subtitle: SubtitleTrack.uri(sub.file!, title: sub.label, language: sub.label))); subs.add(sub.file!); } } } } _subtitle.value ??= videoSubtitle.first; final l10n = l10nLocalizations(context)!; showCupertinoModalPopup( context: context, builder: (_) => CupertinoActionSheet( title: Text(l10n.change_video_subtitle, style: TextStyle( fontSize: 20, color: Theme.of(context) .textTheme .bodyLarge! .color! .withOpacity(0.8)), textAlign: TextAlign.center), actions: videoSubtitle .toSet() .toList() .map((subtitle) => CupertinoActionSheetAction( onPressed: () { Navigator.maybePop(_); _subtitle.value = subtitle; _player.setSubtitleTrack(subtitle.subtitle!); }, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( subtitle.subtitle!.title ?? subtitle.subtitle!.language ?? subtitle.subtitle!.channels ?? "N/A", style: TextStyle( fontSize: 16, color: Theme.of(context).textTheme.bodyLarge!.color), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox( width: 7, ), Icon( Icons.check, color: _subtitle.value != null && "${_subtitle.value!.subtitle!.id}${_subtitle.value!.subtitle!.title}${_subtitle.value!.subtitle!.language}" == "${subtitle.subtitle!.id}${subtitle.subtitle!.title}${subtitle.subtitle!.language}" ? Theme.of(context).iconTheme.color : Colors.transparent, ), ], ), )) .toList(), cancelButton: CupertinoActionSheetAction( onPressed: () => Navigator.maybePop(_), isDestructiveAction: true, child: Text(l10n.cancel), ), ), ); } Future _setPlaybackSpeed(double speed) async { await _player.setRate(speed); _playbackSpeed.value = speed; } Future _togglePlaybackSpeed() async { List allowedSpeeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.50, 1.75, 2.0]; if (allowedSpeeds.indexOf(_playbackSpeed.value) < allowedSpeeds.length - 1) { _setPlaybackSpeed( allowedSpeeds[allowedSpeeds.indexOf(_playbackSpeed.value) + 1]); } else { _setPlaybackSpeed(allowedSpeeds[0]); } } List _bottomButtonBar(BuildContext context, bool isFullScreen) { bool hasPrevEpisode = _streamController.getEpisodeIndex() + 1 != _streamController.getEpisodesLength(); bool hasNextEpisode = _streamController.getEpisodeIndex() != 0; return [ Flexible( child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ const MaterialPositionIndicator(), const Spacer(), Stack( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ if (!isFullScreen) Padding( padding: const EdgeInsets.all(8.0), child: CupertinoButton( padding: const EdgeInsets.all(5), onPressed: _onChangeVideoQuality, child: const Icon( Icons.video_settings_outlined, size: 30, color: Colors.white, ), )), if (!isFullScreen) Padding( padding: const EdgeInsets.all(8.0), child: CupertinoButton( padding: const EdgeInsets.all(5), onPressed: _onChangeVideoSubtitle, child: const Icon( Icons.subtitles, size: 30, color: Colors.white, ), )), if (_isDesktop) const MaterialDesktopVolumeButton( iconSize: 38, ), ], ), Row( children: [ MaterialButton( child: ValueListenableBuilder( valueListenable: _playbackSpeed, builder: (context, value, child) { return Padding( padding: const EdgeInsets.all(8.0), child: Text( "${value}x", style: const TextStyle( color: Colors.white, ), ), ); }, ), onPressed: () { _togglePlaybackSpeed(); }), MaterialButton( child: const Icon(Icons.fit_screen, size: 30, color: Colors.white), onPressed: () async { ref.read(_showFitLabel.notifier).state = true; final fit = switch (ref.watch(_fit)) { BoxFit.contain => BoxFit.cover, BoxFit.cover => BoxFit.fill, BoxFit.fill => BoxFit.fitHeight, BoxFit.fitHeight => BoxFit.fitWidth, BoxFit.fitWidth => BoxFit.none, BoxFit.none => BoxFit.scaleDown, _ => BoxFit.contain, }; ref.read(_fit.notifier).state = fit; await Future.delayed(const Duration(seconds: 1)); ref.read(_showFitLabel.notifier).state = false; }, ), if (_isDesktop) const MaterialDesktopFullscreenButton() ], ), ], ), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ MaterialButton( onPressed: hasPrevEpisode ? () { pushReplacementMangaReaderView( context: context, chapter: _streamController.getPrevEpisode()); } : null, child: Icon( Icons.skip_previous_outlined, size: 30, color: hasPrevEpisode ? Colors.white : Colors.white.withOpacity(0.4), ), ), const SizedBox( width: 10, ), Stack( children: [ Positioned( left: 0, right: 0, bottom: 0, top: 0, child: UnconstrainedBox( child: Container( width: 47, height: 47, decoration: BoxDecoration( borderRadius: BorderRadius.circular(30), border: Border.all( color: Colors.white, width: 3)), ), ), ), _isDesktop ? const MaterialDesktopPlayOrPauseButton( iconSize: 36, ) : const MaterialPlayOrPauseButton( iconSize: 36, ), ], ), const SizedBox( width: 10, ), MaterialButton( onPressed: hasNextEpisode ? () { pushReplacementMangaReaderView( context: context, chapter: _streamController.getNextEpisode(), ); } : null, child: Icon( Icons.skip_next_outlined, size: 30, color: hasNextEpisode ? Colors.white : Colors.white.withOpacity(0.4), ), ), ], ), ], ), ], ), ) ]; } List _topButtonBar(BuildContext context) { return [ Flexible( child: Row( children: [ 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, ), ), 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, ), ), ), ), ], ), ) ]; } Widget _videoPlayer(BuildContext context) { final fit = ref.watch(_fit); return Stack( children: [ Video( // subtitleViewConfiguration: const SubtitleViewConfiguration( // style: TextStyle( // height: 1.4, // fontSize: 40, // fontWeight: FontWeight.normal, // backgroundColor: Colors.transparent), // ), fit: fit, controller: _controller, width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, resumeUponEnteringForegroundMode: true, ), if (ref.watch(_showFitLabel)) Positioned( top: 0, bottom: 0, left: 0, right: 0, child: Center( child: Text( fit.name.toUpperCase(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20), ))) ], ); } Widget _mobilePlayer() { return MaterialVideoControlsTheme( normal: MaterialVideoControlsThemeData( visibleOnMount: true, buttonBarHeight: 83, seekOnDoubleTap: true, controlsHoverDuration: const Duration(seconds: 5), volumeGesture: true, brightnessGesture: true, seekBarPositionColor: primaryColor(context), seekBarThumbColor: primaryColor(context), primaryButtonBar: [], seekBarMargin: const EdgeInsets.only(bottom: 60, left: 8, right: 8), topButtonBarMargin: const EdgeInsets.all(0), topButtonBar: _topButtonBar(context), bottomButtonBarMargin: const EdgeInsets.only(left: 8, right: 8), bottomButtonBar: _bottomButtonBar(context, false)), fullscreen: MaterialVideoControlsThemeData( buttonBarHeight: 83, seekOnDoubleTap: true, controlsHoverDuration: const Duration(seconds: 5), volumeGesture: true, brightnessGesture: true, seekBarPositionColor: primaryColor(context), seekBarThumbColor: primaryColor(context), primaryButtonBar: [], seekBarMargin: const EdgeInsets.only(bottom: 60, left: 8, right: 8), topButtonBarMargin: const EdgeInsets.all(0), topButtonBar: _topButtonBar(context), bottomButtonBarMargin: const EdgeInsets.only(left: 8, right: 8), bottomButtonBar: _bottomButtonBar(context, true)), child: _videoPlayer(context)); } Widget _desktopPlayer() { return MaterialDesktopVideoControlsTheme( normal: MaterialDesktopVideoControlsThemeData( visibleOnMount: true, buttonBarHeight: 83, seekBarContainerHeight: 4, controlsHoverDuration: const Duration(seconds: 5), seekBarPositionColor: primaryColor(context), seekBarThumbColor: primaryColor(context), primaryButtonBar: [], seekBarMargin: const EdgeInsets.only(left: 8, right: 8), topButtonBarMargin: const EdgeInsets.all(0), topButtonBar: _topButtonBar(context), bottomButtonBarMargin: const EdgeInsets.only(left: 8, right: 8), bottomButtonBar: _bottomButtonBar(context, false)), fullscreen: MaterialDesktopVideoControlsThemeData( buttonBarHeight: 83, seekBarContainerHeight: 4, controlsHoverDuration: const Duration(seconds: 5), seekBarPositionColor: primaryColor(context), seekBarThumbColor: primaryColor(context), primaryButtonBar: [], seekBarMargin: const EdgeInsets.only(left: 8, right: 8), topButtonBarMargin: const EdgeInsets.all(0), topButtonBar: _topButtonBar(context), bottomButtonBarMargin: const EdgeInsets.only(left: 8, right: 8), bottomButtonBar: _bottomButtonBar(context, true)), child: _videoPlayer(context)); } @override Widget build(BuildContext context) { return Scaffold( body: WillPopScope( onWillPop: () async { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); Navigator.pop(context); return false; }, child: _isDesktop ? _desktopPlayer() : _mobilePlayer(), ), ); } } class MaterialPositionIndicator extends StatefulWidget { /// Overriden [TextStyle] for the [MaterialPositionIndicator]. final TextStyle? style; const MaterialPositionIndicator({super.key, this.style}); @override MaterialPositionIndicatorState createState() => MaterialPositionIndicatorState(); } class MaterialPositionIndicatorState 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 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ 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, ), ), ], ); } } class VideoPrefs { VideoTrack? videoTrack; SubtitleTrack? subtitle; bool isLocal; final Map? headers; VideoPrefs( {this.videoTrack, this.isLocal = true, this.headers, this.subtitle}); }