mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-06 02:59:28 +00:00
feat: add PlayingIndicatorIcon widget for animated playback indication (#80)
This commit is contained in:
parent
25be7fda03
commit
bae99292a2
3 changed files with 237 additions and 20 deletions
|
|
@ -22,7 +22,7 @@ import 'package:vaani/settings/api_settings_provider.dart'
|
|||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
|
||||
class UserLoginWidget extends HookConsumerWidget {
|
||||
UserLoginWidget({
|
||||
const UserLoginWidget({
|
||||
super.key,
|
||||
required this.server,
|
||||
this.onSuccess,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/features/player/providers/currently_playing_provider.dart';
|
||||
import 'package:vaani/features/player/view/player_when_expanded.dart';
|
||||
import 'package:vaani/main.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
import 'package:vaani/shared/hooks.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart'
|
||||
show audiobookPlayerProvider;
|
||||
import 'package:vaani/features/player/providers/currently_playing_provider.dart'
|
||||
show currentPlayingChapterProvider, currentlyPlayingBookProvider;
|
||||
import 'package:vaani/features/player/view/player_when_expanded.dart'
|
||||
show pendingPlayerModals;
|
||||
import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart';
|
||||
import 'package:vaani/main.dart' show appLogger;
|
||||
import 'package:vaani/shared/extensions/chapter.dart' show ChapterDuration;
|
||||
import 'package:vaani/shared/extensions/duration_format.dart'
|
||||
show DurationFormat;
|
||||
import 'package:vaani/shared/hooks.dart' show useTimer;
|
||||
|
||||
class ChapterSelectionButton extends HookConsumerWidget {
|
||||
const ChapterSelectionButton({
|
||||
|
|
@ -67,6 +72,7 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
|||
|
||||
useTimer(scrollToCurrentChapter, 500.ms);
|
||||
// useInterval(scrollToCurrentChapter, 500.ms);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
|
|
@ -81,24 +87,41 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
|||
child: currentBook?.chapters == null
|
||||
? const Text('No chapters found')
|
||||
: Column(
|
||||
children: [
|
||||
for (final chapter in currentBook!.chapters)
|
||||
ListTile(
|
||||
title: Text(chapter.title),
|
||||
trailing: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
),
|
||||
selected: currentChapterIndex == chapter.id,
|
||||
key: currentChapterIndex == chapter.id
|
||||
? chapterKey
|
||||
children: currentBook!.chapters.map(
|
||||
(chapter) {
|
||||
final isCurrent = currentChapterIndex == chapter.id;
|
||||
final isPlayed = currentChapterIndex != null &&
|
||||
chapter.id < currentChapterIndex;
|
||||
return ListTile(
|
||||
autofocus: isCurrent,
|
||||
iconColor: isPlayed && !isCurrent
|
||||
? theme.disabledColor
|
||||
: null,
|
||||
title: Text(
|
||||
chapter.title,
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: isCurrent
|
||||
? const PlayingIndicatorIcon()
|
||||
: const Icon(Icons.play_arrow),
|
||||
selected: isCurrent,
|
||||
key: isCurrent ? chapterKey : null,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
notifier.seek(chapter.start + 90.ms);
|
||||
notifier.play();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
194
lib/features/player/view/widgets/playing_indicator_icon.dart
Normal file
194
lib/features/player/view/widgets/playing_indicator_icon.dart
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
/// An icon that animates like audio equalizer bars to indicate playback.
|
||||
///
|
||||
/// Creates multiple vertical bars that independently animate their height
|
||||
/// in a looping, visually dynamic pattern.
|
||||
class PlayingIndicatorIcon extends StatefulWidget {
|
||||
/// The number of vertical bars in the indicator.
|
||||
final int barCount;
|
||||
|
||||
/// The total width and height of the icon area.
|
||||
final double size;
|
||||
|
||||
/// The color of the bars. Defaults to the current [IconTheme] color.
|
||||
final Color? color;
|
||||
|
||||
/// The minimum height factor for a bar (relative to [size]).
|
||||
/// When [centerSymmetric] is true, this represents the minimum height
|
||||
/// extending from the center line (so total minimum height is 2 * minHeightFactor * size).
|
||||
/// When false, it's the minimum height from the bottom.
|
||||
final double minHeightFactor;
|
||||
|
||||
/// The maximum height factor for a bar (relative to [size]).
|
||||
/// When [centerSymmetric] is true, this represents the maximum height
|
||||
/// extending from the center line (so total maximum height is 2 * maxHeightFactor * size).
|
||||
/// When false, it's the maximum height from the bottom.
|
||||
final double maxHeightFactor;
|
||||
|
||||
/// Base duration for a full up/down animation cycle for a single bar.
|
||||
/// Actual duration will vary slightly per bar.
|
||||
final Duration baseCycleDuration;
|
||||
|
||||
/// If true, the bars animate symmetrically expanding/collapsing from the
|
||||
/// horizontal center line. If false (default), they expand/collapse from
|
||||
/// the bottom edge.
|
||||
final bool centerSymmetric;
|
||||
|
||||
const PlayingIndicatorIcon({
|
||||
super.key,
|
||||
this.barCount = 4,
|
||||
this.size = 20.0,
|
||||
this.color,
|
||||
this.minHeightFactor = 0.2,
|
||||
this.maxHeightFactor = 1.0,
|
||||
this.baseCycleDuration = const Duration(milliseconds: 350),
|
||||
this.centerSymmetric = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayingIndicatorIcon> createState() => _PlayingIndicatorIconState();
|
||||
}
|
||||
|
||||
class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
||||
late List<_BarAnimationParams> _animationParams;
|
||||
final _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationParams =
|
||||
List.generate(widget.barCount, _createRandomParams, growable: false);
|
||||
}
|
||||
|
||||
// Helper to generate random parameters for one bar's animation cycle
|
||||
_BarAnimationParams _createRandomParams(int index) {
|
||||
final duration1 =
|
||||
(widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4));
|
||||
final duration2 =
|
||||
(widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4));
|
||||
|
||||
// Note: These factors represent the scale relative to the *half-height*
|
||||
// if centerSymmetric is true, controlled by the alignment in scaleY.
|
||||
final targetHeightFactor1 = widget.minHeightFactor +
|
||||
_random.nextDouble() *
|
||||
(widget.maxHeightFactor - widget.minHeightFactor);
|
||||
final targetHeightFactor2 = widget.minHeightFactor +
|
||||
_random.nextDouble() *
|
||||
(widget.maxHeightFactor - widget.minHeightFactor);
|
||||
|
||||
// --- Random initial delay ---
|
||||
final initialDelay =
|
||||
(_random.nextDouble() * (widget.baseCycleDuration.inMilliseconds / 4))
|
||||
.ms;
|
||||
|
||||
return _BarAnimationParams(
|
||||
duration1: duration1,
|
||||
duration2: duration2,
|
||||
targetHeightFactor1: targetHeightFactor1,
|
||||
targetHeightFactor2: targetHeightFactor2,
|
||||
initialDelay: initialDelay,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.color ??
|
||||
IconTheme.of(context).color ??
|
||||
Theme.of(context).colorScheme.primary;
|
||||
|
||||
// --- Bar geometry calculation ---
|
||||
final double totalSpacing = widget.size * 0.2;
|
||||
// Ensure at least 1px spacing if size is very small
|
||||
final double barSpacing = max(1.0, totalSpacing / (widget.barCount + 1));
|
||||
final double availableWidthForBars =
|
||||
widget.size - (barSpacing * (widget.barCount + 1));
|
||||
final double barWidth = max(1.0, availableWidthForBars / widget.barCount);
|
||||
// Max height remains the full size potential for the container
|
||||
final double maxHeight = widget.size;
|
||||
|
||||
// Determine the alignment for scaling based on the symmetric flag
|
||||
final Alignment scaleAlignment =
|
||||
widget.centerSymmetric ? Alignment.center : Alignment.bottomCenter;
|
||||
|
||||
// Determine the cross axis alignment for the Row
|
||||
final CrossAxisAlignment rowAlignment = widget.centerSymmetric
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.end;
|
||||
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
// Clip ensures bars don't draw outside the SizedBox bounds
|
||||
// especially important for center alignment if maxFactor > 0.5
|
||||
child: ClipRect(
|
||||
child: Row(
|
||||
// Use calculated alignment
|
||||
crossAxisAlignment: rowAlignment,
|
||||
// Use spaceEvenly for better distribution, especially with center alignment
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
widget.barCount,
|
||||
(index) {
|
||||
final params = _animationParams[index];
|
||||
// The actual bar widget that will be animated
|
||||
return Container(
|
||||
width: barWidth,
|
||||
// Set initial height to the max potential height
|
||||
// The scaleY animation will control the visible height
|
||||
height: maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(barWidth / 2),
|
||||
),
|
||||
)
|
||||
.animate(
|
||||
delay: params.initialDelay,
|
||||
onPlay: (controller) => controller.repeat(
|
||||
reverse: true,
|
||||
),
|
||||
)
|
||||
// 1. Scale to targetHeightFactor1
|
||||
.scaleY(
|
||||
begin:
|
||||
widget.minHeightFactor, // Scale factor starts near min
|
||||
end: params.targetHeightFactor1,
|
||||
duration: params.duration1,
|
||||
curve: Curves.easeInOutCirc,
|
||||
alignment: scaleAlignment, // Apply chosen alignment
|
||||
)
|
||||
// 2. Then scale to targetHeightFactor2
|
||||
.then()
|
||||
.scaleY(
|
||||
end: params.targetHeightFactor2,
|
||||
duration: params.duration2,
|
||||
curve: Curves.easeInOutCirc,
|
||||
alignment: scaleAlignment, // Apply chosen alignment
|
||||
);
|
||||
},
|
||||
growable: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class: Renamed height fields for clarity
|
||||
class _BarAnimationParams {
|
||||
final Duration duration1;
|
||||
final Duration duration2;
|
||||
final double targetHeightFactor1; // Factor relative to total size
|
||||
final double targetHeightFactor2; // Factor relative to total size
|
||||
final Duration initialDelay;
|
||||
|
||||
_BarAnimationParams({
|
||||
required this.duration1,
|
||||
required this.duration2,
|
||||
required this.targetHeightFactor1,
|
||||
required this.targetHeightFactor2,
|
||||
required this.initialDelay,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue