/// This file is a part of media_kit (https://github.com/media-kit/media-kit). /// /// Copyright © 2021 & onwards, Hitesh Kumar Saini . /// All rights reserved. /// Use of this source code is governed by MIT license that can be found in the LICENSE file. // ignore_for_file: non_constant_identifier_names import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:media_kit_video/media_kit_video.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/extensions/duration.dart'; import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; /// {@template material_desktop_video_controls} /// /// [Video] controls which use Material design. /// /// {@endtemplate} Widget MaterialTvVideoControls(VideoState state) { return const VideoControlsThemeDataInjector( child: _MaterialTvVideoControls(), ); } /// [MaterialTvVideoControlsThemeData] available in this [context]. MaterialTvVideoControlsThemeData _theme(BuildContext context) => FullscreenInheritedWidget.maybeOf(context) == null ? MaterialTvVideoControlsTheme.maybeOf(context)?.normal ?? kDefaultMaterialDesktopVideoControlsThemeData : MaterialTvVideoControlsTheme.maybeOf(context)?.fullscreen ?? kDefaultMaterialDesktopVideoControlsThemeDataFullscreen; /// Default [MaterialTvVideoControlsThemeData]. const kDefaultMaterialDesktopVideoControlsThemeData = MaterialTvVideoControlsThemeData(); /// Default [MaterialTvVideoControlsThemeData] for fullscreen. const kDefaultMaterialDesktopVideoControlsThemeDataFullscreen = MaterialTvVideoControlsThemeData(); /// {@template material_desktop_video_controls_theme_data} /// /// Theming related data for [MaterialTvVideoControls]. These values are used to theme the descendant [MaterialTvVideoControls]. /// /// {@endtemplate} class MaterialTvVideoControlsThemeData { // 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; /// Modify volume on mouse scroll. final bool modifyVolumeOnScroll; /// Whether to toggle fullscreen on double press. final bool toggleFullscreenOnDoublePress; /// Whether to hide mouse on controls removal.(will need to move the mouse to be hidden check issue: https://github.com/flutter/flutter/issues/76622) works on macos without moving the mouse final bool hideMouseOnControlsRemoval; /// Whether to toggle play and pause on tap. final bool playAndPauseOnTap; /// Keyboards shortcuts. final Map? keyboardShortcuts; /// Whether the controls are initially visible. final bool visibleOnMount; // GENERIC /// Padding around the controls. /// /// * Default: `EdgeInsets.zero` /// * Fullscreen: `MediaQuery.of(context).padding` 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; // BUTTON BAR /// Buttons to be displayed in the primary button bar. final List primaryButtonBar; /// Buttons to be displayed in the top button bar. final List topButtonBar; /// Margin around the top button bar. final EdgeInsets topButtonBarMargin; /// Buttons to be displayed in the bottom button bar. final List bottomButtonBar; /// Margin around the bottom 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 /// [Duration] for which the seek bar will be animated when the user seeks. final Duration seekBarTransitionDuration; /// [Duration] for which the seek bar thumb will be animated when the user seeks. final Duration seekBarThumbTransitionDuration; /// Margin around the seek bar. final EdgeInsets seekBarMargin; /// Height of the seek bar. final double seekBarHeight; /// Height of the seek bar when hovered. final double seekBarHoverHeight; /// Height of the seek bar [Container]. final double seekBarContainerHeight; /// [Color] of the seek bar. final Color seekBarColor; /// [Color] of the hovered section in the seek bar. final Color seekBarHoverColor; /// [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; // VOLUME BAR /// [Color] of the volume bar. final Color volumeBarColor; /// [Color] of the active region in the volume bar. final Color volumeBarActiveColor; /// Size of the volume bar thumb. final double volumeBarThumbSize; /// [Color] of the volume bar thumb. final Color volumeBarThumbColor; /// [Duration] for which the volume bar will be animated when the user hovers. final Duration volumeBarTransitionDuration; // SUBTITLE /// Whether to shift the subtitles upwards when the controls are visible. final bool shiftSubtitlesOnControlsVisibilityChange; /// {@macro material_desktop_video_controls_theme_data} const MaterialTvVideoControlsThemeData({ this.displaySeekBar = true, this.automaticallyImplySkipNextButton = true, this.automaticallyImplySkipPreviousButton = true, this.toggleFullscreenOnDoublePress = true, this.playAndPauseOnTap = false, this.modifyVolumeOnScroll = true, this.keyboardShortcuts, this.visibleOnMount = false, this.hideMouseOnControlsRemoval = false, this.padding, this.controlsHoverDuration = const Duration(seconds: 3), this.controlsTransitionDuration = const Duration(milliseconds: 150), this.bufferingIndicatorBuilder, this.primaryButtonBar = const [], this.topButtonBar = const [], this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), this.bottomButtonBar = const [ MaterialTvSkipPreviousButton(), MaterialTvPlayOrPauseButton(), MaterialTvSkipNextButton(), MaterialTvVolumeButton(), MaterialTvPositionIndicator(), Spacer(), MaterialTvFullscreenButton(), ], this.bottomButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), this.buttonBarHeight = 56.0, this.buttonBarButtonSize = 28.0, this.buttonBarButtonColor = const Color(0xFFFFFFFF), this.seekBarTransitionDuration = const Duration(milliseconds: 300), this.seekBarThumbTransitionDuration = const Duration(milliseconds: 150), this.seekBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), this.seekBarHeight = 3.2, this.seekBarHoverHeight = 5.6, this.seekBarContainerHeight = 36.0, this.seekBarColor = const Color(0x3DFFFFFF), this.seekBarHoverColor = const Color(0x3DFFFFFF), this.seekBarPositionColor = const Color(0xFFFF0000), this.seekBarBufferColor = const Color(0x3DFFFFFF), this.seekBarThumbSize = 12.0, this.seekBarThumbColor = const Color(0xFFFF0000), this.volumeBarColor = const Color(0x3DFFFFFF), this.volumeBarActiveColor = const Color(0xFFFFFFFF), this.volumeBarThumbSize = 12.0, this.volumeBarThumbColor = const Color(0xFFFFFFFF), this.volumeBarTransitionDuration = const Duration(milliseconds: 150), this.shiftSubtitlesOnControlsVisibilityChange = true, }); /// Creates a copy of this [MaterialTvVideoControlsThemeData] with the given fields replaced by the non-null parameter values. MaterialTvVideoControlsThemeData copyWith({ bool? displaySeekBar, bool? automaticallyImplySkipNextButton, bool? automaticallyImplySkipPreviousButton, bool? toggleFullscreenOnDoublePress, bool? playAndPauseOnTap, bool? modifyVolumeOnScroll, Map? keyboardShortcuts, bool? visibleOnMount, bool? hideMouseOnControlsRemoval, Duration? controlsHoverDuration, Duration? controlsTransitionDuration, Widget Function(BuildContext)? bufferingIndicatorBuilder, List? topButtonBar, EdgeInsets? topButtonBarMargin, List? bottomButtonBar, EdgeInsets? bottomButtonBarMargin, double? buttonBarHeight, double? buttonBarButtonSize, Color? buttonBarButtonColor, Duration? seekBarTransitionDuration, Duration? seekBarThumbTransitionDuration, EdgeInsets? seekBarMargin, double? seekBarHeight, double? seekBarHoverHeight, double? seekBarContainerHeight, Color? seekBarColor, Color? seekBarHoverColor, Color? seekBarPositionColor, Color? seekBarBufferColor, double? seekBarThumbSize, Color? seekBarThumbColor, Color? volumeBarColor, Color? volumeBarActiveColor, double? volumeBarThumbSize, Color? volumeBarThumbColor, Duration? volumeBarTransitionDuration, bool? shiftSubtitlesOnControlsVisibilityChange, }) { return MaterialTvVideoControlsThemeData( displaySeekBar: displaySeekBar ?? this.displaySeekBar, automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? this.automaticallyImplySkipNextButton, automaticallyImplySkipPreviousButton: automaticallyImplySkipPreviousButton ?? this.automaticallyImplySkipPreviousButton, toggleFullscreenOnDoublePress: toggleFullscreenOnDoublePress ?? this.toggleFullscreenOnDoublePress, playAndPauseOnTap: playAndPauseOnTap ?? this.playAndPauseOnTap, modifyVolumeOnScroll: modifyVolumeOnScroll ?? this.modifyVolumeOnScroll, keyboardShortcuts: keyboardShortcuts ?? this.keyboardShortcuts, visibleOnMount: visibleOnMount ?? this.visibleOnMount, hideMouseOnControlsRemoval: hideMouseOnControlsRemoval ?? this.hideMouseOnControlsRemoval, controlsHoverDuration: controlsHoverDuration ?? this.controlsHoverDuration, bufferingIndicatorBuilder: bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, controlsTransitionDuration: controlsTransitionDuration ?? this.controlsTransitionDuration, 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, seekBarTransitionDuration: seekBarTransitionDuration ?? this.seekBarTransitionDuration, seekBarThumbTransitionDuration: seekBarThumbTransitionDuration ?? this.seekBarThumbTransitionDuration, seekBarMargin: seekBarMargin ?? this.seekBarMargin, seekBarHeight: seekBarHeight ?? this.seekBarHeight, seekBarHoverHeight: seekBarHoverHeight ?? this.seekBarHoverHeight, seekBarContainerHeight: seekBarContainerHeight ?? this.seekBarContainerHeight, seekBarColor: seekBarColor ?? this.seekBarColor, seekBarHoverColor: seekBarHoverColor ?? this.seekBarHoverColor, seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, volumeBarColor: volumeBarColor ?? this.volumeBarColor, volumeBarActiveColor: volumeBarActiveColor ?? this.volumeBarActiveColor, volumeBarThumbSize: volumeBarThumbSize ?? this.volumeBarThumbSize, volumeBarThumbColor: volumeBarThumbColor ?? this.volumeBarThumbColor, volumeBarTransitionDuration: volumeBarTransitionDuration ?? this.volumeBarTransitionDuration, shiftSubtitlesOnControlsVisibilityChange: shiftSubtitlesOnControlsVisibilityChange ?? this.shiftSubtitlesOnControlsVisibilityChange, ); } } /// {@template material_desktop_video_controls_theme} /// /// Inherited widget which provides [MaterialTvVideoControlsThemeData] to descendant widgets. /// /// {@endtemplate} class MaterialTvVideoControlsTheme extends InheritedWidget { final MaterialTvVideoControlsThemeData normal; final MaterialTvVideoControlsThemeData fullscreen; const MaterialTvVideoControlsTheme({ super.key, required this.normal, required this.fullscreen, required super.child, }); static MaterialTvVideoControlsTheme? maybeOf(BuildContext context) { return context .dependOnInheritedWidgetOfExactType(); } static MaterialTvVideoControlsTheme of(BuildContext context) { final MaterialTvVideoControlsTheme? result = maybeOf(context); assert( result != null, 'No [MaterialDesktopVideoControlsTheme] found in [context]', ); return result!; } @override bool updateShouldNotify(MaterialTvVideoControlsTheme oldWidget) => identical(normal, oldWidget.normal) && identical(fullscreen, oldWidget.fullscreen); } /// {@macro material_desktop_video_controls} class _MaterialTvVideoControls extends StatefulWidget { const _MaterialTvVideoControls(); @override State<_MaterialTvVideoControls> createState() => _MaterialTvVideoControlsState(); } /// {@macro material_desktop_video_controls} class _MaterialTvVideoControlsState extends State<_MaterialTvVideoControls> { late bool mount = _theme(context).visibleOnMount; late bool visible = _theme(context).visibleOnMount; Timer? _timer; late /* private */ var playlist = controller(context).player.state.playlist; late bool buffering = controller(context).player.state.buffering; DateTime last = DateTime.now(); final List subscriptions = []; FocusNode _focusNode = FocusNode(); double get subtitleVerticalShiftOffset => (_theme(context).padding?.bottom ?? 0.0) + (_theme(context).bottomButtonBarMargin.vertical) + (_theme(context).bottomButtonBar.isNotEmpty ? _theme(context).buttonBarHeight : 0.0); @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(); } 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 onHover() { setState(() { mount = true; visible = true; }); shiftSubtitle(); _timer?.cancel(); _timer = Timer(_theme(context).controlsHoverDuration, () { if (mounted) { setState(() { visible = false; }); unshiftSubtitle(); } }); } void onEnter() { setState(() { mount = true; visible = true; }); shiftSubtitle(); _timer?.cancel(); _timer = Timer(_theme(context).controlsHoverDuration, () { if (mounted) { setState(() { visible = false; }); unshiftSubtitle(); } }); } void onExit() { setState(() { visible = false; }); unshiftSubtitle(); _timer?.cancel(); } @override Widget build(BuildContext context) { return FocusScope( autofocus: true, onKeyEvent: (node, event) { onEnter(); if (event is KeyDownEvent) { print('Key pressed: ${event.logicalKey.debugName}'); if (event.logicalKey == LogicalKeyboardKey.mediaPlayPause) { controller(context).player.playOrPause(); } } return KeyEventResult.ignored; }, child: Theme( data: Theme.of(context).copyWith( focusColor: const Color(0x00000000), hoverColor: const Color(0x00000000), splashColor: const Color(0x00000000), highlightColor: const Color(0x00000000), ), child: Material( elevation: 0.0, borderOnForeground: false, animationDuration: Duration.zero, color: const Color(0x00000000), shadowColor: const Color(0x00000000), surfaceTintColor: const Color(0x00000000), child: Stack( children: [ AnimatedOpacity( curve: Curves.easeInOut, opacity: visible ? 1.0 : 0.0, duration: _theme(context).controlsTransitionDuration, onEnd: () { if (!visible) { setState(() { mount = false; }); } }, child: Stack( clipBehavior: Clip.none, alignment: Alignment.bottomCenter, children: [ // Top gradient. if (_theme(context).topButtonBar.isNotEmpty) Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: [ 0.0, 0.2, ], colors: [ Color(0x61000000), Color(0x00000000), ], ), ), ), // Bottom gradient. if (_theme(context).bottomButtonBar.isNotEmpty) Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: [ 0.5, 1.0, ], colors: [ Color(0x00000000), Color(0x61000000), ], ), ), ), if (mount) Padding( padding: _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, ), ), ), ), if (_theme(context).displaySeekBar) Transform.translate( offset: _theme(context).bottomButtonBar.isNotEmpty ? const Offset(0.0, 16.0) : Offset.zero, child: MaterialTvSeekBar( onSeekStart: () { _timer?.cancel(); }, onSeekEnd: () { _timer = Timer( _theme(context).controlsHoverDuration, () { if (mounted) { setState(() { visible = false; }); unshiftSubtitle(); } }, ); }, ), ), if (_theme(context).bottomButtonBar.isNotEmpty) Container( height: _theme(context).buttonBarHeight, margin: _theme(context).bottomButtonBarMargin, child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: _theme(context).bottomButtonBar, ), ), ], ), ), ], ), ), // 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: Center( child: TweenAnimationBuilder( tween: Tween( 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, ), ], ), ), ), ], ), ), ), ); } } // SEEK BAR /// Material design seek bar. class MaterialTvSeekBar extends StatefulWidget { final VoidCallback? onSeekStart; final VoidCallback? onSeekEnd; const MaterialTvSeekBar({ Key? key, this.onSeekStart, this.onSeekEnd, }) : super(key: key); @override MaterialTvSeekBarState createState() => MaterialTvSeekBarState(); } class MaterialTvSeekBarState extends State { bool hover = false; bool click = 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 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.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 (!click) 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() { for (final subscription in subscriptions) { subscription.cancel(); } super.dispose(); } void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { final percent = e.localPosition.dx / constraints.maxWidth; setState(() { hover = true; slider = percent.clamp(0.0, 1.0); }); controller(context).player.seek(duration * slider); } void onPointerDown() { widget.onSeekStart?.call(); setState(() { click = true; }); } void onPointerUp() { widget.onSeekEnd?.call(); setState(() { // Explicitly set the position to prevent the slider from jumping. click = false; position = duration * slider; }); controller(context).player.seek(duration * slider); } void onHover(PointerHoverEvent e, BoxConstraints constraints) { final percent = e.localPosition.dx / constraints.maxWidth; setState(() { hover = true; slider = percent.clamp(0.0, 1.0); }); } void onEnter(PointerEnterEvent e, BoxConstraints constraints) { final percent = e.localPosition.dx / constraints.maxWidth; setState(() { hover = true; slider = percent.clamp(0.0, 1.0); }); } void onExit(PointerExitEvent e, BoxConstraints constraints) { setState(() { hover = false; slider = 0.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); } } FocusNode focusNode = FocusNode(); FocusNode focusNode2 = FocusNode(); @override Widget build(BuildContext context) { return FocusableActionDetector( focusNode: focusNode, // Create a FocusNode to manage focus autofocus: false, // Automatically focus when the widget appears onFocusChange: (focused) { // Handle focus changes setState(() { hover = focused; // Example: show hover effect when focused }); if (focused) { focusNode2.requestFocus(); } }, child: Focus( focusNode: focusNode2, onKeyEvent: (node, event) { if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.arrowRight) { double percent = 0.01; double sliderPercent = (positionPercent + percent).clamp(0.0, 1.0); setState(() { hover = true; slider = sliderPercent; }); controller(context).player.seek(duration * slider); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { double percent = 0.01; double sliderPercent = (positionPercent - percent).clamp(0.0, 1.0); setState(() { hover = true; slider = sliderPercent; }); controller(context).player.seek(duration * slider); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: Container( clipBehavior: Clip.none, margin: _theme(context).seekBarMargin, child: LayoutBuilder( builder: (context, constraints) => Container( color: const Color(0x00000000), width: constraints.maxWidth, height: _theme(context).seekBarContainerHeight, child: Stack( clipBehavior: Clip.none, alignment: Alignment.centerLeft, children: [ AnimatedContainer( width: constraints.maxWidth, height: hover ? _theme(context).seekBarHoverHeight : _theme(context).seekBarHeight, alignment: Alignment.centerLeft, duration: _theme(context).seekBarThumbTransitionDuration, color: _theme(context).seekBarColor, child: Stack( clipBehavior: Clip.none, alignment: Alignment.centerLeft, children: [ Container( width: constraints.maxWidth * slider, color: _theme(context).seekBarHoverColor, ), Container( width: constraints.maxWidth * bufferPercent, color: _theme(context).seekBarBufferColor, ), Container( width: click ? constraints.maxWidth * slider : constraints.maxWidth * positionPercent, color: _theme(context).seekBarPositionColor, ), ], ), ), Positioned( left: click ? (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * slider : (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * positionPercent, child: AnimatedContainer( width: hover || click ? _theme(context).seekBarThumbSize : 0.0, height: hover || click ? _theme(context).seekBarThumbSize : 0.0, duration: _theme(context).seekBarThumbTransitionDuration, decoration: BoxDecoration( color: _theme(context).seekBarThumbColor, borderRadius: BorderRadius.circular( _theme(context).seekBarThumbSize / 2, ), ), ), ), ], ), ), ), ), ), ); } } // BUTTON: PLAY/PAUSE /// A material design play/pause button. class MaterialTvPlayOrPauseButton extends StatefulWidget { /// Overriden icon size for [MaterialTvSkipPreviousButton]. final double? iconSize; /// Overriden icon color for [MaterialTvSkipPreviousButton]. final Color? iconColor; const MaterialTvPlayOrPauseButton({ super.key, this.iconSize, this.iconColor, }); @override MaterialTvPlayOrPauseButtonState createState() => MaterialTvPlayOrPauseButtonState(); } class MaterialTvPlayOrPauseButtonState extends State with SingleTickerProviderStateMixin { late final animation = AnimationController( vsync: this, value: controller(context).player.state.playing ? 1 : 0, duration: const Duration(milliseconds: 200), ); StreamSubscription? subscription; @override void setState(VoidCallback fn) { if (mounted) { super.setState(fn); } } @override void didChangeDependencies() { super.didChangeDependencies(); subscription ??= 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: AnimatedIcon( progress: animation, icon: AnimatedIcons.play_pause, size: widget.iconSize ?? _theme(context).buttonBarButtonSize, color: widget.iconColor ?? _theme(context).buttonBarButtonColor, ), ); } } // BUTTON: SKIP NEXT /// MaterialDesktop design skip next button. class MaterialTvSkipNextButton extends StatelessWidget { /// Icon for [MaterialTvSkipNextButton]. final Widget? icon; /// Overriden icon size for [MaterialTvSkipNextButton]. final double? iconSize; /// Overriden icon color for [MaterialTvSkipNextButton]. final Color? iconColor; const MaterialTvSkipNextButton({ Key? key, this.icon, this.iconSize, this.iconColor, }) : super(key: key); @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 /// MaterialDesktop design skip previous button. class MaterialTvSkipPreviousButton extends StatelessWidget { /// Icon for [MaterialTvSkipPreviousButton]. final Widget? icon; /// Overriden icon size for [MaterialTvSkipPreviousButton]. final double? iconSize; /// Overriden icon color for [MaterialTvSkipPreviousButton]. final Color? iconColor; const MaterialTvSkipPreviousButton({ Key? key, this.icon, this.iconSize, this.iconColor, }) : super(key: key); @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 /// MaterialDesktop design fullscreen button. class MaterialTvFullscreenButton extends StatelessWidget { /// Icon for [MaterialTvFullscreenButton]. final Widget? icon; /// Overriden icon size for [MaterialTvFullscreenButton]. final double? iconSize; /// Overriden icon color for [MaterialTvFullscreenButton]. final Color? iconColor; const MaterialTvFullscreenButton({ Key? key, this.icon, this.iconSize, this.iconColor, }) : super(key: key); @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 /// MaterialDesktop design custom button. class MaterialTvCustomButton extends StatelessWidget { /// Icon for [MaterialTvCustomButton]. final Widget? icon; /// Icon size for [MaterialTvCustomButton]. final double? iconSize; /// Icon color for [MaterialTvCustomButton]. final Color? iconColor; /// The callback that is called when the button is tapped or otherwise activated. final VoidCallback onPressed; const MaterialTvCustomButton({ Key? key, this.icon, this.iconSize, this.iconColor, required this.onPressed, }) : super(key: key); @override Widget build(BuildContext context) { return IconButton( onPressed: onPressed, icon: icon ?? const Icon(Icons.settings), iconSize: iconSize ?? _theme(context).buttonBarButtonSize, color: iconColor ?? _theme(context).buttonBarButtonColor, ); } } // BUTTON: VOLUME /// MaterialDesktop design volume button & slider. class MaterialTvVolumeButton extends StatefulWidget { /// Icon size for the volume button. final double? iconSize; /// Icon color for the volume button. final Color? iconColor; /// Mute icon. final Widget? volumeMuteIcon; /// Low volume icon. final Widget? volumeLowIcon; /// High volume icon. final Widget? volumeHighIcon; /// Width for the volume slider. final double? sliderWidth; const MaterialTvVolumeButton({ super.key, this.iconSize, this.iconColor, this.volumeMuteIcon, this.volumeLowIcon, this.volumeHighIcon, this.sliderWidth, }); @override MaterialTvVolumeButtonState createState() => MaterialTvVolumeButtonState(); } class MaterialTvVolumeButtonState extends State with SingleTickerProviderStateMixin { late double volume = controller(context).player.state.volume; StreamSubscription? subscription; bool hover = false; bool mute = false; double _volume = 0.0; @override void setState(VoidCallback fn) { if (mounted) { super.setState(fn); } } @override void didChangeDependencies() { super.didChangeDependencies(); subscription ??= controller(context).player.stream.volume.listen((event) { setState(() { volume = event; }); }); } @override void dispose() { subscription?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (e) { setState(() { hover = true; }); }, onExit: (e) { setState(() { hover = false; }); }, child: Listener( onPointerSignal: (event) { if (event is PointerScrollEvent) { if (event.scrollDelta.dy < 0) { controller(context).player.setVolume( (volume + 5.0).clamp(0.0, 100.0), ); } if (event.scrollDelta.dy > 0) { controller(context).player.setVolume( (volume - 5.0).clamp(0.0, 100.0), ); } } }, child: Row( children: [ const SizedBox(width: 4.0), IconButton( onPressed: () async { if (mute) { await controller(context).player.setVolume(_volume); mute = !mute; } // https://github.com/media-kit/media-kit/pull/250#issuecomment-1605588306 else if (volume == 0.0) { _volume = 100.0; await controller(context).player.setVolume(100.0); mute = false; } else { _volume = volume; await controller(context).player.setVolume(0.0); mute = !mute; } setState(() {}); }, iconSize: widget.iconSize ?? (_theme(context).buttonBarButtonSize * 0.8), color: widget.iconColor ?? _theme(context).buttonBarButtonColor, icon: AnimatedSwitcher( duration: _theme(context).volumeBarTransitionDuration, child: volume == 0.0 ? (widget.volumeMuteIcon ?? const Icon( Icons.volume_off, key: ValueKey(Icons.volume_off), )) : volume < 50.0 ? (widget.volumeLowIcon ?? const Icon( Icons.volume_down, key: ValueKey(Icons.volume_down), )) : (widget.volumeHighIcon ?? const Icon( Icons.volume_up, key: ValueKey(Icons.volume_up), )), ), ), AnimatedOpacity( opacity: hover ? 1.0 : 0.0, duration: _theme(context).volumeBarTransitionDuration, child: AnimatedContainer( width: hover ? (12.0 + (widget.sliderWidth ?? 52.0) + 18.0) : 12.0, duration: _theme(context).volumeBarTransitionDuration, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ const SizedBox(width: 12.0), SizedBox( width: widget.sliderWidth ?? 52.0, child: SliderTheme( data: SliderThemeData( trackHeight: 1.2, inactiveTrackColor: _theme(context).volumeBarColor, activeTrackColor: _theme(context).volumeBarActiveColor, thumbColor: _theme(context).volumeBarThumbColor, thumbShape: RoundSliderThumbShape( enabledThumbRadius: _theme(context).volumeBarThumbSize / 2, elevation: 0.0, pressedElevation: 0.0, ), trackShape: _CustomTrackShape(), overlayColor: const Color(0x00000000), ), child: Slider( value: volume.clamp(0.0, 100.0), min: 0.0, max: 100.0, onChanged: (value) async { await controller(context).player.setVolume(value); mute = false; setState(() {}); }, ), ), ), const SizedBox(width: 18.0), ], ), ), ), ), ], ), ), ); } } // POSITION INDICATOR /// MaterialDesktop design position indicator. class MaterialTvPositionIndicator extends StatefulWidget { /// Overriden [TextStyle] for the [MaterialTvPositionIndicator]. final TextStyle? style; const MaterialTvPositionIndicator({super.key, this.style}); @override MaterialTvPositionIndicatorState createState() => MaterialTvPositionIndicatorState(); } class MaterialTvPositionIndicatorState extends State { late Duration position = controller(context).player.state.position; late Duration duration = controller(context).player.state.duration; final List subscriptions = []; @override void setState(VoidCallback fn) { if (mounted) { super.setState(fn); } } @override void didChangeDependencies() { super.didChangeDependencies(); if (subscriptions.isEmpty) { subscriptions.addAll( [ 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 _CustomTrackShape extends RoundedRectSliderTrackShape { @override Rect getPreferredRect({ required RenderBox parentBox, Offset offset = Offset.zero, required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, }) { final height = sliderTheme.trackHeight; final left = offset.dx; final top = offset.dy + (parentBox.size.height - height!) / 2; final width = parentBox.size.width; return Rect.fromLTWH( left, top, width, height, ); } }