mangayomi-mirror/lib/modules/anime/anime_player_view.dart

853 lines
31 KiB
Dart

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<AnimePlayerView> createState() => _AnimePlayerViewState();
}
class _AnimePlayerViewState extends riv.ConsumerState<AnimePlayerView> {
@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<vid.Video> 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<AnimeStreamPage> createState() => _AnimeStreamPageState();
}
class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
late final Player _player = Player();
late final VideoController _controller = VideoController(_player);
late final _streamController = AnimeStreamController(episode: widget.episode);
final ValueNotifier<VideoPrefs?> _video = ValueNotifier(null);
final ValueNotifier<VideoPrefs?> _subtitle = ValueNotifier(null);
final ValueNotifier<double> _playbackSpeed = ValueNotifier(1.0);
bool _seekToCurrentPosition = true;
late Duration _currentPosition = _streamController.geTCurrentPosition();
final _showFitLabel = StateProvider((ref) => false);
final ValueNotifier<bool> _isCompleted = ValueNotifier(false);
final _fit = StateProvider((ref) => BoxFit.contain);
final bool _isDesktop =
Platform.isWindows || Platform.isMacOS || Platform.isLinux;
late StreamSubscription<Duration> _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<VideoPrefs> videoQuality = [];
List<VideoTrack> 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<VideoPrefs> videoSubtitle = [];
List<SubtitleTrack> 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<String> 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<void> _setPlaybackSpeed(double speed) async {
await _player.setRate(speed);
_playbackSpeed.value = speed;
}
Future<void> _togglePlaybackSpeed() async {
List<double> 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<Widget> _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<double>(
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<Widget> _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<MaterialPositionIndicator> {
late Duration position = controller(context).player.state.position;
late Duration duration = controller(context).player.state.duration;
final List<StreamSubscription> 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<String, String>? headers;
VideoPrefs(
{this.videoTrack, this.isLocal = true, this.headers, this.subtitle});
}