flutter/lib/components/shark_video.dart

2063 lines
75 KiB
Dart
Raw Permalink Normal View History

2025-08-21 10:50:38 +08:00
/// This file is a part of media_kit (https://github.com/media-kit/media-kit).
///
/// Copyright © 2021 & onwards, Hitesh Kumar Saini <saini123hitesh@gmail.com>.
/// All rights reserved.
/// Use of this source code is governed by MIT license that can be found in the LICENSE file.
library;
// ignore_for_file: non_constant_identifier_names
import 'dart:async';
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';
import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart';
import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart';
import 'package:screen_brightness_platform_interface/screen_brightness_platform_interface.dart';
import 'package:volume_controller/volume_controller.dart';
/// {@template material_video_controls}
///
/// [Video] controls which use Material design.
///
/// {@endtemplate}
Widget MyMaterialVideoControls(VideoState state) {
return const VideoControlsThemeDataInjector(
child: _MaterialVideoControls(),
);
}
/// [MaterialVideoControlsThemeData] available in this [context].
MaterialVideoControlsThemeData _theme(BuildContext context) => FullscreenInheritedWidget.maybeOf(context) == null
? MaterialVideoControlsTheme.maybeOf(context)?.normal ?? kDefaultMaterialVideoControlsThemeData
: MaterialVideoControlsTheme.maybeOf(context)?.fullscreen ?? kDefaultMaterialVideoControlsThemeDataFullscreen;
/// Default [MaterialVideoControlsThemeData].
const kDefaultMaterialVideoControlsThemeData = MaterialVideoControlsThemeData();
/// Default [MaterialVideoControlsThemeData] for fullscreen.
const kDefaultMaterialVideoControlsThemeDataFullscreen = MaterialVideoControlsThemeData(
displaySeekBar: true,
automaticallyImplySkipNextButton: true,
automaticallyImplySkipPreviousButton: true,
volumeGesture: true,
brightnessGesture: true,
seekGesture: true,
gesturesEnabledWhileControlsVisible: true,
seekOnDoubleTap: true,
seekOnDoubleTapEnabledWhileControlsVisible: true,
visibleOnMount: false,
speedUpOnLongPress: false,
speedUpFactor: 2.0,
verticalGestureSensitivity: 100,
horizontalGestureSensitivity: 1000,
backdropColor: Color(0x66000000),
padding: null,
controlsHoverDuration: Duration(seconds: 3),
controlsTransitionDuration: Duration(milliseconds: 300),
bufferingIndicatorBuilder: null,
volumeIndicatorBuilder: null,
brightnessIndicatorBuilder: null,
seekIndicatorBuilder: null,
speedUpIndicatorBuilder: null,
primaryButtonBar: [
Spacer(flex: 2),
MaterialSkipPreviousButton(),
Spacer(),
MaterialPlayOrPauseButton(iconSize: 56.0),
Spacer(),
MaterialSkipNextButton(),
Spacer(flex: 2),
],
topButtonBar: [],
topButtonBarMargin: EdgeInsets.symmetric(
horizontal: 16.0,
),
bottomButtonBar: [
MaterialPositionIndicator(),
Spacer(),
// 全屏后的
MaterialFullscreenButton(),
],
bottomButtonBarMargin: EdgeInsets.only(
left: 16.0,
right: 8.0,
bottom: 42.0,
),
buttonBarHeight: 56.0,
buttonBarButtonSize: 24.0,
buttonBarButtonColor: Color(0xFFFFFFFF),
seekBarMargin: EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 42.0,
),
seekBarHeight: 2.4,
seekBarContainerHeight: 36.0,
seekBarColor: Color(0x3DFFFFFF),
seekBarPositionColor: Color(0xFFFF0000),
seekBarBufferColor: Color(0x3DFFFFFF),
seekBarThumbSize: 12.8,
seekBarThumbColor: Color(0xFFFF0000),
seekBarAlignment: Alignment.bottomCenter,
shiftSubtitlesOnControlsVisibilityChange: false,
);
/// {@template material_video_controls_theme_data}
///
/// Theming related data for [MaterialVideoControls]. These values are used to theme the descendant [MaterialVideoControls].
///
/// {@endtemplate}
class MaterialVideoControlsThemeData {
// BEHAVIOR
/// Whether to display seek bar.
final bool displaySeekBar;
/// Whether a skip next button should be displayed if there are more than one videos in the playlist.
final bool automaticallyImplySkipNextButton;
/// Whether a skip previous button should be displayed if there are more than one videos in the playlist.
final bool automaticallyImplySkipPreviousButton;
/// Whether to modify volume on vertical drag gesture on the right side of the screen.
final bool volumeGesture;
/// Whether to modify screen brightness on vertical drag gesture on the left side of the screen.
final bool brightnessGesture;
/// Whether to seek on horizontal drag gesture.
final bool seekGesture;
/// Whether to allow gesture controls to work while controls are visible.
/// NOTE: This option is ignored when gestures are false.
final bool gesturesEnabledWhileControlsVisible;
/// Whether to enable double tap to seek on left or right side of the screen.
final bool seekOnDoubleTap;
/// Whether to allow double tap to seek on left or right side of the screen to work while controls are visible.
/// NOTE: This option is ignored when [seekOnDoubleTap] is false.
final bool seekOnDoubleTapEnabledWhileControlsVisible;
/// `seekOnDoubleTapLayoutTapsRatios` defines the width proportions for the interactive areas
/// responsible for seek actions (backward seek, instant tap, forward seek) when a double tap
/// occurs on the video widget. This property divides the video widget into three segments
/// horizontally. Each integer in the list represents the relative width of each segment.
/// By default, the value `[1, 1, 1]` means that the video widget is equally divided into three
/// segments: the left segment for backward seek, the middle segment for instant tap (usually show and hide controls),
/// and the right segment for forward seek. Adjusting these values changes the width of the interactive areas
/// for each double tap action.
final List<int> seekOnDoubleTapLayoutTapsRatios;
/// `seekOnDoubleTapLayoutWidgetRatios` defines the width proportions for the visual indicators or
/// widgets that appear during the double tap actions (backward seek, instant tap, forward seek).
/// Similar to `seekOnDoubleTapLayoutTapsRatios`, it divides the area where these indicators are
/// displayed into three segments. Each integer in the list represents the relative width of each
/// segment where the corresponding indicators will be shown. The default `[1, 1, 1]` equally divides
/// the space for each indicator. Modifying these values can change the layout of the seek indicators,
/// giving more or less space to each one based on the specified ratios.
final List<int> seekOnDoubleTapLayoutWidgetRatios;
/// Duration of seek on double tap backward.
final Duration seekOnDoubleTapBackwardDuration;
/// Duration of seek on double tap forward.
final Duration seekOnDoubleTapForwardDuration;
/// Whether the controls are initially visible.
final bool visibleOnMount;
/// Whether to speed up on long press.
final bool speedUpOnLongPress;
/// Factor to speed up on long press.
final double speedUpFactor;
/// Gesture sensitivity on vertical drag gestures, the higher the value is the less sensitive the gesture.
final double verticalGestureSensitivity;
/// Gesture sensitivity on horizontal drag gestures, the higher the value is the less sensitive the gesture.
final double horizontalGestureSensitivity;
/// Color of backdrop that comes up when controls are visible.
final Color? backdropColor;
// GENERIC
/// Padding around the controls.
///
/// * Default: `EdgeInsets.zero`
/// * FullScreen: `MediaQuery.of(context).padding`
///
/// NOTE: In fullscreen, this will be safe area (set [padding] to [EdgeInsets.zero] to disable safe area)
final EdgeInsets? padding;
/// [Duration] after which the controls will be hidden when there is no mouse movement.
final Duration controlsHoverDuration;
/// [Duration] for which the controls will be animated when shown or hidden.
final Duration controlsTransitionDuration;
/// Builder for the buffering indicator.
final Widget Function(BuildContext)? bufferingIndicatorBuilder;
/// Custom builder for volume indicator.
final Widget Function(BuildContext, double)? volumeIndicatorBuilder;
/// Custom builder for brightness indicator.
final Widget Function(BuildContext, double)? brightnessIndicatorBuilder;
/// Custom builder for seek indicator.
final Widget Function(BuildContext, Duration)? seekIndicatorBuilder;
/// Custom builder for seek indicator.
final Widget Function(BuildContext, double)? speedUpIndicatorBuilder;
// BUTTON BAR
/// Buttons to be displayed in the primary button bar.
final List<Widget> primaryButtonBar;
/// Buttons to be displayed in the top button bar.
final List<Widget> topButtonBar;
/// Margin around the top button bar.
final EdgeInsets topButtonBarMargin;
/// Buttons to be displayed in the bottom button bar.
final List<Widget> bottomButtonBar;
/// Margin around the button bar.
final EdgeInsets bottomButtonBarMargin;
/// Height of the button bar.
final double buttonBarHeight;
/// Size of the button bar buttons.
final double buttonBarButtonSize;
/// Color of the button bar buttons.
final Color buttonBarButtonColor;
// SEEK BAR
/// Margin around the seek bar.
final EdgeInsets seekBarMargin;
/// Height of the seek bar.
final double seekBarHeight;
/// Height of the seek bar [Container].
final double seekBarContainerHeight;
/// [Color] of the seek bar.
final Color seekBarColor;
/// [Color] of the playback position section in the seek bar.
final Color seekBarPositionColor;
/// [Color] of the playback buffer section in the seek bar.
final Color seekBarBufferColor;
/// Size of the seek bar thumb.
final double seekBarThumbSize;
/// [Color] of the seek bar thumb.
final Color seekBarThumbColor;
/// [Alignment] of seek bar inside the seek bar container.
final Alignment seekBarAlignment;
// SUBTITLE
/// Whether to shift the subtitles upwards when the controls are visible.
final bool shiftSubtitlesOnControlsVisibilityChange;
/// {@macro material_video_controls_theme_data}
const MaterialVideoControlsThemeData({
this.displaySeekBar = true,
this.automaticallyImplySkipNextButton = true,
this.automaticallyImplySkipPreviousButton = true,
this.volumeGesture = false,
this.brightnessGesture = false,
this.seekGesture = false,
this.gesturesEnabledWhileControlsVisible = true,
this.seekOnDoubleTap = false,
this.seekOnDoubleTapEnabledWhileControlsVisible = true,
this.seekOnDoubleTapLayoutTapsRatios = const [1, 1, 1],
this.seekOnDoubleTapLayoutWidgetRatios = const [1, 1, 1],
this.seekOnDoubleTapBackwardDuration = const Duration(seconds: 10),
this.seekOnDoubleTapForwardDuration = const Duration(seconds: 10),
this.visibleOnMount = false,
this.speedUpOnLongPress = false,
this.speedUpFactor = 2.0,
this.verticalGestureSensitivity = 100,
this.horizontalGestureSensitivity = 1000,
this.backdropColor = const Color(0x66000000),
this.padding,
this.controlsHoverDuration = const Duration(seconds: 3),
this.controlsTransitionDuration = const Duration(milliseconds: 300),
this.bufferingIndicatorBuilder,
this.volumeIndicatorBuilder,
this.brightnessIndicatorBuilder,
this.seekIndicatorBuilder,
this.speedUpIndicatorBuilder,
this.primaryButtonBar = const [
Spacer(flex: 2),
MaterialSkipPreviousButton(),
Spacer(),
MaterialPlayOrPauseButton(iconSize: 48.0),
Spacer(),
MaterialSkipNextButton(),
Spacer(flex: 2),
],
this.topButtonBar = const [],
this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0),
this.bottomButtonBar = const [
MaterialPositionIndicator(),
Spacer(),
// 未全屏的
// MaterialFullscreenButton(),
],
this.bottomButtonBarMargin = const EdgeInsets.only(left: 16.0, right: 8.0),
this.buttonBarHeight = 56.0,
this.buttonBarButtonSize = 24.0,
this.buttonBarButtonColor = const Color(0xFFFFFFFF),
this.seekBarMargin = EdgeInsets.zero,
this.seekBarHeight = 2.4,
this.seekBarContainerHeight = 36.0,
this.seekBarColor = const Color(0x3DFFFFFF),
this.seekBarPositionColor = const Color(0xFFFF0000),
this.seekBarBufferColor = const Color(0x3DFFFFFF),
this.seekBarThumbSize = 12.8,
this.seekBarThumbColor = const Color(0xFFFF0000),
this.seekBarAlignment = Alignment.bottomCenter,
this.shiftSubtitlesOnControlsVisibilityChange = false,
});
/// Creates a copy of this [MaterialVideoControlsThemeData] with the given fields replaced by the non-null parameter values.
MaterialVideoControlsThemeData copyWith({
bool? displaySeekBar,
bool? automaticallyImplySkipNextButton,
bool? automaticallyImplySkipPreviousButton,
bool? volumeGesture,
bool? brightnessGesture,
bool? seekGesture,
bool? gesturesEnabledWhileControlsVisible,
bool? seekOnDoubleTap,
bool? seekOnDoubleTapEnabledWhileControlsVisible,
List<int>? seekOnDoubleTapLayoutTapsRatios,
List<int>? seekOnDoubleTapLayoutWidgetRatios,
Duration? seekOnDoubleTapBackwardDuration,
Duration? seekOnDoubleTapForwardDuration,
bool? visibleOnMount,
bool? speedUpOnLongPress,
double? speedUpFactor,
double? verticalGestureSensitivity,
double? horizontalGestureSensitivity,
Color? backdropColor,
Duration? controlsHoverDuration,
Duration? controlsTransitionDuration,
Widget Function(BuildContext)? bufferingIndicatorBuilder,
Widget Function(BuildContext, double)? volumeIndicatorBuilder,
Widget Function(BuildContext, double)? brightnessIndicatorBuilder,
Widget Function(BuildContext, Duration)? seekIndicatorBuilder,
Widget Function(BuildContext, double)? speedUpIndicatorBuilder,
List<Widget>? primaryButtonBar,
List<Widget>? topButtonBar,
EdgeInsets? topButtonBarMargin,
List<Widget>? bottomButtonBar,
EdgeInsets? bottomButtonBarMargin,
double? buttonBarHeight,
double? buttonBarButtonSize,
Color? buttonBarButtonColor,
EdgeInsets? seekBarMargin,
double? seekBarHeight,
double? seekBarContainerHeight,
Color? seekBarColor,
Color? seekBarPositionColor,
Color? seekBarBufferColor,
double? seekBarThumbSize,
Color? seekBarThumbColor,
Alignment? seekBarAlignment,
bool? shiftSubtitlesOnControlsVisibilityChange,
}) {
return MaterialVideoControlsThemeData(
displaySeekBar: displaySeekBar ?? this.displaySeekBar,
automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? this.automaticallyImplySkipNextButton,
automaticallyImplySkipPreviousButton: automaticallyImplySkipPreviousButton ?? this.automaticallyImplySkipPreviousButton,
volumeGesture: volumeGesture ?? this.volumeGesture,
brightnessGesture: brightnessGesture ?? this.brightnessGesture,
seekGesture: seekGesture ?? this.seekGesture,
gesturesEnabledWhileControlsVisible: gesturesEnabledWhileControlsVisible ?? this.gesturesEnabledWhileControlsVisible,
seekOnDoubleTap: seekOnDoubleTap ?? this.seekOnDoubleTap,
seekOnDoubleTapEnabledWhileControlsVisible: seekOnDoubleTapEnabledWhileControlsVisible ?? this.seekOnDoubleTapEnabledWhileControlsVisible,
seekOnDoubleTapLayoutTapsRatios: seekOnDoubleTapLayoutTapsRatios ?? this.seekOnDoubleTapLayoutTapsRatios,
seekOnDoubleTapLayoutWidgetRatios: seekOnDoubleTapLayoutWidgetRatios ?? this.seekOnDoubleTapLayoutWidgetRatios,
seekOnDoubleTapBackwardDuration: seekOnDoubleTapBackwardDuration ?? this.seekOnDoubleTapBackwardDuration,
seekOnDoubleTapForwardDuration: seekOnDoubleTapForwardDuration ?? this.seekOnDoubleTapForwardDuration,
visibleOnMount: visibleOnMount ?? this.visibleOnMount,
speedUpOnLongPress: speedUpOnLongPress ?? this.speedUpOnLongPress,
speedUpFactor: speedUpFactor ?? this.speedUpFactor,
verticalGestureSensitivity: verticalGestureSensitivity ?? this.verticalGestureSensitivity,
horizontalGestureSensitivity: horizontalGestureSensitivity ?? this.horizontalGestureSensitivity,
backdropColor: backdropColor ?? this.backdropColor,
controlsHoverDuration: controlsHoverDuration ?? this.controlsHoverDuration,
controlsTransitionDuration: controlsTransitionDuration ?? this.controlsTransitionDuration,
bufferingIndicatorBuilder: bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder,
volumeIndicatorBuilder: volumeIndicatorBuilder ?? this.volumeIndicatorBuilder,
brightnessIndicatorBuilder: brightnessIndicatorBuilder ?? this.brightnessIndicatorBuilder,
seekIndicatorBuilder: seekIndicatorBuilder ?? this.seekIndicatorBuilder,
speedUpIndicatorBuilder: speedUpIndicatorBuilder ?? this.speedUpIndicatorBuilder,
primaryButtonBar: primaryButtonBar ?? this.primaryButtonBar,
topButtonBar: topButtonBar ?? this.topButtonBar,
topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin,
bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar,
bottomButtonBarMargin: bottomButtonBarMargin ?? this.bottomButtonBarMargin,
buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight,
buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize,
buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor,
seekBarMargin: seekBarMargin ?? this.seekBarMargin,
seekBarHeight: seekBarHeight ?? this.seekBarHeight,
seekBarContainerHeight: seekBarContainerHeight ?? this.seekBarContainerHeight,
seekBarColor: seekBarColor ?? this.seekBarColor,
seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor,
seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor,
seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize,
seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor,
seekBarAlignment: seekBarAlignment ?? this.seekBarAlignment,
shiftSubtitlesOnControlsVisibilityChange: shiftSubtitlesOnControlsVisibilityChange ?? this.shiftSubtitlesOnControlsVisibilityChange,
);
}
}
/// {@template material_video_controls_theme}
///
/// Inherited widget which provides [MaterialVideoControlsThemeData] to descendant widgets.
///
/// {@endtemplate}
class MaterialVideoControlsTheme extends InheritedWidget {
final MaterialVideoControlsThemeData normal;
final MaterialVideoControlsThemeData fullscreen;
const MaterialVideoControlsTheme({
super.key,
required this.normal,
required this.fullscreen,
required super.child,
});
static MaterialVideoControlsTheme? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MaterialVideoControlsTheme>();
}
static MaterialVideoControlsTheme of(BuildContext context) {
final MaterialVideoControlsTheme? result = maybeOf(context);
assert(
result != null,
'No [MaterialVideoControlsTheme] found in [context]',
);
return result!;
}
@override
bool updateShouldNotify(MaterialVideoControlsTheme oldWidget) => identical(normal, oldWidget.normal) && identical(fullscreen, oldWidget.fullscreen);
}
/// {@macro material_video_controls}
class _MaterialVideoControls extends StatefulWidget {
const _MaterialVideoControls();
@override
State<_MaterialVideoControls> createState() => _MaterialVideoControlsState();
}
/// {@macro material_video_controls}
class _MaterialVideoControlsState extends State<_MaterialVideoControls> {
late bool mount = _theme(context).visibleOnMount;
late bool visible = _theme(context).visibleOnMount;
Timer? _timer;
double _brightnessValue = 0.0;
bool _brightnessIndicator = false;
Timer? _brightnessTimer;
double _currentRate = 1.0;
double _volumeValue = 0.0;
bool _volumeIndicator = 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
bool _speedUpIndicator = false;
late /* private */ var playlist = controller(context).player.state.playlist;
late bool buffering = controller(context).player.state.buffering;
final VolumeController _volumeController = VolumeController.instance;
bool _mountSeekBackwardButton = false;
bool _mountSeekForwardButton = false;
bool _hideSeekBackwardButton = false;
bool _hideSeekForwardButton = false;
Timer? _timerSeekBackwardButton;
Timer? _timerSeekForwardButton;
final ValueNotifier<Duration> _seekBarDeltaValueNotifier = ValueNotifier<Duration>(Duration.zero);
final List<StreamSubscription> subscriptions = [];
double get subtitleVerticalShiftOffset =>
(_theme(context).padding?.bottom ?? 0.0) +
(_theme(context).bottomButtonBarMargin.vertical) +
(_theme(context).bottomButtonBar.isNotEmpty ? _theme(context).buttonBarHeight : 0.0);
Offset? _tapPosition;
void _handleDoubleTapDown(TapDownDetails details) {
setState(() {
_tapPosition = details.localPosition;
});
}
void _handleLongPress() {
setState(() {
_speedUpIndicator = true;
});
_currentRate = controller(context).player.state.rate;
controller(context).player.setRate(_theme(context).speedUpFactor);
}
void _handleLongPressEnd(LongPressEndDetails details) {
setState(() {
_speedUpIndicator = false;
});
controller(context).player.setRate(_currentRate);
}
@override
void setState(VoidCallback fn) {
if (mounted) {
super.setState(fn);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (subscriptions.isEmpty) {
subscriptions.addAll(
[
controller(context).player.stream.playlist.listen(
(event) {
setState(() {
playlist = event;
});
},
),
controller(context).player.stream.buffering.listen(
(event) {
setState(() {
buffering = event;
});
},
),
],
);
if (_theme(context).visibleOnMount) {
_timer = Timer(
_theme(context).controlsHoverDuration,
() {
if (mounted) {
setState(() {
visible = false;
});
unshiftSubtitle();
}
},
);
}
}
}
@override
void dispose() {
for (final subscription in subscriptions) {
subscription.cancel();
}
// --------------------------------------------------
// package:screen_brightness
Future.microtask(() async {
try {
await ScreenBrightnessPlatform.instance.resetApplicationScreenBrightness();
} catch (_) {}
});
// --------------------------------------------------
_timerSeekBackwardButton?.cancel();
_timerSeekForwardButton?.cancel();
super.dispose();
}
void shiftSubtitle() {
if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) {
state(context).setSubtitleViewPadding(
state(context).widget.subtitleViewConfiguration.padding +
EdgeInsets.fromLTRB(
0.0,
0.0,
0.0,
subtitleVerticalShiftOffset,
),
);
}
}
void unshiftSubtitle() {
if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) {
state(context).setSubtitleViewPadding(
state(context).widget.subtitleViewConfiguration.padding,
);
}
}
void onTap() {
if (!visible) {
setState(() {
mount = true;
visible = true;
});
shiftSubtitle();
_timer?.cancel();
_timer = Timer(_theme(context).controlsHoverDuration, () {
if (mounted) {
setState(() {
visible = false;
});
unshiftSubtitle();
}
});
} else {
setState(() {
visible = false;
});
unshiftSubtitle();
_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 = controller(context).player.state.duration.inSeconds;
final position = controller(context).player.state.position.inSeconds;
final seconds = -(diff * duration / _theme(context).horizontalGestureSensitivity).round();
final relativePosition = position + seconds;
if (relativePosition <= duration && relativePosition >= 0) {
setState(() {
swipeDuration = seconds;
showSwipeDuration = true;
_seekBarDeltaValueNotifier.value = Duration(seconds: seconds);
});
}
}
void onHorizontalDragEnd() {
if (swipeDuration != 0) {
Duration newPosition = controller(context).player.state.position + Duration(seconds: swipeDuration);
newPosition = newPosition.clamp(
Duration.zero,
controller(context).player.state.duration,
);
controller(context).player.seek(newPosition);
}
setState(() {
_dragInitialDelta = Offset.zero;
showSwipeDuration = false;
});
}
bool _isInSegment(double localX, int segmentIndex) {
// Local variable with the list of ratios
List<int> segmentRatios = _theme(context).seekOnDoubleTapLayoutTapsRatios;
int totalRatios = segmentRatios.reduce((a, b) => a + b);
double segmentWidthMultiplier = widgetWidth(context) / totalRatios;
double start = 0;
double end;
for (int i = 0; i < segmentRatios.length; i++) {
end = start + (segmentWidthMultiplier * segmentRatios[i]);
// Check if the current index matches the segmentIndex and if localX falls within it
if (i == segmentIndex && localX >= start && localX <= end) {
return true;
}
// Set the start of the next segment
start = end;
}
// If localX does not fall within the specified segment
return false;
}
bool _isInRightSegment(double localX) {
return _isInSegment(localX, 2);
}
bool _isInLeftSegment(double localX) {
return _isInSegment(localX, 0);
}
void _handlePointerDown(PointerDownEvent event) {
onTap();
}
@override
void initState() {
super.initState();
// --------------------------------------------------
// package:volume_controller
Future.microtask(() async {
try {
_volumeController.showSystemUI = false;
_volumeValue = await _volumeController.getVolume();
_volumeController.addListener((value) {
if (mounted && !_volumeInterceptEventStream) {
setState(() {
_volumeValue = value;
});
}
});
} catch (_) {}
});
// --------------------------------------------------
// --------------------------------------------------
// package:screen_brightness
Future.microtask(() async {
try {
_brightnessValue = await ScreenBrightnessPlatform.instance.application;
ScreenBrightnessPlatform.instance.onApplicationScreenBrightnessChanged.listen((value) {
if (mounted) {
setState(() {
_brightnessValue = value;
});
}
});
} catch (_) {}
});
// --------------------------------------------------
}
Future<void> setVolume(double value) async {
// --------------------------------------------------
// package:volume_controller
try {
_volumeController.setVolume(value);
} catch (_) {}
setState(() {
_volumeValue = value;
_volumeIndicator = true;
_volumeInterceptEventStream = true;
});
_volumeTimer?.cancel();
_volumeTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) {
setState(() {
_volumeIndicator = false;
_volumeInterceptEventStream = false;
});
}
});
// --------------------------------------------------
}
Future<void> setBrightness(double value) async {
// --------------------------------------------------
// package:screen_brightness
try {
await ScreenBrightnessPlatform.instance.setApplicationScreenBrightness(value);
} catch (_) {}
setState(() {
_brightnessIndicator = true;
});
_brightnessTimer?.cancel();
_brightnessTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) {
setState(() {
_brightnessIndicator = false;
});
}
});
// --------------------------------------------------
}
@override
Widget build(BuildContext context) {
var seekOnDoubleTapEnabledWhileControlsAreVisible = (_theme(context).seekOnDoubleTap && _theme(context).seekOnDoubleTapEnabledWhileControlsVisible);
assert(_theme(context).seekOnDoubleTapLayoutTapsRatios.length == 3, "The number of seekOnDoubleTapLayoutTapsRatios must be 3, i.e. [1, 1, 1]");
assert(_theme(context).seekOnDoubleTapLayoutWidgetRatios.length == 3, "The number of seekOnDoubleTapLayoutWidgetRatios must be 3, i.e. [1, 1, 1]");
return Theme(
data: Theme.of(context).copyWith(
focusColor: const Color(0x00000000),
hoverColor: const Color(0x00000000),
splashColor: const Color(0x00000000),
highlightColor: const Color(0x00000000),
),
child: Focus(
autofocus: true,
child: Material(
elevation: 0.0,
borderOnForeground: false,
animationDuration: Duration.zero,
color: const Color(0x00000000),
shadowColor: const Color(0x00000000),
surfaceTintColor: const Color(0x00000000),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// Controls:
AnimatedOpacity(
curve: Curves.easeInOut,
opacity: visible ? 1.0 : 0.0,
duration: _theme(context).controlsTransitionDuration,
onEnd: () {
setState(() {
if (!visible) {
mount = false;
}
});
},
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Positioned.fill(
child: Container(
color: _theme(context).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 + subtitleVerticalShiftOffset,
child: Listener(
onPointerDown: (event) => _handlePointerDown(event),
child: GestureDetector(
onDoubleTapDown: _handleDoubleTapDown,
onLongPress: _theme(context).speedUpOnLongPress ? _handleLongPress : null,
onLongPressEnd: _theme(context).speedUpOnLongPress ? _handleLongPressEnd : null,
onDoubleTap: () {
if (_tapPosition == null) {
return;
}
if (_isInRightSegment(_tapPosition!.dx)) {
if ((!mount && _theme(context).seekOnDoubleTap) || seekOnDoubleTapEnabledWhileControlsAreVisible) {
onDoubleTapSeekForward();
}
} else {
if (_isInLeftSegment(_tapPosition!.dx)) {
if ((!mount && _theme(context).seekOnDoubleTap) || seekOnDoubleTapEnabledWhileControlsAreVisible) {
onDoubleTapSeekBackward();
}
}
}
},
onHorizontalDragUpdate: (details) {
if ((!mount && _theme(context).seekGesture) ||
(_theme(context).seekGesture && _theme(context).gesturesEnabledWhileControlsVisible)) {
onHorizontalDragUpdate(details);
}
},
onHorizontalDragEnd: (details) {
onHorizontalDragEnd();
},
onVerticalDragUpdate: (e) async {
final delta = e.delta.dy;
final Offset position = e.localPosition;
if (position.dx <= widgetWidth(context) / 2) {
// Left side of screen swiped
if ((!mount && _theme(context).brightnessGesture) ||
(_theme(context).brightnessGesture && _theme(context).gesturesEnabledWhileControlsVisible)) {
final brightness = _brightnessValue - delta / _theme(context).verticalGestureSensitivity;
final result = brightness.clamp(0.0, 1.0);
setBrightness(result);
}
} else {
// Right side of screen swiped
if ((!mount && _theme(context).volumeGesture) ||
(_theme(context).volumeGesture && _theme(context).gesturesEnabledWhileControlsVisible)) {
final volume = _volumeValue - delta / _theme(context).verticalGestureSensitivity;
final result = volume.clamp(0.0, 1.0);
setVolume(result);
}
}
},
child: Container(
color: const Color(0x00000000),
),
),
),
),
if (mount)
Padding(
padding: _theme(context).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: [
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).topButtonBarMargin,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: _theme(context).topButtonBar,
),
),
// Only display [primaryButtonBar] if [buffering] is false.
Expanded(
child: AnimatedOpacity(
curve: Curves.easeInOut,
opacity: buffering ? 0.0 : 1.0,
duration: _theme(context).controlsTransitionDuration,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: _theme(context).primaryButtonBar,
),
),
),
),
Stack(
alignment: Alignment.bottomCenter,
children: [
if (_theme(context).displaySeekBar)
MaterialSeekBar(
onSeekStart: () {
_timer?.cancel();
},
onSeekEnd: () {
_timer = Timer(
_theme(context).controlsHoverDuration,
() {
if (mounted) {
setState(() {
visible = false;
});
unshiftSubtitle();
}
},
);
},
),
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).bottomButtonBarMargin,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: _theme(context).bottomButtonBar,
),
),
],
),
],
),
),
],
),
),
// Double-Tap Seek Seek-Bar:
if (!mount)
if (_mountSeekBackwardButton || _mountSeekForwardButton || showSwipeDuration)
Column(
children: [
const Spacer(),
Stack(
alignment: Alignment.bottomCenter,
children: [
if (_theme(context).displaySeekBar)
MaterialSeekBar(
delta: _seekBarDeltaValueNotifier,
),
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).bottomButtonBarMargin,
),
],
),
],
),
// Buffering Indicator.
IgnorePointer(
child: Padding(
padding: _theme(context).padding ??
(
// Add padding in fullscreen!
isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero),
child: Column(
children: [
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).topButtonBarMargin,
),
Expanded(
child: Center(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0.0,
end: buffering ? 1.0 : 0.0,
),
duration: _theme(context).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: _theme(context).bufferingIndicatorBuilder?.call(context) ?? child!,
);
}
return const SizedBox.shrink();
},
child: const CircularProgressIndicator(
color: Color(0xFFFFFFFF),
),
),
),
),
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).bottomButtonBarMargin,
),
],
),
),
),
// Volume Indicator.
IgnorePointer(
child: AnimatedOpacity(
curve: Curves.easeInOut,
opacity: (!mount || _theme(context).gesturesEnabledWhileControlsVisible) && _volumeIndicator ? 1.0 : 0.0,
duration: _theme(context).controlsTransitionDuration,
child: _theme(context).volumeIndicatorBuilder?.call(context, _volumeValue) ??
Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x88000000),
borderRadius: BorderRadius.circular(64.0),
),
height: 52.0,
width: 108.0,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 52.0,
width: 42.0,
alignment: Alignment.centerRight,
child: Icon(
_volumeValue == 0.0
? Icons.volume_off
: _volumeValue < 0.5
? Icons.volume_down
: Icons.volume_up,
color: const Color(0xFFFFFFFF),
size: 24.0,
),
),
const SizedBox(width: 8.0),
Expanded(
child: Text(
'${(_volumeValue * 100.0).round()}%',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
color: Color(0xFFFFFFFF),
),
),
),
const SizedBox(width: 16.0),
],
),
),
),
),
// Brightness Indicator.
IgnorePointer(
child: AnimatedOpacity(
curve: Curves.easeInOut,
opacity: (!mount || _theme(context).gesturesEnabledWhileControlsVisible) && _brightnessIndicator ? 1.0 : 0.0,
duration: _theme(context).controlsTransitionDuration,
child: _theme(context).brightnessIndicatorBuilder?.call(context, _brightnessValue) ??
Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x88000000),
borderRadius: BorderRadius.circular(64.0),
),
height: 52.0,
width: 108.0,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 52.0,
width: 42.0,
alignment: Alignment.centerRight,
child: Icon(
_brightnessValue < 1.0 / 3.0
? Icons.brightness_low
: _brightnessValue < 2.0 / 3.0
? Icons.brightness_medium
: Icons.brightness_high,
color: const Color(0xFFFFFFFF),
size: 24.0,
),
),
const SizedBox(width: 8.0),
Expanded(
child: Text(
'${(_brightnessValue * 100.0).round()}%',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
color: Color(0xFFFFFFFF),
),
),
),
const SizedBox(width: 16.0),
],
),
),
),
),
// Speedup Indicator.
IgnorePointer(
child: Padding(
padding: _theme(context).padding ??
(
// Add padding in fullscreen!
isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero),
child: Column(
children: [
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).topButtonBarMargin,
),
Expanded(
child: AnimatedOpacity(
duration: _theme(context).controlsTransitionDuration,
opacity: _speedUpIndicator ? 1 : 0,
child: _theme(context).speedUpIndicatorBuilder?.call(context, _theme(context).speedUpFactor) ??
Container(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.all(16.0),
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x88000000),
borderRadius: BorderRadius.circular(64.0),
),
height: 48.0,
width: 108.0,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(width: 16.0),
Expanded(
child: Text(
'${_theme(context).speedUpFactor.toStringAsFixed(1)}x',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
color: Color(0xFFFFFFFF),
),
),
),
Container(
height: 48.0,
width: 48.0 - 16.0,
alignment: Alignment.centerRight,
child: const Icon(
Icons.fast_forward,
color: Color(0xFFFFFFFF),
size: 24.0,
),
),
const SizedBox(width: 16.0),
],
),
),
),
),
),
Container(
height: _theme(context).buttonBarHeight,
margin: _theme(context).bottomButtonBarMargin,
),
],
),
),
),
// Seek Indicator.
IgnorePointer(
child: AnimatedOpacity(
duration: _theme(context).controlsTransitionDuration,
opacity: showSwipeDuration ? 1 : 0,
child: _theme(context).seekIndicatorBuilder?.call(context, Duration(seconds: swipeDuration)) ??
Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x88000000),
borderRadius: BorderRadius.circular(64.0),
),
height: 52.0,
width: 108.0,
child: Text(
swipeDuration > 0 ? "+ ${Duration(seconds: swipeDuration).label()}" : "- ${Duration(seconds: swipeDuration).label()}",
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
color: Color(0xFFFFFFFF),
),
),
),
),
),
// Double-Tap Seek Button(s):
if (!mount || seekOnDoubleTapEnabledWhileControlsAreVisible)
if (_mountSeekBackwardButton || _mountSeekForwardButton)
Positioned.fill(
child: Row(
children: [
Expanded(
flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[0],
child: _mountSeekBackwardButton
? AnimatedOpacity(
opacity: _hideSeekBackwardButton ? 0 : 1.0,
duration: const Duration(milliseconds: 200),
child: _BackwardSeekIndicator(
duration: _theme(context).seekOnDoubleTapBackwardDuration,
onChanged: (value) {
_seekBarDeltaValueNotifier.value = -value;
},
onSubmitted: (value) {
_timerSeekBackwardButton?.cancel();
_timerSeekBackwardButton = Timer(
const Duration(milliseconds: 200),
() {
setState(() {
_hideSeekBackwardButton = false;
_mountSeekBackwardButton = false;
});
},
);
setState(() {
_hideSeekBackwardButton = true;
});
var result = controller(context).player.state.position - value;
result = result.clamp(
Duration.zero,
controller(context).player.state.duration,
);
controller(context).player.seek(result);
},
),
)
: const SizedBox(),
),
//Area in the middle where the double-tap seek buttons are ignored in
if (_theme(context).seekOnDoubleTapLayoutWidgetRatios[1] > 0)
Spacer(
flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[1],
),
Expanded(
flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[2],
child: _mountSeekForwardButton
? AnimatedOpacity(
opacity: _hideSeekForwardButton ? 0 : 1.0,
duration: const Duration(milliseconds: 200),
child: _ForwardSeekIndicator(
duration: _theme(context).seekOnDoubleTapForwardDuration,
onChanged: (value) {
_seekBarDeltaValueNotifier.value = value;
},
onSubmitted: (value) {
_timerSeekForwardButton?.cancel();
_timerSeekForwardButton = Timer(const Duration(milliseconds: 200), () {
if (_hideSeekForwardButton) {
setState(() {
_hideSeekForwardButton = false;
_mountSeekForwardButton = false;
});
}
});
setState(() {
_hideSeekForwardButton = true;
});
var result = controller(context).player.state.position + value;
result = result.clamp(
Duration.zero,
controller(context).player.state.duration,
);
controller(context).player.seek(result);
},
),
)
: const SizedBox(),
),
],
),
),
],
),
),
),
);
}
double widgetWidth(BuildContext context) => (context.findRenderObject() as RenderBox).paintBounds.width;
}
// SEEK BAR
/// Material design seek bar.
class MaterialSeekBar extends StatefulWidget {
final ValueNotifier<Duration>? delta;
final VoidCallback? onSeekStart;
final VoidCallback? onSeekEnd;
const MaterialSeekBar({
super.key,
this.delta,
this.onSeekStart,
this.onSeekEnd,
});
@override
MaterialSeekBarState createState() => MaterialSeekBarState();
}
class MaterialSeekBarState extends State<MaterialSeekBar> {
bool tapped = false;
double slider = 0.0;
late bool playing = controller(context).player.state.playing;
late Duration position = controller(context).player.state.position;
late Duration duration = controller(context).player.state.duration;
late Duration buffer = controller(context).player.state.buffer;
final List<StreamSubscription> subscriptions = [];
@override
void setState(VoidCallback fn) {
if (mounted) {
super.setState(fn);
}
}
void listener() {
setState(() {
final delta = widget.delta?.value ?? Duration.zero;
position = controller(context).player.state.position + delta;
});
}
@override
void initState() {
super.initState();
widget.delta?.addListener(listener);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (subscriptions.isEmpty && widget.delta == null) {
subscriptions.addAll(
[
controller(context).player.stream.playing.listen((event) {
setState(() {
playing = event;
});
}),
controller(context).player.stream.completed.listen((event) {
setState(() {
position = Duration.zero;
});
}),
controller(context).player.stream.position.listen((event) {
setState(() {
if (!tapped) {
position = event;
}
});
}),
controller(context).player.stream.duration.listen((event) {
setState(() {
duration = event;
});
}),
controller(context).player.stream.buffer.listen((event) {
setState(() {
buffer = event;
});
}),
],
);
}
}
@override
void dispose() {
widget.delta?.removeListener(listener);
for (final subscription in subscriptions) {
subscription.cancel();
}
super.dispose();
}
void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) {
final percent = e.localPosition.dx / constraints.maxWidth;
setState(() {
tapped = true;
slider = percent.clamp(0.0, 1.0);
});
controller(context).player.seek(duration * slider);
}
void onPointerDown() {
widget.onSeekStart?.call();
setState(() {
tapped = true;
});
}
void onPointerUp() {
widget.onSeekEnd?.call();
setState(() {
// Explicitly set the position to prevent the slider from jumping.
tapped = false;
position = duration * slider;
});
controller(context).player.seek(duration * slider);
}
void onPanStart(DragStartDetails e, BoxConstraints constraints) {
final percent = e.localPosition.dx / constraints.maxWidth;
setState(() {
tapped = true;
slider = percent.clamp(0.0, 1.0);
});
}
void onPanDown(DragDownDetails e, BoxConstraints constraints) {
final percent = e.localPosition.dx / constraints.maxWidth;
setState(() {
tapped = true;
slider = percent.clamp(0.0, 1.0);
});
}
void onPanUpdate(DragUpdateDetails e, BoxConstraints constraints) {
final percent = e.localPosition.dx / constraints.maxWidth;
setState(() {
tapped = true;
slider = percent.clamp(0.0, 1.0);
});
}
/// Returns the current playback position in percentage.
double get positionPercent {
if (position == Duration.zero || duration == Duration.zero) {
return 0.0;
} else {
final value = position.inMilliseconds / duration.inMilliseconds;
return value.clamp(0.0, 1.0);
}
}
/// Returns the current playback buffer position in percentage.
double get bufferPercent {
if (buffer == Duration.zero || duration == Duration.zero) {
return 0.0;
} else {
final value = buffer.inMilliseconds / duration.inMilliseconds;
return value.clamp(0.0, 1.0);
}
}
@override
Widget build(BuildContext context) {
return Container(
clipBehavior: Clip.none,
margin: _theme(context).seekBarMargin,
child: LayoutBuilder(
builder: (context, constraints) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onHorizontalDragUpdate: (_) {},
onPanStart: (e) => onPanStart(e, constraints),
onPanDown: (e) => onPanDown(e, constraints),
onPanUpdate: (e) => onPanUpdate(e, constraints),
child: Listener(
onPointerMove: (e) => onPointerMove(e, constraints),
onPointerDown: (e) => onPointerDown(),
onPointerUp: (e) => onPointerUp(),
child: Container(
color: Colors.transparent,
width: constraints.maxWidth,
alignment: _theme(context).seekBarAlignment,
height: _theme(context).seekBarContainerHeight,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Container(
width: constraints.maxWidth,
height: _theme(context).seekBarHeight,
alignment: Alignment.bottomLeft,
color: _theme(context).seekBarColor,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomLeft,
children: [
Container(
width: constraints.maxWidth * bufferPercent,
color: _theme(context).seekBarBufferColor,
),
Container(
width: tapped ? constraints.maxWidth * slider : constraints.maxWidth * positionPercent,
color: _theme(context).seekBarPositionColor,
),
],
),
),
Positioned(
left: tapped
? (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * slider
: (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * positionPercent,
bottom: -1.0 * _theme(context).seekBarThumbSize / 2 + _theme(context).seekBarHeight / 2,
child: Container(
width: _theme(context).seekBarThumbSize,
height: _theme(context).seekBarThumbSize,
decoration: BoxDecoration(
color: _theme(context).seekBarThumbColor,
borderRadius: BorderRadius.circular(
_theme(context).seekBarThumbSize / 2,
),
),
),
),
],
),
),
),
),
),
),
);
}
}
// BUTTON: PLAY/PAUSE
/// A material design play/pause button.
class MaterialPlayOrPauseButton extends StatefulWidget {
/// Overriden icon size for [MaterialSkipPreviousButton].
final double? iconSize;
/// Overriden icon color for [MaterialSkipPreviousButton].
final Color? iconColor;
const MaterialPlayOrPauseButton({
super.key,
this.iconSize,
this.iconColor,
});
@override
MaterialPlayOrPauseButtonState createState() => MaterialPlayOrPauseButtonState();
}
class MaterialPlayOrPauseButtonState extends State<MaterialPlayOrPauseButton> with SingleTickerProviderStateMixin {
late final animation = AnimationController(
vsync: this,
value: controller(context).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 ??= controller(context).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: controller(context).player.playOrPause,
iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize,
color: widget.iconColor ?? _theme(context).buttonBarButtonColor,
icon: IgnorePointer(
child: AnimatedIcon(
progress: animation,
icon: AnimatedIcons.play_pause,
size: widget.iconSize ?? _theme(context).buttonBarButtonSize,
color: widget.iconColor ?? _theme(context).buttonBarButtonColor,
),
),
);
}
}
// BUTTON: SKIP NEXT
/// Material design skip next button.
class MaterialSkipNextButton extends StatelessWidget {
/// Icon for [MaterialSkipNextButton].
final Widget? icon;
/// Overriden icon size for [MaterialSkipNextButton].
final double? iconSize;
/// Overriden icon color for [MaterialSkipNextButton].
final Color? iconColor;
const MaterialSkipNextButton({
super.key,
this.icon,
this.iconSize,
this.iconColor,
});
@override
Widget build(BuildContext context) {
if (!_theme(context).automaticallyImplySkipNextButton ||
(controller(context).player.state.playlist.medias.length > 1 && _theme(context).automaticallyImplySkipNextButton)) {
return IconButton(
onPressed: controller(context).player.next,
icon: icon ?? const Icon(Icons.skip_next),
iconSize: iconSize ?? _theme(context).buttonBarButtonSize,
color: iconColor ?? _theme(context).buttonBarButtonColor,
);
}
return const SizedBox.shrink();
}
}
// BUTTON: SKIP PREVIOUS
/// Material design skip previous button.
class MaterialSkipPreviousButton extends StatelessWidget {
/// Icon for [MaterialSkipPreviousButton].
final Widget? icon;
/// Overriden icon size for [MaterialSkipPreviousButton].
final double? iconSize;
/// Overriden icon color for [MaterialSkipPreviousButton].
final Color? iconColor;
const MaterialSkipPreviousButton({
super.key,
this.icon,
this.iconSize,
this.iconColor,
});
@override
Widget build(BuildContext context) {
if (!_theme(context).automaticallyImplySkipPreviousButton ||
(controller(context).player.state.playlist.medias.length > 1 && _theme(context).automaticallyImplySkipPreviousButton)) {
return IconButton(
onPressed: controller(context).player.previous,
icon: icon ?? const Icon(Icons.skip_previous),
iconSize: iconSize ?? _theme(context).buttonBarButtonSize,
color: iconColor ?? _theme(context).buttonBarButtonColor,
);
}
return const SizedBox.shrink();
}
}
// BUTTON: FULL SCREEN
/// Material design fullscreen button.
class MaterialFullscreenButton extends StatelessWidget {
/// Icon for [MaterialFullscreenButton].
final Widget? icon;
/// Overriden icon size for [MaterialFullscreenButton].
final double? iconSize;
/// Overriden icon color for [MaterialFullscreenButton].
final Color? iconColor;
const MaterialFullscreenButton({
super.key,
this.icon,
this.iconSize,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => toggleFullscreen(context),
icon: icon ?? (isFullscreen(context) ? const Icon(Icons.fullscreen_exit) : const Icon(Icons.fullscreen)),
iconSize: iconSize ?? _theme(context).buttonBarButtonSize,
color: iconColor ?? _theme(context).buttonBarButtonColor,
);
}
}
// BUTTON: CUSTOM
/// Material design custom button.
class MaterialCustomButton extends StatelessWidget {
/// Icon for [MaterialCustomButton].
final Widget? icon;
/// Icon size for [MaterialCustomButton].
final double? iconSize;
/// Icon color for [MaterialCustomButton].
final Color? iconColor;
/// The callback that is called when the button is tapped or otherwise activated.
final VoidCallback onPressed;
const MaterialCustomButton({
super.key,
this.icon,
this.iconSize,
this.iconColor,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
icon: icon ?? const Icon(Icons.settings),
padding: EdgeInsets.zero,
iconSize: iconSize ?? _theme(context).buttonBarButtonSize,
color: iconColor ?? _theme(context).buttonBarButtonColor,
);
}
}
// POSITION INDICATOR
/// Material design position indicator.
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 setState(VoidCallback fn) {
if (mounted) {
super.setState(fn);
}
}
@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 Text(
'${position.label(reference: duration)} / ${duration.label(reference: duration)}',
style: widget.style ??
TextStyle(
height: 1.0,
fontSize: 12.0,
color: _theme(context).buttonBarButtonColor,
),
);
}
}
class _BackwardSeekIndicator extends StatefulWidget {
final Duration duration;
final void Function(Duration) onChanged;
final void Function(Duration) onSubmitted;
const _BackwardSeekIndicator({
super.key,
required this.duration,
required this.onChanged,
required this.onSubmitted,
});
@override
State<_BackwardSeekIndicator> createState() => _BackwardSeekIndicatorState();
}
class _BackwardSeekIndicatorState extends State<_BackwardSeekIndicator> {
late Duration value = widget.duration;
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 Duration duration;
final void Function(Duration) onChanged;
final void Function(Duration) onSubmitted;
const _ForwardSeekIndicator({
super.key,
required this.duration,
required this.onChanged,
required this.onSubmitted,
});
@override
State<_ForwardSeekIndicator> createState() => _ForwardSeekIndicatorState();
}
class _ForwardSeekIndicatorState extends State<_ForwardSeekIndicator> {
late Duration value = widget.duration;
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),
),
),
],
),
),
),
);
}
}