From d5bf3487f86cace6281d3d08dc0e1683c3017e3b Mon Sep 17 00:00:00 2001 From: dom Date: Sun, 3 May 2026 21:23:03 +0800 Subject: [PATCH] add audio volume button & slider on desktop Closes #1950 Signed-off-by: dom --- .../widgets/flutter/vertical_slider.dart | 2613 +++++++++++++++++ lib/pages/audio/controller.dart | 39 +- lib/pages/audio/view.dart | 11 + lib/pages/audio/volume_button.dart | 243 ++ 4 files changed, 2905 insertions(+), 1 deletion(-) create mode 100644 lib/common/widgets/flutter/vertical_slider.dart create mode 100644 lib/pages/audio/volume_button.dart diff --git a/lib/common/widgets/flutter/vertical_slider.dart b/lib/common/widgets/flutter/vertical_slider.dart new file mode 100644 index 000000000..df40eadd6 --- /dev/null +++ b/lib/common/widgets/flutter/vertical_slider.dart @@ -0,0 +1,2613 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart' show timeDilation; +import 'package:flutter/services.dart'; + +enum _SliderType { material, adaptive } + +/// A Material Design slider. +/// +/// Used to select from a range of values. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs} +/// +/// {@tool dartpad} +/// This example showcases non-discrete and discrete [VerticalSlider]s. +/// The [VerticalSlider]s will show the updated ![Material 3 Design appearance](https://m3.material.io/components/sliders/overview) +/// when setting the [VerticalSlider.year2023] flag to false. +/// +/// ** See code in examples/api/lib/material/slider/slider.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [VerticalSlider] widget using the [VerticalSlider.secondaryTrackValue] +/// to show a secondary track in the slider. +/// +/// ** See code in examples/api/lib/material/slider/slider.1.dart ** +/// {@end-tool} +/// +/// A slider can be used to select from either a continuous or a discrete set of +/// values. The default is to use a continuous range of values from [min] to +/// [max]. To use discrete values, use a non-null value for [divisions], which +/// indicates the number of discrete intervals. For example, if [min] is 0.0 and +/// [max] is 50.0 and [divisions] is 5, then the slider can take on the +/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0. +/// +/// The terms for the parts of a slider are: +/// +/// * The "thumb", which is a shape that slides horizontally when the user +/// drags it. +/// * The "track", which is the line that the slider thumb slides along. +/// * The "value indicator", which is a shape that pops up when the user +/// is dragging the thumb to indicate the value being selected. +/// * The "active" side of the slider is the side between the thumb and the +/// minimum value. +/// * The "inactive" side of the slider is the side between the thumb and the +/// maximum value. +/// +/// The slider will be disabled if [onChanged] is null or if the range given by +/// [min]..[max] is empty (i.e. if [min] is equal to [max]). +/// +/// The slider widget itself does not maintain any state. Instead, when the state +/// of the slider changes, the widget calls the [onChanged] callback. Most +/// widgets that use a slider will listen for the [onChanged] callback and +/// rebuild the slider with a new [value] to update the visual appearance of the +/// slider. To know when the value starts to change, or when it is done +/// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd]. +/// +/// By default, a slider will be as wide as possible, centered vertically. When +/// given unbounded constraints, it will attempt to make the track 144 pixels +/// wide (with margins on each side) and will shrink-wrap vertically. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// Requires one of its ancestors to be a [MediaQuery] widget. Typically, these +/// are introduced by the [MaterialApp] or [WidgetsApp] widget at the top of +/// your application widget tree. +/// +/// To determine how it should be displayed (e.g. colors, thumb shape, etc.), +/// a slider uses the [SliderThemeData] available from either a [SliderTheme] +/// widget or the [ThemeData.sliderTheme] a [Theme] widget above it in the +/// widget tree. You can also override some of the colors with the [activeColor] +/// and [inactiveColor] properties, although more fine-grained control of the +/// look is achieved using a [SliderThemeData]. +/// +/// See also: +/// +/// * [SliderTheme] and [SliderThemeData] for information about controlling +/// the visual appearance of the slider. +/// * [Radio], for selecting among a set of explicit values. +/// * [Checkbox] and [Switch], for toggling a particular value on or off. +/// * +/// * [MediaQuery], from which the text scale factor is obtained. +class VerticalSlider extends StatefulWidget { + /// Creates a Material Design slider. + /// + /// The slider itself does not maintain any state. Instead, when the state of + /// the slider changes, the widget calls the [onChanged] callback. Most + /// widgets that use a slider will listen for the [onChanged] callback and + /// rebuild the slider with a new [value] to update the visual appearance of + /// the slider. + /// + /// * [value] determines currently selected value for this slider. + /// * [onChanged] is called while the user is selecting a new value for the + /// slider. + /// * [onChangeStart] is called when the user starts to select a new value for + /// the slider. + /// * [onChangeEnd] is called when the user is done selecting a new value for + /// the slider. + /// + /// You can override some of the colors with the [activeColor] and + /// [inactiveColor] properties, although more fine-grained control of the + /// appearance is achieved using a [SliderThemeData]. + const VerticalSlider({ + super.key, + required this.value, + this.secondaryTrackValue, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.label, + this.activeColor, + this.inactiveColor, + this.secondaryActiveColor, + this.thumbColor, + this.overlayColor, + this.mouseCursor, + this.semanticFormatterCallback, + this.focusNode, + this.autofocus = false, + this.allowedInteraction, + this.padding, + this.showValueIndicator, + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.year2023, + }) : _sliderType = _SliderType.material, + assert(min <= max), + assert( + value >= min && value <= max, + 'Value $value is not between minimum $min and maximum $max', + ), + assert( + secondaryTrackValue == null || + (secondaryTrackValue >= min && secondaryTrackValue <= max), + 'SecondaryValue $secondaryTrackValue is not between $min and $max', + ), + assert(divisions == null || divisions > 0); + + /// Creates an adaptive [VerticalSlider] based on the target platform, following + /// Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// Creates a [CupertinoSlider] if the target platform is iOS or macOS, creates a + /// Material Design slider otherwise. + /// + /// If a [CupertinoSlider] is created, the following parameters are ignored: + /// [secondaryTrackValue], [label], [inactiveColor], [secondaryActiveColor], + /// [semanticFormatterCallback], [showValueIndicator]. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + const VerticalSlider.adaptive({ + super.key, + required this.value, + this.secondaryTrackValue, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.label, + this.mouseCursor, + this.activeColor, + this.inactiveColor, + this.secondaryActiveColor, + this.thumbColor, + this.overlayColor, + this.semanticFormatterCallback, + this.focusNode, + this.autofocus = false, + this.allowedInteraction, + this.showValueIndicator, + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + this.year2023, + }) : _sliderType = _SliderType.adaptive, + padding = null, + assert(min <= max), + assert( + value >= min && value <= max, + 'Value $value is not between minimum $min and maximum $max', + ), + assert( + secondaryTrackValue == null || + (secondaryTrackValue >= min && secondaryTrackValue <= max), + 'SecondaryValue $secondaryTrackValue is not between $min and $max', + ), + assert(divisions == null || divisions > 0); + + /// The currently selected value for this slider. + /// + /// The slider's thumb is drawn at a position that corresponds to this value. + final double value; + + /// The secondary track value for this slider. + /// + /// If not null, a secondary track using [VerticalSlider.secondaryActiveColor] color + /// is drawn between the thumb and this value, over the inactive track. + /// + /// If less than [VerticalSlider.value], then the secondary track is not shown. + /// + /// It can be ideal for media scenarios such as showing the buffering progress + /// while the [VerticalSlider.value] shows the play progress. + final double? secondaryTrackValue; + + /// Called during a drag when the user is selecting a new value for the slider + /// by dragging. + /// + /// The slider passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the slider with the new + /// value. + /// + /// If null, the slider will be displayed as disabled. + /// + /// The callback provided to onChanged should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// {@tool snippet} + /// + /// ```dart + /// Slider( + /// value: _duelCommandment.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// label: '$_duelCommandment', + /// onChanged: (double newValue) { + /// setState(() { + /// _duelCommandment = newValue.round(); + /// }); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when the user starts + /// changing the value. + /// * [onChangeEnd] for a callback that is called when the user stops + /// changing the value. + final ValueChanged? onChanged; + + /// Called when the user starts selecting a new value for the slider. + /// + /// This callback shouldn't be used to update the slider [value] (use + /// [onChanged] for that), but rather to be notified when the user has started + /// selecting a new value by starting a drag or with a tap. + /// + /// The value passed will be the last [value] that the slider had before the + /// change began. + /// + /// {@tool snippet} + /// + /// ```dart + /// Slider( + /// value: _duelCommandment.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// label: '$_duelCommandment', + /// onChanged: (double newValue) { + /// setState(() { + /// _duelCommandment = newValue.round(); + /// }); + /// }, + /// onChangeStart: (double startValue) { + /// print('Started change at $startValue'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeEnd] for a callback that is called when the value change is + /// complete. + final ValueChanged? onChangeStart; + + /// Called when the user is done selecting a new value for the slider. + /// + /// This callback shouldn't be used to update the slider [value] (use + /// [onChanged] for that), but rather to know when the user has completed + /// selecting a new [value] by ending a drag or a click. + /// + /// {@tool snippet} + /// + /// ```dart + /// Slider( + /// value: _duelCommandment.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// label: '$_duelCommandment', + /// onChanged: (double newValue) { + /// setState(() { + /// _duelCommandment = newValue.round(); + /// }); + /// }, + /// onChangeEnd: (double newValue) { + /// print('Ended change on $newValue'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when a value change + /// begins. + final ValueChanged? onChangeEnd; + + /// The minimum value the user can select. + /// + /// Defaults to 0.0. Must be less than or equal to [max]. + /// + /// If the [max] is equal to the [min], then the slider is disabled. + final double min; + + /// The maximum value the user can select. + /// + /// Defaults to 1.0. Must be greater than or equal to [min]. + /// + /// If the [max] is equal to the [min], then the slider is disabled. + final double max; + + /// The number of discrete divisions. + /// + /// Typically used with [label] to show the current discrete value. + /// + /// If null, the slider is continuous. + final int? divisions; + + /// A label to show above the slider when the slider is active and + /// [SliderThemeData.showValueIndicator] is satisfied. + /// + /// It is used to display the value of a discrete slider, and it is displayed + /// as part of the value indicator shape. + /// + /// The label is rendered using the active [ThemeData]'s [TextTheme.bodyLarge] + /// text style, with the theme data's [ColorScheme.onPrimary] color. The + /// label's text style can be overridden with + /// [SliderThemeData.valueIndicatorTextStyle]. + /// + /// If null, then the value indicator will not be displayed. + /// + /// Ignored if this slider is created with [Slider.adaptive]. + /// + /// See also: + /// + /// * [SliderComponentShape] for how to create a custom value indicator + /// shape. + final String? label; + + /// The color to use for the portion of the slider track that is active. + /// + /// The "active" side of the slider is the side between the thumb and the + /// minimum value. + /// + /// If null, [SliderThemeData.activeTrackColor] of the ambient + /// [SliderTheme] is used. If that is null, [ColorScheme.primary] of the + /// surrounding [ThemeData] is used. + /// + /// Using a [SliderTheme] gives much more fine-grained control over the + /// appearance of various components of the slider. + final Color? activeColor; + + /// The color for the inactive portion of the slider track. + /// + /// The "inactive" side of the slider is the side between the thumb and the + /// maximum value. + /// + /// If null, [SliderThemeData.inactiveTrackColor] of the ambient [SliderTheme] + /// is used. If [VerticalSlider.year2023] is false and [ThemeData.useMaterial3] is true, + /// then [ColorScheme.secondaryContainer] is used and if [ThemeData.useMaterial3] + /// is false, [ColorScheme.primary] with an opacity of 0.24 is used. Otherwise, + /// [ColorScheme.surfaceContainerHighest] is used. + /// + /// Using a [SliderTheme] gives much more fine-grained control over the + /// appearance of various components of the slider. + /// + /// Ignored if this slider is created with [Slider.adaptive]. + final Color? inactiveColor; + + /// The color to use for the portion of the slider track between the thumb and + /// the [VerticalSlider.secondaryTrackValue]. + /// + /// Defaults to the [SliderThemeData.secondaryActiveTrackColor] of the current + /// [SliderTheme]. + /// + /// If that is also null, defaults to [ColorScheme.primary] with an + /// opacity of 0.54. + /// + /// Using a [SliderTheme] gives much more fine-grained control over the + /// appearance of various components of the slider. + /// + /// Ignored if this slider is created with [Slider.adaptive]. + final Color? secondaryActiveColor; + + /// The color of the thumb. + /// + /// If this color is null, [VerticalSlider] will use [activeColor], If [activeColor] + /// is also null, [VerticalSlider] will use [SliderThemeData.thumbColor]. + /// + /// If that is also null, defaults to [ColorScheme.primary]. + /// + /// * [CupertinoSlider] will have a white thumb + /// (like the native default iOS slider). + final Color? thumbColor; + + /// The highlight color that's typically used to indicate that + /// the slider thumb is focused, hovered, or dragged. + /// + /// If this property is null, [VerticalSlider] will use [activeColor] with + /// an opacity of 0.12, If null, [SliderThemeData.overlayColor] + /// will be used. + /// + /// If that is also null, If [ThemeData.useMaterial3] is true, + /// Slider will use [ColorScheme.primary] with an opacity of 0.08 when + /// slider thumb is hovered and with an opacity of 0.1 when slider thumb + /// is focused or dragged, If [ThemeData.useMaterial3] is false, defaults + /// to [ColorScheme.primary] with an opacity of 0.12. + final WidgetStateProperty? overlayColor; + + /// {@template flutter.material.slider.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.dragged]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [SliderThemeData.mouseCursor] is used. If that + /// is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The callback used to create a semantic value from a slider value. + /// + /// Defaults to formatting values as a percentage. + /// + /// This is used by accessibility frameworks like TalkBack on Android to + /// inform users what the currently selected value is with more context. + /// + /// {@tool snippet} + /// + /// In the example below, a slider for currency values is configured to + /// announce a value with a currency label. + /// + /// ```dart + /// Slider( + /// value: _dollars.toDouble(), + /// min: 20.0, + /// max: 330.0, + /// label: '$_dollars dollars', + /// onChanged: (double newValue) { + /// setState(() { + /// _dollars = newValue.round(); + /// }); + /// }, + /// semanticFormatterCallback: (double newValue) { + /// return '${newValue.round()} dollars'; + /// } + /// ) + /// ``` + /// {@end-tool} + /// + /// Ignored if this slider is created with [Slider.adaptive] + final SemanticFormatterCallback? semanticFormatterCallback; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Allowed way for the user to interact with the [VerticalSlider]. + /// + /// For example, if this is set to [SliderInteraction.tapOnly], the user can + /// interact with the slider only by tapping anywhere on the track. Sliding + /// will have no effect. + /// + /// Defaults to [SliderInteraction.tapAndSlide]. + final SliderInteraction? allowedInteraction; + + /// Determines the padding around the [VerticalSlider]. + /// + /// If specified, this padding overrides the default vertical padding of + /// the [VerticalSlider], defaults to the height of the overlay shape, and the + /// horizontal padding, defaults to the width of the thumb shape or + /// overlay shape, whichever is larger. + final EdgeInsetsGeometry? padding; + + /// Determines the conditions under which the value indicator is shown. + /// + /// If [VerticalSlider.showValueIndicator] is null then the + /// ambient [SliderThemeData.showValueIndicator] is used. If that is also + /// null, defaults to [ShowValueIndicator.onlyForDiscrete]. + final ShowValueIndicator? showValueIndicator; + + /// When true, the [VerticalSlider] will use the 2023 Material Design 3 appearance. + /// Defaults to true. + /// + /// If this is set to false, the [VerticalSlider] will use the latest Material Design 3 + /// appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + final bool? year2023; + + final _SliderType _sliderType; + + @override + State createState() => _VerticalSliderState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('value', value)) + ..add(DoubleProperty('secondaryTrackValue', secondaryTrackValue)) + ..add( + ObjectFlagProperty>( + 'onChanged', + onChanged, + ifNull: 'disabled', + ), + ) + ..add( + ObjectFlagProperty>.has( + 'onChangeStart', + onChangeStart, + ), + ) + ..add( + ObjectFlagProperty>.has( + 'onChangeEnd', + onChangeEnd, + ), + ) + ..add(DoubleProperty('min', min)) + ..add(DoubleProperty('max', max)) + ..add(IntProperty('divisions', divisions)) + ..add(StringProperty('label', label)) + ..add(ColorProperty('activeColor', activeColor)) + ..add(ColorProperty('inactiveColor', inactiveColor)) + ..add(ColorProperty('secondaryActiveColor', secondaryActiveColor)) + ..add( + ObjectFlagProperty>.has( + 'semanticFormatterCallback', + semanticFormatterCallback, + ), + ) + ..add(ObjectFlagProperty.has('focusNode', focusNode)) + ..add( + FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus'), + ); + } +} + +class _VerticalSliderState extends State + with TickerProviderStateMixin { + static const Duration enableAnimationDuration = Duration(milliseconds: 75); + static const Duration valueIndicatorAnimationDuration = Duration( + milliseconds: 100, + ); + + // Animation controller that is run when the overlay (a.k.a radial reaction) + // is shown in response to user interaction. + late AnimationController overlayController; + // Animation controller that is run when the value indicator is being shown + // or hidden. + late AnimationController valueIndicatorController; + // Animation controller that is run when enabling/disabling the slider. + late AnimationController enableController; + // Animation controller that is run when transitioning between one value + // and the next on a discrete slider. + late AnimationController positionController; + Timer? interactionTimer; + + final GlobalKey _renderObjectKey = GlobalKey(); + + // Keyboard mapping for a focused slider. + static const Map + _traditionalNavShortcutMap = { + SingleActivator(LogicalKeyboardKey.arrowUp): _AdjustSliderIntent.up(), + SingleActivator(LogicalKeyboardKey.arrowDown): _AdjustSliderIntent.down(), + SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), + SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), + }; + + // Keyboard mapping for a focused slider when using directional navigation. + // The vertical inputs are not handled to allow navigating out of the slider. + static const Map + _directionalNavShortcutMap = { + SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), + SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), + }; + + // Action mapping for a focused slider. + late Map> _actionMap; + + bool get _enabled => widget.onChanged != null; + // Value Indicator Animation that appears on the Overlay. + PaintValueIndicator? paintValueIndicator; + + bool _dragging = false; + + // For discrete sliders, _handleChanged might receive the same value + // multiple times. To avoid calling widget.onChanged repeatedly, the + // value from _handleChanged is temporarily saved here. + double? _currentChangedValue; + + FocusNode? _focusNode; + FocusNode get focusNode => widget.focusNode ?? _focusNode!; + + // Always keep the ValueIndicator visible on the Overlay; otherwise, it cannot be updated during the build phase. + final OverlayPortalController _valueIndicatorOverlayPortalController = + OverlayPortalController( + debugLabel: 'Slider ValueIndicator', + )..show(); + + @override + void initState() { + super.initState(); + overlayController = AnimationController( + duration: kRadialReactionDuration, + vsync: this, + ); + valueIndicatorController = AnimationController( + duration: valueIndicatorAnimationDuration, + vsync: this, + ); + enableController = AnimationController( + duration: enableAnimationDuration, + vsync: this, + ); + positionController = AnimationController( + duration: Duration.zero, + vsync: this, + ); + enableController.value = widget.onChanged != null ? 1.0 : 0.0; + positionController.value = _convert(widget.value); + _actionMap = >{ + _AdjustSliderIntent: CallbackAction<_AdjustSliderIntent>( + onInvoke: _actionHandler, + ), + }; + if (widget.focusNode == null) { + // Only create a new node if the widget doesn't have one. + _focusNode ??= FocusNode(); + } + } + + @override + void dispose() { + interactionTimer?.cancel(); + overlayController.dispose(); + valueIndicatorController.dispose(); + enableController.dispose(); + positionController.dispose(); + _focusNode?.dispose(); + super.dispose(); + } + + void _handleChanged(double value) { + assert(widget.onChanged != null); + final double lerpValue = _lerp(value); + if (_currentChangedValue != lerpValue) { + _currentChangedValue = lerpValue; + if (_currentChangedValue != widget.value) { + widget.onChanged!(_currentChangedValue!); + } + } + } + + void _handleDragStart(double value) { + setState(() { + _dragging = true; + }); + widget.onChangeStart?.call(_lerp(value)); + } + + void _handleDragEnd(double value) { + setState(() { + _dragging = false; + }); + _currentChangedValue = null; + widget.onChangeEnd?.call(_lerp(value)); + } + + void _actionHandler(_AdjustSliderIntent intent) { + final TextDirection directionality = Directionality.of( + _renderObjectKey.currentContext!, + ); + final bool shouldIncrease = switch (intent.type) { + _SliderAdjustmentType.up => true, + _SliderAdjustmentType.down => false, + _SliderAdjustmentType.left => directionality == TextDirection.rtl, + _SliderAdjustmentType.right => directionality == TextDirection.ltr, + }; + + final slider = + _renderObjectKey.currentContext!.findRenderObject()! as _RenderSlider; + return shouldIncrease ? slider.increaseAction() : slider.decreaseAction(); + } + + bool _focused = false; + void _handleFocusHighlightChanged(bool focused) { + if (focused != _focused) { + setState(() { + _focused = focused; + }); + } + } + + bool _hovering = false; + void _handleHoverChanged(bool hovering) { + if (hovering != _hovering) { + setState(() { + _hovering = hovering; + }); + } + } + + // Returns a number between min and max, proportional to value, which must + // be between 0.0 and 1.0. + double _lerp(double value) { + assert(value >= 0.0); + assert(value <= 1.0); + return value * (widget.max - widget.min) + widget.min; + } + + double _discretize(double value) { + assert(widget.divisions != null); + assert(value >= 0.0 && value <= 1.0); + + final int divisions = widget.divisions!; + return (value * divisions).round() / divisions; + } + + double _convert(double value) { + double ret = _unlerp(value); + if (widget.divisions != null) { + ret = _discretize(ret); + } + return ret; + } + + // Returns a number between 0.0 and 1.0, given a value between min and max. + double _unlerp(double value) { + assert(value <= widget.max); + assert(value >= widget.min); + return widget.max > widget.min + ? (value - widget.min) / (widget.max - widget.min) + : 0.0; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMediaQuery(context)); + + switch (widget._sliderType) { + case _SliderType.material: + return _buildMaterialSlider(context); + + case _SliderType.adaptive: + { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return _buildMaterialSlider(context); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _buildCupertinoSlider(context); + } + } + } + } + + Widget _buildMaterialSlider(BuildContext context) { + final ThemeData theme = Theme.of(context); + SliderThemeData sliderTheme = SliderTheme.of(context); + // ignore: deprecated_member_use + final bool year2023 = widget.year2023 ?? sliderTheme.year2023 ?? true; + final SliderThemeData defaults = switch (theme.useMaterial3) { + true => + year2023 + ? _SliderDefaultsM3Year2023(context) + : _SliderDefaultsM3(context), + false => _SliderDefaultsM2(context), + }; + + // If the widget has active or inactive colors specified, then we plug them + // in to the slider theme as best we can. If the developer wants more + // control than that, then they need to use a SliderTheme. The default + // colors come from the ThemeData.colorScheme. These colors, along with + // the default shapes and text styles are aligned to the Material + // Guidelines. + + const ShowValueIndicator defaultShowValueIndicator = + ShowValueIndicator.onlyForDiscrete; + const SliderInteraction defaultAllowedInteraction = + SliderInteraction.tapAndSlide; + + final states = { + if (!_enabled) WidgetState.disabled, + if (_hovering) WidgetState.hovered, + if (_focused) WidgetState.focused, + if (_dragging) WidgetState.dragged, + }; + + // The value indicator's color is not the same as the thumb and active track + // (which can be defined by activeColor) if the + // RectangularSliderValueIndicatorShape is used. In all other cases, the + // value indicator is assumed to be the same as the active color. + final SliderComponentShape valueIndicatorShape = + sliderTheme.valueIndicatorShape ?? defaults.valueIndicatorShape!; + final Color valueIndicatorColor; + if (valueIndicatorShape is RectangularSliderValueIndicatorShape) { + valueIndicatorColor = + sliderTheme.valueIndicatorColor ?? + Color.alphaBlend( + theme.colorScheme.onSurface.withValues(alpha: 0.60), + theme.colorScheme.surface.withValues(alpha: 0.90), + ); + } else { + valueIndicatorColor = + widget.activeColor ?? + sliderTheme.valueIndicatorColor ?? + defaults.valueIndicatorColor!; + } + + Color? effectiveOverlayColor() { + return widget.overlayColor?.resolve(states) ?? + widget.activeColor?.withValues(alpha: 0.12) ?? + WidgetStateProperty.resolveAs( + sliderTheme.overlayColor, + states, + ) ?? + WidgetStateProperty.resolveAs(defaults.overlayColor, states); + } + + TextStyle valueIndicatorTextStyle = + sliderTheme.valueIndicatorTextStyle ?? + defaults.valueIndicatorTextStyle!; + if (MediaQuery.boldTextOf(context)) { + valueIndicatorTextStyle = valueIndicatorTextStyle.merge( + const TextStyle(fontWeight: FontWeight.bold), + ); + } + + sliderTheme = sliderTheme.copyWith( + trackHeight: sliderTheme.trackHeight ?? defaults.trackHeight, + activeTrackColor: + widget.activeColor ?? + sliderTheme.activeTrackColor ?? + defaults.activeTrackColor, + inactiveTrackColor: + widget.inactiveColor ?? + sliderTheme.inactiveTrackColor ?? + defaults.inactiveTrackColor, + secondaryActiveTrackColor: + widget.secondaryActiveColor ?? + sliderTheme.secondaryActiveTrackColor ?? + defaults.secondaryActiveTrackColor, + disabledActiveTrackColor: + sliderTheme.disabledActiveTrackColor ?? + defaults.disabledActiveTrackColor, + disabledInactiveTrackColor: + sliderTheme.disabledInactiveTrackColor ?? + defaults.disabledInactiveTrackColor, + disabledSecondaryActiveTrackColor: + sliderTheme.disabledSecondaryActiveTrackColor ?? + defaults.disabledSecondaryActiveTrackColor, + activeTickMarkColor: + widget.inactiveColor ?? + sliderTheme.activeTickMarkColor ?? + defaults.activeTickMarkColor, + inactiveTickMarkColor: + widget.activeColor ?? + sliderTheme.inactiveTickMarkColor ?? + defaults.inactiveTickMarkColor, + disabledActiveTickMarkColor: + sliderTheme.disabledActiveTickMarkColor ?? + defaults.disabledActiveTickMarkColor, + disabledInactiveTickMarkColor: + sliderTheme.disabledInactiveTickMarkColor ?? + defaults.disabledInactiveTickMarkColor, + thumbColor: + widget.thumbColor ?? + widget.activeColor ?? + sliderTheme.thumbColor ?? + defaults.thumbColor, + disabledThumbColor: + sliderTheme.disabledThumbColor ?? defaults.disabledThumbColor, + overlayColor: effectiveOverlayColor(), + valueIndicatorColor: valueIndicatorColor, + trackShape: sliderTheme.trackShape ?? defaults.trackShape, + tickMarkShape: sliderTheme.tickMarkShape ?? defaults.tickMarkShape, + thumbShape: sliderTheme.thumbShape ?? defaults.thumbShape, + overlayShape: sliderTheme.overlayShape ?? defaults.overlayShape, + valueIndicatorShape: valueIndicatorShape, + showValueIndicator: + widget.showValueIndicator ?? + sliderTheme.showValueIndicator ?? + defaultShowValueIndicator, + valueIndicatorTextStyle: valueIndicatorTextStyle, + padding: widget.padding ?? sliderTheme.padding, + thumbSize: sliderTheme.thumbSize ?? defaults.thumbSize, + trackGap: sliderTheme.trackGap ?? defaults.trackGap, + ); + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs( + widget.mouseCursor, + states, + ) ?? + sliderTheme.mouseCursor?.resolve(states) ?? + WidgetStateMouseCursor.clickable.resolve(states); + final SliderInteraction effectiveAllowedInteraction = + widget.allowedInteraction ?? + sliderTheme.allowedInteraction ?? + defaultAllowedInteraction; + + // This size is used as the max bounds for the painting of the value + // indicators It must be kept in sync with the function with the same name + // in range_slider.dart. + Size screenSize() => MediaQuery.sizeOf(context); + + VoidCallback? handleDidGainAccessibilityFocus; + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + break; + case TargetPlatform.windows: + handleDidGainAccessibilityFocus = () { + // Automatically activate the slider when it receives a11y focus. + if (!focusNode.hasFocus && focusNode.canRequestFocus) { + focusNode.requestFocus(); + } + }; + } + + final Map shortcutMap = + switch (MediaQuery.navigationModeOf( + context, + )) { + NavigationMode.directional => _directionalNavShortcutMap, + NavigationMode.traditional => _traditionalNavShortcutMap, + }; + + final double fontSize = + sliderTheme.valueIndicatorTextStyle?.fontSize ?? kDefaultFontSize; + final double fontSizeToScale = fontSize == 0.0 + ? kDefaultFontSize + : fontSize; + final TextScaler textScaler = theme.useMaterial3 + // TODO(tahatesser): This is an eye-balled value. + // This needs to be updated when accessibility + // guidelines are available on the material specs page + // https://m3.material.io/components/sliders/accessibility. + ? MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.3) + : MediaQuery.textScalerOf(context); + final double effectiveTextScale = + textScaler.scale(fontSizeToScale) / fontSizeToScale; + + Widget result = CompositedTransformTarget( + link: _layerLink, + child: _SliderRenderObjectWidget( + key: _renderObjectKey, + value: _convert(widget.value), + secondaryTrackValue: (widget.secondaryTrackValue != null) + ? _convert(widget.secondaryTrackValue!) + : null, + divisions: widget.divisions, + label: widget.label, + sliderTheme: sliderTheme, + textScaleFactor: effectiveTextScale, + screenSize: screenSize(), + onChanged: (widget.onChanged != null) && (widget.max > widget.min) + ? _handleChanged + : null, + onChangeStart: _handleDragStart, + onChangeEnd: _handleDragEnd, + state: this, + semanticFormatterCallback: widget.semanticFormatterCallback, + hasFocus: _focused, + hovering: _hovering, + allowedInteraction: effectiveAllowedInteraction, + ), + ); + + final EdgeInsetsGeometry? padding = widget.padding ?? sliderTheme.padding; + if (padding != null) { + result = Padding(padding: padding, child: result); + } + result = OverlayPortal( + controller: _valueIndicatorOverlayPortalController, + overlayChildBuilder: (BuildContext context) { + return _buildValueIndicator(sliderTheme.showValueIndicator!); + }, + child: result, + ); + + return Semantics( + label: widget.label, + container: true, + slider: true, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + child: FocusableActionDetector( + actions: _actionMap, + shortcuts: shortcutMap, + focusNode: focusNode, + autofocus: widget.autofocus, + enabled: _enabled, + onShowFocusHighlight: _handleFocusHighlightChanged, + onShowHoverHighlight: _handleHoverChanged, + mouseCursor: effectiveMouseCursor, + child: result, + ), + ); + } + + Widget _buildCupertinoSlider(BuildContext context) { + // The render box of a slider has a fixed height but takes up the available + // width. Wrapping the [CupertinoSlider] in this manner will help maintain + // the same size. + return SizedBox( + width: double.infinity, + child: CupertinoSlider( + value: widget.value, + onChanged: widget.onChanged, + onChangeStart: widget.onChangeStart, + onChangeEnd: widget.onChangeEnd, + min: widget.min, + max: widget.max, + divisions: widget.divisions, + activeColor: widget.activeColor, + thumbColor: widget.thumbColor ?? CupertinoColors.white, + ), + ); + } + + final LayerLink _layerLink = LayerLink(); + Widget _buildValueIndicator(ShowValueIndicator showValueIndicator) { + final Widget valueIndicator = CompositedTransformFollower( + link: _layerLink, + child: _ValueIndicatorRenderObjectWidget(state: this), + ); + return switch (showValueIndicator) { + ShowValueIndicator.never => const SizedBox.shrink(), + ShowValueIndicator.onlyForDiscrete => + widget.divisions != null ? valueIndicator : const SizedBox.shrink(), + ShowValueIndicator.onlyForContinuous => + widget.divisions == null ? valueIndicator : const SizedBox.shrink(), + ShowValueIndicator.alwaysVisible || + // ignore: deprecated_member_use + ShowValueIndicator.always || + ShowValueIndicator.onDrag => valueIndicator, + }; + } +} + +class _SliderRenderObjectWidget extends LeafRenderObjectWidget { + const _SliderRenderObjectWidget({ + super.key, + required this.value, + required this.secondaryTrackValue, + required this.divisions, + required this.label, + required this.sliderTheme, + required this.textScaleFactor, + required this.screenSize, + required this.onChanged, + required this.onChangeStart, + required this.onChangeEnd, + required this.state, + required this.semanticFormatterCallback, + required this.hasFocus, + required this.hovering, + required this.allowedInteraction, + }); + + final double value; + final double? secondaryTrackValue; + final int? divisions; + final String? label; + final SliderThemeData sliderTheme; + final double textScaleFactor; + final Size screenSize; + final ValueChanged? onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + final SemanticFormatterCallback? semanticFormatterCallback; + final _VerticalSliderState state; + final bool hasFocus; + final bool hovering; + final SliderInteraction allowedInteraction; + + @override + _RenderSlider createRenderObject(BuildContext context) { + return _RenderSlider( + value: value, + secondaryTrackValue: secondaryTrackValue, + divisions: divisions, + label: label, + sliderTheme: sliderTheme, + textScaleFactor: textScaleFactor, + screenSize: screenSize, + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + state: state, + textDirection: Directionality.of(context), + semanticFormatterCallback: semanticFormatterCallback, + platform: Theme.of(context).platform, + hasFocus: hasFocus, + hovering: hovering, + gestureSettings: MediaQuery.gestureSettingsOf(context), + allowedInteraction: allowedInteraction, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSlider renderObject) { + renderObject + // We should update the `divisions` ahead of `value`, because the `value` + // setter dependent on the `divisions`. + ..divisions = divisions + ..value = value + ..secondaryTrackValue = secondaryTrackValue + ..label = label + ..sliderTheme = sliderTheme + ..textScaleFactor = textScaleFactor + ..screenSize = screenSize + ..onChanged = onChanged + ..onChangeStart = onChangeStart + ..onChangeEnd = onChangeEnd + ..textDirection = Directionality.of(context) + ..semanticFormatterCallback = semanticFormatterCallback + ..platform = Theme.of(context).platform + ..hasFocus = hasFocus + ..hovering = hovering + ..gestureSettings = MediaQuery.gestureSettingsOf(context) + ..allowedInteraction = allowedInteraction; + // Ticker provider cannot change since there's a 1:1 relationship between + // the _SliderRenderObjectWidget object and the _SliderState object. + } +} + +class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderSlider({ + required double value, + required double? secondaryTrackValue, + required int? divisions, + required String? label, + required SliderThemeData sliderTheme, + required double textScaleFactor, + required Size screenSize, + required TargetPlatform platform, + required ValueChanged? onChanged, + required SemanticFormatterCallback? semanticFormatterCallback, + required this.onChangeStart, + required this.onChangeEnd, + required _VerticalSliderState state, + required TextDirection textDirection, + required bool hasFocus, + required bool hovering, + required DeviceGestureSettings gestureSettings, + required SliderInteraction allowedInteraction, + }) : assert(value >= 0.0 && value <= 1.0), + assert( + secondaryTrackValue == null || + (secondaryTrackValue >= 0.0 && secondaryTrackValue <= 1.0), + ), + _platform = platform, + _semanticFormatterCallback = semanticFormatterCallback, + _label = label, + _value = value, + _secondaryTrackValue = secondaryTrackValue, + _divisions = divisions, + _sliderTheme = sliderTheme, + _textScaleFactor = textScaleFactor, + _screenSize = screenSize, + _onChanged = onChanged, + _state = state, + _textDirection = textDirection, + _hasFocus = hasFocus, + _hovering = hovering, + _allowedInteraction = allowedInteraction { + _updateLabelPainter(); + final team = GestureArenaTeam(); + _drag = VerticalDragGestureRecognizer() + ..team = team + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..onCancel = _endInteraction + ..gestureSettings = gestureSettings; + _tap = TapGestureRecognizer() + ..team = team + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..gestureSettings = gestureSettings; + _overlayAnimation = CurvedAnimation( + parent: _state.overlayController, + curve: Curves.fastOutSlowIn, + ); + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + _enableAnimation = CurvedAnimation( + parent: _state.enableController, + curve: Curves.easeInOut, + ); + } + static const Duration _positionAnimationDuration = Duration(milliseconds: 75); + static const Duration _minimumInteractionTime = Duration(milliseconds: 500); + + // This value is the touch target, 48, multiplied by 3. + static const double _minPreferredTrackWidth = 144.0; + + // Compute the largest width and height needed to paint the slider shapes, + // other than the track shape. It is assumed that these shapes are vertically + // centered on the track. + double get _maxSliderPartWidth => + _sliderPartSizes.map((Size size) => size.width).reduce(math.max); + double get _maxSliderPartHeight => + _sliderPartSizes.map((Size size) => size.height).reduce(math.max); + double get _thumbSizeHeight => _sliderTheme.thumbShape! + .getPreferredSize(isInteractive, isDiscrete) + .height; + double get _overlayHeight => _sliderTheme.overlayShape! + .getPreferredSize(isInteractive, isDiscrete) + .height; + List get _sliderPartSizes => [ + Size( + _sliderTheme.overlayShape! + .getPreferredSize(isInteractive, isDiscrete) + .width, + _sliderTheme.padding != null ? _thumbSizeHeight : _overlayHeight, + ), + _sliderTheme.thumbShape!.getPreferredSize(isInteractive, isDiscrete), + _sliderTheme.tickMarkShape!.getPreferredSize( + isEnabled: isInteractive, + sliderTheme: sliderTheme, + ), + ]; + double get _minPreferredTrackHeight => _sliderTheme.trackHeight!; + + final _VerticalSliderState _state; + late CurvedAnimation _overlayAnimation; + late CurvedAnimation _valueIndicatorAnimation; + late CurvedAnimation _enableAnimation; + final TextPainter _labelPainter = TextPainter(); + late VerticalDragGestureRecognizer _drag; + late TapGestureRecognizer _tap; + bool _active = false; + double _currentDragValue = 0.0; + Rect? overlayRect; + + // This rect is used in gesture calculations, where the gesture coordinates + // are relative to the sliders origin. Therefore, the offset is passed as + // (0,0). + Rect get _trackRect => _sliderTheme.trackShape!.getPreferredRect( + parentBox: this, + sliderTheme: _sliderTheme, + isDiscrete: false, + ); + + bool get isInteractive => onChanged != null; + + bool get isDiscrete => false; // divisions != null && divisions! > 0; + + double get value => _value; + double _value; + set value(double newValue) { + assert(newValue >= 0.0 && newValue <= 1.0); + final double convertedValue = isDiscrete ? _discretize(newValue) : newValue; + if (convertedValue == _value) { + return; + } + _value = convertedValue; + if (isDiscrete) { + // Reset the duration to match the distance that we're traveling, so that + // whatever the distance, we still do it in _positionAnimationDuration, + // and if we get re-targeted in the middle, it still takes that long to + // get to the new location. + final double distance = (_value - _state.positionController.value).abs(); + _state.positionController.duration = distance != 0.0 + ? _positionAnimationDuration * (1.0 / distance) + : Duration.zero; + _state.positionController.animateTo( + convertedValue, + curve: Curves.easeInOut, + ); + } else { + _state.positionController.value = convertedValue; + } + markNeedsSemanticsUpdate(); + } + + double? get secondaryTrackValue => _secondaryTrackValue; + double? _secondaryTrackValue; + set secondaryTrackValue(double? newValue) { + assert(newValue == null || (newValue >= 0.0 && newValue <= 1.0)); + if (newValue == _secondaryTrackValue) { + return; + } + _secondaryTrackValue = newValue; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + DeviceGestureSettings? get gestureSettings => _drag.gestureSettings; + set gestureSettings(DeviceGestureSettings? gestureSettings) { + _drag.gestureSettings = gestureSettings; + _tap.gestureSettings = gestureSettings; + } + + TargetPlatform _platform; + TargetPlatform get platform => _platform; + set platform(TargetPlatform value) { + if (_platform == value) { + return; + } + _platform = value; + markNeedsSemanticsUpdate(); + } + + SemanticFormatterCallback? _semanticFormatterCallback; + SemanticFormatterCallback? get semanticFormatterCallback => + _semanticFormatterCallback; + set semanticFormatterCallback(SemanticFormatterCallback? value) { + if (_semanticFormatterCallback == value) { + return; + } + _semanticFormatterCallback = value; + markNeedsSemanticsUpdate(); + } + + int? get divisions => _divisions; + int? _divisions; + set divisions(int? value) { + if (value == _divisions) { + return; + } + _divisions = value; + markNeedsPaint(); + } + + String? get label => _label; + String? _label; + set label(String? value) { + if (value == _label) { + return; + } + _label = value; + _updateLabelPainter(); + } + + SliderThemeData get sliderTheme => _sliderTheme; + SliderThemeData _sliderTheme; + set sliderTheme(SliderThemeData value) { + if (value == _sliderTheme) { + return; + } + _sliderTheme = value; + _updateLabelPainter(); + } + + double get textScaleFactor => _textScaleFactor; + double _textScaleFactor; + set textScaleFactor(double value) { + if (value == _textScaleFactor) { + return; + } + _textScaleFactor = value; + _updateLabelPainter(); + } + + Size get screenSize => _screenSize; + Size _screenSize; + set screenSize(Size value) { + if (value == _screenSize) { + return; + } + _screenSize = value; + markNeedsPaint(); + } + + ValueChanged? get onChanged => _onChanged; + ValueChanged? _onChanged; + set onChanged(ValueChanged? value) { + if (value == _onChanged) { + return; + } + final bool wasInteractive = isInteractive; + _onChanged = value; + if (wasInteractive != isInteractive) { + if (isInteractive) { + _state.enableController.forward(); + } else { + _state.enableController.reverse(); + } + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + } + + ValueChanged? onChangeStart; + ValueChanged? onChangeEnd; + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + _updateLabelPainter(); + } + + /// True if this slider has the input focus. + bool get hasFocus => _hasFocus; + bool _hasFocus; + set hasFocus(bool value) { + if (value == _hasFocus) { + return; + } + _hasFocus = value; + _updateForFocus(_hasFocus); + markNeedsSemanticsUpdate(); + } + + /// True if this slider is being hovered over by a pointer. + bool get hovering => _hovering; + bool _hovering; + set hovering(bool value) { + if (value == _hovering) { + return; + } + _hovering = value; + _updateForHover(_hovering); + } + + /// True if the slider is interactive and the slider thumb is being + /// hovered over by a pointer. + bool _hoveringThumb = false; + bool get hoveringThumb => _hoveringThumb; + set hoveringThumb(bool value) { + if (value == _hoveringThumb) { + return; + } + _hoveringThumb = value; + _updateForHover(_hovering); + } + + SliderInteraction _allowedInteraction; + SliderInteraction get allowedInteraction => _allowedInteraction; + set allowedInteraction(SliderInteraction value) { + if (value == _allowedInteraction) { + return; + } + _allowedInteraction = value; + markNeedsSemanticsUpdate(); + } + + void _updateForFocus(bool focused) { + if (focused) { + _state.overlayController.forward(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.forward(); + } + } else { + _state.overlayController.reverse(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.reverse(); + } + } + } + + void _updateForHover(bool hovered) { + // Only show overlay when pointer is hovering the thumb. + if (hovered && hoveringThumb) { + _state.overlayController.forward(); + } else { + // Only remove overlay when Slider is inactive and unfocused. + if (!_active && !hasFocus) { + _state.overlayController.reverse(); + } + } + } + + bool get shouldAlwaysShowValueIndicator => + _sliderTheme.showValueIndicator == ShowValueIndicator.alwaysVisible; + bool get shouldShowValueIndicatorWhenDragged => + switch (_sliderTheme.showValueIndicator!) { + ShowValueIndicator.onlyForDiscrete => isDiscrete, + ShowValueIndicator.onlyForContinuous => !isDiscrete, + // ignore: deprecated_member_use + ShowValueIndicator.always || ShowValueIndicator.onDrag => true, + ShowValueIndicator.never || ShowValueIndicator.alwaysVisible => false, + }; + + double get _adjustmentUnit { + switch (_platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // Matches iOS implementation of material slider. + return 0.1; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Matches Android implementation of material slider. + return 0.05; + } + } + + void _updateLabelPainter() { + if (label != null) { + _labelPainter + ..text = TextSpan( + style: _sliderTheme.valueIndicatorTextStyle, + text: label, + ) + ..textDirection = textDirection + // ignore: deprecated_member_use + ..textScaleFactor = textScaleFactor + ..layout(); + } else { + _labelPainter.text = null; + } + // Changing the textDirection can result in the layout changing, because the + // bidi algorithm might line up the glyphs differently which can result in + // different ligatures, different shapes, etc. So we always markNeedsLayout. + markNeedsLayout(); + } + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + _labelPainter.markNeedsLayout(); + _updateLabelPainter(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _overlayAnimation.addListener(markNeedsPaint); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _enableAnimation.addListener(markNeedsPaint); + _state.positionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _overlayAnimation.removeListener(markNeedsPaint); + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _enableAnimation.removeListener(markNeedsPaint); + _state.positionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void dispose() { + _drag.dispose(); + _tap.dispose(); + _labelPainter.dispose(); + _enableAnimation.dispose(); + _valueIndicatorAnimation.dispose(); + _overlayAnimation.dispose(); + super.dispose(); + } + + double _getValueFromVisualPosition(double visualPosition) { + return switch (textDirection) { + TextDirection.rtl => 1.0 - visualPosition, + TextDirection.ltr => visualPosition, + }; + } + + double _getValueFromGlobalPosition(Offset globalPosition) { + final double visualPosition = + (_trackRect.bottom - globalToLocal(globalPosition).dy) / + _trackRect.height; + return _getValueFromVisualPosition(visualPosition); + } + + double _discretize(double value) { + double result = clampDouble(value, 0.0, 1.0); + if (isDiscrete) { + result = (result * divisions!).round() / divisions!; + } + return result; + } + + void _startInteraction(Offset globalPosition) { + if (!_state.mounted) { + return; + } + if (!_active && isInteractive) { + switch (allowedInteraction) { + case SliderInteraction.tapAndSlide: + case SliderInteraction.tapOnly: + _active = true; + _currentDragValue = _getValueFromGlobalPosition(globalPosition); + case SliderInteraction.slideThumb: + if (_isPointerOnOverlay(globalPosition)) { + _active = true; + _currentDragValue = value; + } + case SliderInteraction.slideOnly: + _active = true; + _currentDragValue = value; + } + + if (_active) { + // We supply the *current* value as the start location, so that if we have + // a tap, it consists of a call to onChangeStart with the previous value and + // a call to onChangeEnd with the new value. + onChangeStart?.call(_discretize(value)); + onChanged!(_discretize(_currentDragValue)); + _state.overlayController.forward(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.forward(); + _state.interactionTimer?.cancel(); + _state.interactionTimer = Timer( + _minimumInteractionTime * timeDilation, + () { + _state.interactionTimer = null; + if (!_active && _state.valueIndicatorController.isCompleted) { + _state.valueIndicatorController.reverse(); + } + }, + ); + } + } + } + } + + void _endInteraction() { + if (!_state.mounted) { + return; + } + + if (_active && _state.mounted) { + onChangeEnd?.call(_discretize(_currentDragValue)); + _active = false; + _currentDragValue = 0.0; + _state.overlayController.reverse(); + if (shouldShowValueIndicatorWhenDragged && + _state.interactionTimer == null) { + _state.valueIndicatorController.reverse(); + } + } + } + + void _handleDragStart(DragStartDetails details) { + _startInteraction(details.globalPosition); + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_state.mounted) { + return; + } + + switch (allowedInteraction) { + case SliderInteraction.tapAndSlide: + case SliderInteraction.slideOnly: + case SliderInteraction.slideThumb: + if (_active && isInteractive) { + final double valueDelta = details.primaryDelta! / _trackRect.height; + _currentDragValue -= valueDelta; + onChanged!(_discretize(_currentDragValue)); + } + case SliderInteraction.tapOnly: + // cannot slide (drag) as its tapOnly. + break; + } + } + + void _handleDragEnd(DragEndDetails details) { + _endInteraction(); + } + + void _handleTapDown(TapDownDetails details) { + _startInteraction(details.globalPosition); + } + + void _handleTapUp(TapUpDetails details) { + _endInteraction(); + } + + bool _isPointerOnOverlay(Offset globalPosition) { + return overlayRect!.contains(globalToLocal(globalPosition)); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + if (!_state.mounted) { + return; + } + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent && isInteractive) { + // We need to add the drag first so that it has priority. + _drag.addPointer(event); + _tap.addPointer(event); + } + if (isInteractive && overlayRect != null) { + hoveringThumb = overlayRect!.contains(event.localPosition); + } + } + + @override + double computeMinIntrinsicWidth(double height) => + _minPreferredTrackWidth + _maxSliderPartWidth; + + @override + double computeMaxIntrinsicWidth(double height) => + _minPreferredTrackWidth + _maxSliderPartWidth; + + @override + double computeMinIntrinsicHeight(double width) => + math.max(_minPreferredTrackHeight, _maxSliderPartHeight); + + @override + double computeMaxIntrinsicHeight(double width) => + math.max(_minPreferredTrackHeight, _maxSliderPartHeight); + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return Size( + constraints.hasBoundedWidth + ? constraints.maxWidth + : _minPreferredTrackWidth + _maxSliderPartWidth, + constraints.hasBoundedHeight + ? constraints.maxHeight + : math.max(_minPreferredTrackHeight, _maxSliderPartHeight), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final double controllerValue = _state.positionController.value; + + // The visual position is the position of the thumb from 0 to 1 from left + // to right. In left to right, this is the same as the value, but it is + // reversed for right to left text. + final ( + double visualPosition, + double? secondaryVisualPosition, + ) = switch (textDirection) { + TextDirection.rtl when _secondaryTrackValue == null => ( + 1.0 - controllerValue, + null, + ), + TextDirection.rtl => (1.0 - controllerValue, 1.0 - _secondaryTrackValue!), + TextDirection.ltr => (controllerValue, _secondaryTrackValue), + }; + + final Rect trackRect = _sliderTheme.trackShape!.getPreferredRect( + parentBox: this, + offset: offset, + sliderTheme: _sliderTheme, + isDiscrete: isDiscrete, + ); + final double padding = _sliderTheme.trackShape!.isRounded + ? trackRect.width + : 0.0; + final double thumbPosition = isDiscrete + ? trackRect.left + + visualPosition * (trackRect.width - padding) + + padding / 2 + : trackRect.bottom - visualPosition * trackRect.height; + // Apply padding to trackRect.left and trackRect.right if the track height is + // greater than the thumb radius to ensure the thumb is drawn within the track. + final Size thumbPreferredSize = _sliderTheme.thumbShape!.getPreferredSize( + isInteractive, + isDiscrete, + ); + final double thumbPadding = (padding > thumbPreferredSize.width / 2 + ? padding / 2 + : 0); + final thumbCenter = Offset( + trackRect.center.dx, + clampDouble( + thumbPosition, + trackRect.top + thumbPadding, + trackRect.bottom - thumbPadding, + ), + ); + if (isInteractive) { + final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize( + isInteractive, + false, + ); + overlayRect = Rect.fromCircle( + center: thumbCenter, + radius: overlaySize.width / 2.0, + ); + } + final Offset? secondaryOffset = (secondaryVisualPosition != null) + ? Offset( + trackRect.left + secondaryVisualPosition * trackRect.width, + trackRect.center.dy, + ) + : null; + + // If [Slider.year2023] is false, the thumb uses handle thumb shape and gapped track shape. + // The handle width and track gap are adjusted when the thumb is pressed. + double? thumbWidth = _sliderTheme.thumbSize + ?.resolve({}) + ?.width; + final double? thumbHeight = _sliderTheme.thumbSize + ?.resolve({}) + ?.height; + double? trackGap = _sliderTheme.trackGap; + final double? pressedThumbWidth = _sliderTheme.thumbSize?.resolve( + { + WidgetState.pressed, + }, + )?.width; + final double delta; + if (_active && + thumbWidth != null && + pressedThumbWidth != null && + trackGap != null) { + delta = thumbWidth - pressedThumbWidth; + if (thumbWidth > 0.0) { + thumbWidth = pressedThumbWidth; + } + if (trackGap > 0.0) { + trackGap = trackGap - delta / 2; + } + } + + _sliderTheme.trackShape!.paint( + context, + offset, + parentBox: this, + sliderTheme: _sliderTheme.copyWith(trackGap: trackGap), + enableAnimation: _enableAnimation, + textDirection: _textDirection, + thumbCenter: thumbCenter, + secondaryOffset: secondaryOffset, + isDiscrete: isDiscrete, + isEnabled: isInteractive, + ); + + if (!_overlayAnimation.isDismissed) { + _sliderTheme.overlayShape!.paint( + context, + thumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: _textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + } + + if (isDiscrete) { + final double tickMarkWidth = _sliderTheme.tickMarkShape! + .getPreferredSize(isEnabled: isInteractive, sliderTheme: _sliderTheme) + .width; + final double discreteTrackPadding = trackRect.height; + final double adjustedTrackWidth = trackRect.width - discreteTrackPadding; + // If the tick marks would be too dense, don't bother painting them. + if (adjustedTrackWidth / divisions! >= 3.0 * tickMarkWidth) { + final double dy = trackRect.center.dy; + for (var i = 0; i <= divisions!; i++) { + final double value = i / divisions!; + // The ticks are mapped to be within the track, so the tick mark width + // must be subtracted from the track width. + final double dx = + trackRect.left + + value * adjustedTrackWidth + + discreteTrackPadding / 2; + final tickMarkOffset = Offset(dx, dy); + _sliderTheme.tickMarkShape!.paint( + context, + tickMarkOffset, + parentBox: this, + sliderTheme: _sliderTheme, + enableAnimation: _enableAnimation, + textDirection: _textDirection, + thumbCenter: thumbCenter, + isEnabled: isInteractive, + ); + } + } + } + + if (isInteractive && label != null) { + if ((shouldShowValueIndicatorWhenDragged && + !_valueIndicatorAnimation.isDismissed) || + shouldAlwaysShowValueIndicator) { + _state.paintValueIndicator = (PaintingContext context, Offset offset) { + if (attached && _labelPainter.text != null) { + _sliderTheme.valueIndicatorShape!.paint( + context, + offset + thumbCenter, + activationAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation(1) + : _valueIndicatorAnimation, + enableAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation(1) + : _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + } + }; + } + } + + _sliderTheme.thumbShape!.paint( + context, + thumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: thumbWidth != null && thumbHeight != null + ? _sliderTheme.copyWith( + thumbSize: WidgetStatePropertyAll( + Size(thumbWidth, thumbHeight), + ), + ) + : _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + + // The Slider widget has its own Focus widget with semantics information, + // and we want that semantics node to collect the semantics information here + // so that it's all in the same node: otherwise Talkback sees that the node + // has focusable children, and it won't focus the Slider's Focus widget + // because it thinks the Focus widget's node doesn't have anything to say + // (which it doesn't, but this child does). Aggregating the semantic + // information into one node means that Talkback will recognize that it has + // something to say and focus it when it receives keyboard focus. + // (See https://github.com/flutter/flutter/issues/57038 for context). + config + ..isSemanticBoundary = false + ..isEnabled = isInteractive + ..textDirection = textDirection; + if (isInteractive) { + config + ..onIncrease = increaseAction + ..onDecrease = decreaseAction; + } + + if (semanticFormatterCallback != null) { + config + ..value = semanticFormatterCallback!(_state._lerp(value)) + ..increasedValue = semanticFormatterCallback!( + _state._lerp(clampDouble(value + _semanticActionUnit, 0.0, 1.0)), + ) + ..decreasedValue = semanticFormatterCallback!( + _state._lerp(clampDouble(value - _semanticActionUnit, 0.0, 1.0)), + ); + } else { + config + ..value = '${(value * 100).round()}%' + ..increasedValue = + '${(clampDouble(value + _semanticActionUnit, 0.0, 1.0) * 100).round()}%' + ..decreasedValue = + '${(clampDouble(value - _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; + } + } + + double get _semanticActionUnit => + divisions != null ? 1.0 / divisions! : _adjustmentUnit; + + void increaseAction() { + if (isInteractive) { + onChangeStart!(currentValue); + final double increase = increaseValue(); + onChanged!(increase); + onChangeEnd!(increase); + if (!_state.mounted) { + return; + } + } + } + + void decreaseAction() { + if (isInteractive) { + onChangeStart!(currentValue); + final double decrease = decreaseValue(); + onChanged!(decrease); + onChangeEnd!(decrease); + if (!_state.mounted) { + return; + } + } + } + + double get currentValue { + return clampDouble(value, 0.0, 1.0); + } + + double increaseValue() { + return clampDouble(value + _semanticActionUnit, 0.0, 1.0); + } + + double decreaseValue() { + return clampDouble(value - _semanticActionUnit, 0.0, 1.0); + } +} + +class _AdjustSliderIntent extends Intent { + const _AdjustSliderIntent({required this.type}); + + const _AdjustSliderIntent.right() : type = _SliderAdjustmentType.right; + + const _AdjustSliderIntent.left() : type = _SliderAdjustmentType.left; + + const _AdjustSliderIntent.up() : type = _SliderAdjustmentType.up; + + const _AdjustSliderIntent.down() : type = _SliderAdjustmentType.down; + + final _SliderAdjustmentType type; +} + +enum _SliderAdjustmentType { right, left, up, down } + +class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { + const _ValueIndicatorRenderObjectWidget({required this.state}); + + final _VerticalSliderState state; + + @override + _RenderValueIndicator createRenderObject(BuildContext context) { + return _RenderValueIndicator(state: state); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderValueIndicator renderObject, + ) { + renderObject._state = state; + } +} + +class _RenderValueIndicator extends RenderBox + with RelayoutWhenSystemFontsChangeMixin { + _RenderValueIndicator({required _VerticalSliderState state}) + : _state = state { + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + } + late CurvedAnimation _valueIndicatorAnimation; + _VerticalSliderState _state; + + @override + bool get sizedByParent => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _state.positionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _state.positionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _state.paintValueIndicator?.call(context, offset); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.smallest; + } + + @override + void dispose() { + _valueIndicatorAnimation.dispose(); + super.dispose(); + } +} + +class _SliderDefaultsM2 extends SliderThemeData { + _SliderDefaultsM2(this.context) : super(trackHeight: 4.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final SliderThemeData sliderTheme = SliderTheme.of(context); + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.primary.withValues(alpha: 0.24); + + @override + Color? get secondaryActiveTrackColor => + _colors.primary.withValues(alpha: 0.54); + + @override + Color? get disabledActiveTrackColor => + _colors.onSurface.withValues(alpha: 0.32); + + @override + Color? get disabledInactiveTrackColor => + _colors.onSurface.withValues(alpha: 0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => + _colors.onSurface.withValues(alpha: 0.12); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withValues(alpha: 0.54); + + @override + Color? get inactiveTickMarkColor => _colors.primary.withValues(alpha: 0.54); + + @override + Color? get disabledActiveTickMarkColor => + _colors.onPrimary.withValues(alpha: 0.12); + + @override + Color? get disabledInactiveTickMarkColor => + _colors.onSurface.withValues(alpha: 0.12); + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => Color.alphaBlend( + _colors.onSurface.withValues(alpha: .38), + _colors.surface, + ); + + @override + Color? get overlayColor => _colors.primary.withValues(alpha: 0.12); + + @override + TextStyle? get valueIndicatorTextStyle => + Theme.of(context).textTheme.bodyLarge!.copyWith(color: _colors.onPrimary); + + @override + Color? get valueIndicatorColor { + if (sliderTheme.valueIndicatorShape + is RoundedRectSliderValueIndicatorShape) { + return _colors.inverseSurface; + } + return _colors.primary; + } + + @override + SliderComponentShape? get valueIndicatorShape => + const RectangularSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const RoundSliderThumbShape(); + + @override + SliderTrackShape? get trackShape => const RoundedRectSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(); +} + +class _SliderDefaultsM3Year2023 extends SliderThemeData { + _SliderDefaultsM3Year2023(this.context) : super(trackHeight: 4.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.surfaceContainerHighest; + + @override + Color? get secondaryActiveTrackColor => + _colors.primary.withValues(alpha: 0.54); + + @override + Color? get disabledActiveTrackColor => + _colors.onSurface.withValues(alpha: 0.38); + + @override + Color? get disabledInactiveTrackColor => + _colors.onSurface.withValues(alpha: 0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => + _colors.onSurface.withValues(alpha: 0.12); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withValues(alpha: 0.38); + + @override + Color? get inactiveTickMarkColor => + _colors.onSurfaceVariant.withValues(alpha: 0.38); + + @override + Color? get disabledActiveTickMarkColor => + _colors.onSurface.withValues(alpha: 0.38); + + @override + Color? get disabledInactiveTickMarkColor => + _colors.onSurface.withValues(alpha: 0.38); + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => Color.alphaBlend( + _colors.onSurface.withValues(alpha: 0.38), + _colors.surface, + ); + + @override + Color? get overlayColor => + WidgetStateColor.resolveWith((Set states) { + if (states.contains(WidgetState.dragged)) { + return _colors.primary.withValues(alpha: 0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withValues(alpha: 0.1); + } + + return Colors.transparent; + }); + + @override + TextStyle? get valueIndicatorTextStyle => Theme.of( + context, + ).textTheme.labelMedium!.copyWith(color: _colors.onPrimary); + + @override + Color? get valueIndicatorColor => _colors.primary; + + @override + SliderComponentShape? get valueIndicatorShape => + const DropSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const RoundSliderThumbShape(); + + @override + SliderTrackShape? get trackShape => const RoundedRectSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Slider + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// dart format off +class _SliderDefaultsM3 extends SliderThemeData { + _SliderDefaultsM3(this.context) + : super(trackHeight: 16.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.secondaryContainer; + + @override + Color? get secondaryActiveTrackColor => _colors.primary.withValues(alpha: 0.54); + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withValues(alpha: 0.38); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withValues(alpha: 0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withValues(alpha: 0.38); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withValues(alpha: 1.0); + + @override + Color? get inactiveTickMarkColor => _colors.onSecondaryContainer.withValues(alpha: 1.0); + + @override + Color? get disabledActiveTickMarkColor => _colors.onInverseSurface; + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface; + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => _colors.onSurface.withValues(alpha: 0.38); + + @override + Color? get overlayColor => WidgetStateColor.resolveWith((Set states) { + if (states.contains(WidgetState.dragged)) { + return _colors.primary.withValues(alpha: 0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withValues(alpha: 0.1); + } + + return Colors.transparent; + }); + + @override + TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelLarge!.copyWith( + color: _colors.onInverseSurface, + ); + + @override + Color? get valueIndicatorColor => _colors.inverseSurface; + + @override + SliderComponentShape? get valueIndicatorShape => const RoundedRectSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const HandleThumbShape(); + + @override + SliderTrackShape? get trackShape => const GappedSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(tickMarkRadius: 4.0 / 2); + + @override + WidgetStateProperty? get thumbSize { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return const Size(4.0, 44.0); + } + if (states.contains(WidgetState.hovered)) { + return const Size(4.0, 44.0); + } + if (states.contains(WidgetState.focused)) { + return const Size(2.0, 44.0); + } + if (states.contains(WidgetState.pressed)) { + return const Size(2.0, 44.0); + } + return const Size(4.0, 44.0); + }); + } + + @override + double? get trackGap => 6.0; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Slider + +class RoundedRectSliderTrackShape extends SliderTrackShape { + /// Create a slider track that draws two rectangles with rounded outer edges. + const RoundedRectSliderTrackShape(); + + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + // final double thumbHeight = sliderTheme.thumbShape! + // .getPreferredSize(isEnabled, isDiscrete) + // .height; + final double overlayHight = sliderTheme.overlayShape! + .getPreferredSize(isEnabled, isDiscrete) + .height; + double trackWidth = sliderTheme.trackHeight!; + assert(overlayHight >= 0); + assert(trackWidth >= 0); + + // If the track colors are transparent, then override only the track height + // to maintain overall Slider width. + if (sliderTheme.activeTrackColor == Colors.transparent && + sliderTheme.inactiveTrackColor == Colors.transparent) { + trackWidth = 0; + } + + final double trackLeft = + offset.dx + (parentBox.size.width - trackWidth) / 2; + final double trackTop = offset.dy + 10; + // padding + // (sliderTheme.padding == null + // ? math.max(overlayHight / 2, thumbHeight / 2) + // : 0); + final double trackRight = trackLeft + trackWidth; + final double trackBottom = trackTop + parentBox.size.height - 20; + // (sliderTheme.padding == null ? math.max(thumbHeight, overlayHight) : 0); + // If the parentBox's size less than slider's size the trackRight will be less than trackLeft, so switch them. + return Rect.fromLTRB( + trackLeft, + math.min(trackTop, trackBottom), + trackRight, + math.max(trackTop, trackBottom), + ); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 2, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.thumbShape != null); + // If the slider [SliderThemeData.trackHeight] is less than or equal to 0, + // then it makes no difference whether the track is painted or not, + // therefore the painting can be a no-op. + if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) { + return; + } + + // Assign the track segment paints, which are leading: active and + // trailing: inactive. + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final activePaint = Paint() + ..color = activeTrackColorTween.evaluate(enableAnimation)!; + final inactivePaint = Paint() + ..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + final (Paint leftTrackPaint, Paint rightTrackPaint) = ( + activePaint, + inactivePaint, + ); + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final trackRadius = Radius.circular(trackRect.height / 2); + final activeTrackRadius = Radius.circular( + (trackRect.height + additionalActiveTrackHeight) / 2, + ); + + final bool drawInactiveTrack = + thumbCenter.dy > (trackRect.top - (sliderTheme.trackHeight! / 2)); + if (drawInactiveTrack) { + // Draw the inactive track segment. + context.canvas.drawRRect( + RRect.fromLTRBR( + trackRect.left, + trackRect.top, + trackRect.right, + thumbCenter.dy - (sliderTheme.trackHeight! / 2), + trackRadius, + ), + rightTrackPaint, + ); + } + final bool drawActiveTrack = + thumbCenter.dy < (trackRect.bottom - (sliderTheme.trackHeight! / 2)); + if (drawActiveTrack) { + // Draw the active track segment. + context.canvas.drawRRect( + RRect.fromLTRBR( + trackRect.left, + thumbCenter.dy + (sliderTheme.trackHeight! / 2), + trackRect.right, + trackRect.bottom, + activeTrackRadius, + ), + leftTrackPaint, + ); + } + + // final bool showSecondaryTrack = + // (secondaryOffset != null) && (secondaryOffset.dx > thumbCenter.dx); + + // if (showSecondaryTrack) { + // final secondaryTrackColorTween = ColorTween( + // begin: sliderTheme.disabledSecondaryActiveTrackColor, + // end: sliderTheme.secondaryActiveTrackColor, + // ); + // final secondaryTrackPaint = Paint() + // ..color = secondaryTrackColorTween.evaluate(enableAnimation)!; + + // context.canvas.drawRRect( + // RRect.fromLTRBAndCorners( + // thumbCenter.dx, + // trackRect.top, + // secondaryOffset.dx, + // trackRect.bottom, + // topRight: trackRadius, + // bottomRight: trackRadius, + // ), + // secondaryTrackPaint, + // ); + // } + } + + @override + bool get isRounded => true; +} diff --git a/lib/pages/audio/controller.dart b/lib/pages/audio/controller.dart index 35f445a6f..0d11ae1cb 100644 --- a/lib/pages/audio/controller.dart +++ b/lib/pages/audio/controller.dart @@ -23,6 +23,7 @@ import 'package:PiliPlus/pages/sponsor_block/block_mixin.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_mixin.dart'; import 'package:PiliPlus/pages/video/pay_coins/view.dart'; +import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; import 'package:PiliPlus/services/service_locator.dart'; @@ -36,6 +37,8 @@ import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/share_utils.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/video_utils.dart'; @@ -94,6 +97,34 @@ class AudioController extends GetxController ListOrder order = ListOrder.ORDER_NORMAL; + double? _lastVolume; + late final RxDouble desktopVolume = RxDouble(Pref.desktopVolume); + + void toggleVolume() { + if (_lastVolume == null) { + _lastVolume = desktopVolume.value; + setVolume(0, clearLastVolme: false); + } else { + setVolume(_lastVolume!); + } + } + + void setVolume(double volume, {bool clearLastVolme = true}) { + if (clearLastVolme) { + _lastVolume = null; + } + desktopVolume.value = volume; + player?.setVolume(volume * 100); + } + + void syncVolume([_]) { + final volume = desktopVolume.value; + PlPlayerController.instance + ?..volume.value = volume + ..videoPlayerController?.setVolume(volume * 100); + GStorage.setting.put(SettingBoxKey.desktopVolume, volume.toPrecision(3)); + } + @override void onInit() { super.onInit(); @@ -296,7 +327,13 @@ class AudioController extends GetxController if (_hasInit) return; _hasInit = true; assert(player == null, _subscriptions = null); - player = await Player.create(); + player = await Player.create( + configuration: PlatformUtils.isDesktop + ? PlayerConfiguration( + options: {'volume': (desktopVolume.value * 100).toString()}, + ) + : const PlayerConfiguration(), + ); if (isClosed) { player!.dispose(); player = null; diff --git a/lib/pages/audio/view.dart b/lib/pages/audio/view.dart index 432c8e10f..00ecad8e8 100644 --- a/lib/pages/audio/view.dart +++ b/lib/pages/audio/view.dart @@ -13,6 +13,7 @@ import 'package:PiliPlus/grpc/bilibili/app/listener/v1.pb.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/pages/audio/controller.dart'; +import 'package:PiliPlus/pages/audio/volume_button.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart'; import 'package:PiliPlus/services/shutdown_timer_service.dart'; @@ -29,6 +30,7 @@ import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart' hide DraggableScrollableSheet; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; @@ -795,6 +797,15 @@ class _AudioPageState extends State { ], ); } + if (kDebugMode || PlatformUtils.isDesktop) { + child = Row( + spacing: 10, + children: [ + Expanded(child: child), + VolumeButton(controller: _controller), + ], + ); + } return child; } diff --git a/lib/pages/audio/volume_button.dart b/lib/pages/audio/volume_button.dart new file mode 100644 index 000000000..d422b15d1 --- /dev/null +++ b/lib/pages/audio/volume_button.dart @@ -0,0 +1,243 @@ +import 'dart:async' show Timer; +import 'dart:math' as math; + +import 'package:PiliPlus/common/widgets/flutter/vertical_slider.dart'; +import 'package:PiliPlus/pages/audio/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderProxyBox, BoxHitTestResult; +import 'package:get/get.dart'; + +class VolumeButton extends StatefulWidget { + const VolumeButton({ + super.key, + required this.controller, + }); + + final AudioController controller; + + @override + State createState() => _VolumeButtonState(); +} + +class _VolumeButtonState extends State { + final _controller = OverlayPortalController(); + Timer? _timer; + late CardThemeData cardTheme; + late ColorScheme theme; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + theme = ColorScheme.of(context); + cardTheme = CardTheme.of(context); + } + + void _stopTimer([_]) { + _timer?.cancel(); + _timer = null; + } + + void _show([_]) { + _stopTimer(); + _controller.show(); + } + + void _scheduleDismiss([_]) { + _timer ??= Timer(const Duration(milliseconds: 100), () { + _controller.hide(); + _timer = null; + }); + } + + @override + void dispose() { + _stopTimer(); + if (_controller.isShowing) { + _controller.hide(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: _show, + onExit: _scheduleDismiss, + cursor: SystemMouseCursors.click, + child: OverlayPortal.overlayChildLayoutBuilder( + controller: _controller, + overlayChildBuilder: _overlayChildBuilder, + child: Obx(() { + final volume = widget.controller.desktopVolume.value; + return InkWell( + onTapUp: _onTapUp, + customBorder: const CircleBorder(), + child: Padding( + padding: const .all(10.0), + child: Icon( + volume == 0.0 + ? Icons.volume_off + : volume < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: theme.onSurfaceVariant, + size: 22.0, + ), + ), + ); + }), + ), + ); + } + + Widget _overlayChildBuilder( + BuildContext context, + OverlayChildLayoutInfo info, + ) { + final offset = MatrixUtils.transformPoint( + info.childPaintTransform, + info.childSize.topCenter(const Offset(0, -6)), + ); + return _volumeSlider(offset); + } + + Widget _volumeSlider(Offset offset) { + return _VolumeWidget( + offset: offset, + child: MouseRegion( + onEnter: _stopTimer, + onExit: _scheduleDismiss, + child: Container( + padding: const .fromLTRB(6, 8, 6, 2), + decoration: BoxDecoration( + color: ElevationOverlay.applySurfaceTint( + cardTheme.color ?? theme.surfaceContainerLow, + cardTheme.surfaceTintColor, + 2, + ), + borderRadius: const .all(.circular(6)), + ), + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 4, + overlayColor: Colors.transparent, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + ), + child: Obx( + () { + final volume = widget.controller.desktopVolume.value; + return Column( + spacing: 2, + mainAxisSize: .min, + children: [ + Text( + '${(volume * 100).round()}', + style: const TextStyle(fontSize: 13), + ), + Expanded( + child: VerticalSlider( + year2023: true, + min: 0.0, + max: 2.0, + value: volume, + showValueIndicator: .never, + onChanged: widget.controller.setVolume, + onChangeEnd: widget.controller.syncVolume, + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + } + + void _onTapUp(TapUpDetails details) { + switch (details.kind) { + case .mouse: + widget.controller.toggleVolume(); + case _: + _showVolumeDialog(); + } + } + + void _showVolumeDialog() { + final renderBox = context.findRenderObject() as RenderBox; + final offset = renderBox.localToGlobal( + renderBox.size.topCenter(const Offset(0, -6)), + ); + Get.key.currentState!.push( + DialogRoute( + context: context, + useSafeArea: false, + barrierColor: Colors.transparent, + builder: (context) { + return _volumeSlider(offset); + }, + ), + ); + } +} + +class _VolumeWidget extends SingleChildRenderObjectWidget { + const _VolumeWidget({ + required this.offset, + required Widget super.child, + }); + + final Offset offset; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderVolumeWidget(offset: offset); + } +} + +class _RenderVolumeWidget extends RenderProxyBox { + _RenderVolumeWidget({required this.offset}); + + final Offset offset; + late Offset _offset; + + @override + void performLayout() { + final childSize = + (child!..layout( + const BoxConstraints(maxWidth: 40, maxHeight: 170), + parentUsesSize: true, + )) + .size; + size = constraints.biggest; + _offset = Offset( + math.min(offset.dx - (childSize.width / 2), size.width - childSize.width), + math.min(offset.dy, size.height) - childSize.height, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, _offset); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + return result.addWithPaintOffset( + offset: _offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - _offset); + return child!.hitTest(result, position: transformed); + }, + ); + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + transform.translateByDouble(_offset.dx, _offset.dy, 0.0, 1.0); + } +}