mangayomi-mirror/lib/modules/anime/widgets/mobile.dart
2024-10-07 16:33:35 +01:00

995 lines
35 KiB
Dart

// ignore_for_file: depend_on_referenced_packages
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:mangayomi/modules/anime/widgets/indicator_builder.dart';
import 'package:mangayomi/modules/anime/widgets/subtitle_view.dart';
import 'package:mangayomi/modules/anime/widgets/video_preview.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: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 ConsumerStatefulWidget {
final AnimeStreamController streamController;
final VideoController videoController;
final Widget topButtonBarWidget;
final GlobalKey<VideoState> videoStatekey;
final Widget bottomButtonBarWidget;
final VideoPrefs? video;
const MobileControllerWidget(
{super.key,
required this.videoController,
required this.topButtonBarWidget,
required this.bottomButtonBarWidget,
required this.streamController,
required this.videoStatekey,
required this.video});
@override
ConsumerState<MobileControllerWidget> createState() =>
_MobileControllerWidgetState();
}
class _MobileControllerWidgetState
extends ConsumerState<MobileControllerWidget> {
bool mount = true;
bool visible = true;
Duration controlsTransitionDuration = const Duration(milliseconds: 300);
Color backdropColor = const Color(0x66000000);
Timer? _timer;
late final skipDuration =
ref.watch(defaultDoubleTapToSkipLengthStateProvider);
final ValueNotifier<double> _brightnessValue = ValueNotifier(0.0);
final ValueNotifier<bool> _brightnessIndicator = ValueNotifier(false);
Timer? _brightnessTimer;
final ValueNotifier<double> _volumeValue = ValueNotifier(0.0);
final ValueNotifier<bool> _volumeIndicator = ValueNotifier(false);
final ValueNotifier<bool> _isDragging = ValueNotifier(false);
final ValueNotifier<double?> _dragPosition = ValueNotifier(null);
final ValueNotifier<Duration?> _onDragDuration = ValueNotifier(null);
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<StreamSubscription> subscriptions = [];
Offset? _tapPosition;
void _handleTapDown(TapDownDetails details) {
setState(() {
_tapPosition = details.localPosition;
});
}
@override
void setState(VoidCallback fn) {
if (mounted) {
super.setState(fn);
}
}
final horizontalGestureSensitivity = 7500;
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;
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
});
} else {
setState(() {
visible = false;
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
_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<void> 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<void> 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 Stack(
children: [
Consumer(
builder: (context, ref, _) => ref.read(useLibassStateProvider)
? const SizedBox.shrink()
: Positioned(
child: CustomSubtitleView(
controller: widget.videoController,
configuration:
SubtitleViewConfiguration(style: subtileTextStyle(ref)),
)),
),
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,
isDragging: (value) {
setState(() {
_isDragging.value = value;
});
},
dragPosition: (value) {
setState(() {
_dragPosition.value = value;
});
},
onDragDuration: (value) {
setState(() {
_onDragDuration.value = value;
});
},
),
),
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,
isDragging: (value) {},
dragPosition: (value) {},
onDragDuration: (value) {},
),
),
],
),
],
),
// // 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<double>(
tween: Tween<double>(
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<double>(
tween: Tween<double>(
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);
},
skipDuration: skipDuration),
)
: const SizedBox(),
),
Expanded(
child: _mountSeekForwardButton
? TweenAnimationBuilder<double>(
tween: Tween<double>(
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);
},
skipDuration: skipDuration),
)
: const SizedBox(),
),
],
),
),
],
),
),
VideoPreview(
isDragging: _isDragging,
dragPosition: _dragPosition,
onDragDuration: _onDragDuration,
video: widget.video!),
],
);
}
}
class _BackwardSeekIndicator extends StatefulWidget {
final void Function(Duration) onChanged;
final void Function(Duration) onSubmitted;
final int skipDuration;
const _BackwardSeekIndicator({
required this.onChanged,
required this.onSubmitted,
required this.skipDuration,
});
@override
State<_BackwardSeekIndicator> createState() => _BackwardSeekIndicatorState();
}
class _BackwardSeekIndicatorState extends State<_BackwardSeekIndicator> {
late Duration value = Duration(seconds: widget.skipDuration);
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 += Duration(seconds: widget.skipDuration);
});
}
@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;
final int skipDuration;
const _ForwardSeekIndicator({
required this.onChanged,
required this.onSubmitted,
required this.skipDuration,
});
@override
State<_ForwardSeekIndicator> createState() => _ForwardSeekIndicatorState();
}
class _ForwardSeekIndicatorState extends State<_ForwardSeekIndicator> {
late Duration value = Duration(seconds: widget.skipDuration);
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 += Duration(seconds: widget.skipDuration);
});
}
@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<CustomMaterialPlayOrPauseButton>
with SingleTickerProviderStateMixin {
late final animation = AnimationController(
vsync: this,
value: widget.controller.player.state.playing ? 1 : 0,
duration: const Duration(milliseconds: 200),
);
StreamSubscription<bool>? 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<Widget> mobilePrimaryButtonBar(
BuildContext context,
GlobalKey<VideoState> 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)
];
}