mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
187 lines
5.6 KiB
Dart
187 lines
5.6 KiB
Dart
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// The press response and advancing skip glyphs used by Mornye's transport.
|
|
class MornyePlaybackButton extends StatefulWidget {
|
|
const MornyePlaybackButton({
|
|
super.key,
|
|
required this.icon,
|
|
required this.tooltip,
|
|
required this.color,
|
|
required this.onPressed,
|
|
this.iconSize = 40,
|
|
this.padding = const EdgeInsets.all(8),
|
|
this.loading = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String tooltip;
|
|
final Color color;
|
|
final VoidCallback? onPressed;
|
|
final double iconSize;
|
|
final EdgeInsetsGeometry padding;
|
|
final bool loading;
|
|
|
|
@override
|
|
State<MornyePlaybackButton> createState() => _MornyePlaybackButtonState();
|
|
}
|
|
|
|
class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
|
|
with TickerProviderStateMixin {
|
|
final _states = WidgetStatesController();
|
|
late final _press = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 100),
|
|
reverseDuration: const Duration(milliseconds: 320),
|
|
);
|
|
late final _skip = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 300),
|
|
);
|
|
|
|
bool get _isSkip =>
|
|
widget.icon == CupertinoIcons.forward_fill ||
|
|
widget.icon == CupertinoIcons.backward_fill;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_states.addListener(_updatePress);
|
|
_press.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed &&
|
|
!_states.value.contains(WidgetState.pressed)) {
|
|
_press.reverse();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _updatePress() {
|
|
if (_states.value.contains(WidgetState.pressed)) {
|
|
_press.forward();
|
|
} else if (_press.isCompleted) {
|
|
_press.reverse();
|
|
}
|
|
// A quick tap still finishes its fade-in before fading back out.
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_states.removeListener(_updatePress);
|
|
_states.dispose();
|
|
_press.dispose();
|
|
_skip.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Widget _skipGlyph(bool reduceMotion) => AnimatedBuilder(
|
|
animation: _skip,
|
|
builder: (context, _) {
|
|
if (!_skip.isAnimating || reduceMotion) return Icon(widget.icon);
|
|
final progress = Curves.easeInOut.transform(_skip.value);
|
|
final size = widget.iconSize;
|
|
Widget triangle(double position, double scale) => Positioned(
|
|
left: size * (position - 0.26),
|
|
top: 0,
|
|
width: size * 0.52,
|
|
height: size,
|
|
child: Transform.scale(
|
|
scale: scale,
|
|
child: const FittedBox(
|
|
fit: BoxFit.fill,
|
|
child: Icon(CupertinoIcons.play_fill),
|
|
),
|
|
),
|
|
);
|
|
return Transform.flip(
|
|
flipX: widget.icon == CupertinoIcons.backward_fill,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
triangle(0.25, progress),
|
|
triangle(0.25 + progress * 0.5, 1),
|
|
triangle(0.75, 1 - progress),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
|
final motion = Duration(milliseconds: reduceMotion ? 120 : 180);
|
|
final enabled = widget.onPressed != null && !widget.loading;
|
|
final button = IconButton(
|
|
statesController: _states,
|
|
tooltip: widget.tooltip,
|
|
color: widget.color,
|
|
iconSize: widget.iconSize,
|
|
padding: widget.padding,
|
|
style: ButtonStyle(
|
|
shape: const WidgetStatePropertyAll(CircleBorder()),
|
|
splashFactory: NoSplash.splashFactory,
|
|
overlayColor: WidgetStateProperty.resolveWith((states) {
|
|
return states.contains(WidgetState.focused) ||
|
|
states.contains(WidgetState.hovered)
|
|
? widget.color.withValues(alpha: 0.08)
|
|
: Colors.transparent;
|
|
}),
|
|
),
|
|
onPressed: enabled
|
|
? () {
|
|
_press.forward();
|
|
if (_isSkip && !reduceMotion) _skip.forward(from: 0);
|
|
widget.onPressed!();
|
|
}
|
|
: null,
|
|
icon: ValueListenableBuilder<Set<WidgetState>>(
|
|
valueListenable: _states,
|
|
builder: (context, states, _) {
|
|
final pressed = states.contains(WidgetState.pressed);
|
|
return AnimatedScale(
|
|
scale: pressed && !reduceMotion ? 0.95 : 1,
|
|
duration: motion,
|
|
curve: Curves.easeOutCubic,
|
|
child: AnimatedOpacity(
|
|
opacity: pressed ? 0.72 : 1,
|
|
duration: motion,
|
|
child: SizedBox.square(
|
|
dimension: widget.iconSize,
|
|
child: AnimatedSwitcher(
|
|
duration: Duration(milliseconds: reduceMotion ? 120 : 200),
|
|
child: widget.loading
|
|
? const Center(
|
|
child: SizedBox.square(
|
|
dimension: 32,
|
|
child: CircularProgressIndicator(strokeWidth: 3),
|
|
),
|
|
)
|
|
: KeyedSubtree(
|
|
key: ValueKey(widget.icon),
|
|
child: _isSkip
|
|
? _skipGlyph(reduceMotion)
|
|
: Icon(widget.icon),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
return AnimatedBuilder(
|
|
animation: _press,
|
|
child: button,
|
|
builder: (context, child) => DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: widget.color.withValues(
|
|
alpha: 0.14 * Curves.easeOutCubic.transform(_press.value),
|
|
),
|
|
),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
}
|