mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-06 11:09: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;
|
import 'package:vaani/settings/models/models.dart' as model;
|
||||||
|
|
||||||
class UserLoginWidget extends HookConsumerWidget {
|
class UserLoginWidget extends HookConsumerWidget {
|
||||||
UserLoginWidget({
|
const UserLoginWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.server,
|
required this.server,
|
||||||
this.onSuccess,
|
this.onSuccess,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
import 'package:vaani/features/player/providers/audiobook_player.dart'
|
||||||
import 'package:vaani/features/player/providers/currently_playing_provider.dart';
|
show audiobookPlayerProvider;
|
||||||
import 'package:vaani/features/player/view/player_when_expanded.dart';
|
import 'package:vaani/features/player/providers/currently_playing_provider.dart'
|
||||||
import 'package:vaani/main.dart';
|
show currentPlayingChapterProvider, currentlyPlayingBookProvider;
|
||||||
import 'package:vaani/shared/extensions/chapter.dart';
|
import 'package:vaani/features/player/view/player_when_expanded.dart'
|
||||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
show pendingPlayerModals;
|
||||||
import 'package:vaani/shared/hooks.dart';
|
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 {
|
class ChapterSelectionButton extends HookConsumerWidget {
|
||||||
const ChapterSelectionButton({
|
const ChapterSelectionButton({
|
||||||
|
|
@ -67,6 +72,7 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
||||||
|
|
||||||
useTimer(scrollToCurrentChapter, 500.ms);
|
useTimer(scrollToCurrentChapter, 500.ms);
|
||||||
// useInterval(scrollToCurrentChapter, 500.ms);
|
// useInterval(scrollToCurrentChapter, 500.ms);
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
@ -81,24 +87,41 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
||||||
child: currentBook?.chapters == null
|
child: currentBook?.chapters == null
|
||||||
? const Text('No chapters found')
|
? const Text('No chapters found')
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: currentBook!.chapters.map(
|
||||||
for (final chapter in currentBook!.chapters)
|
(chapter) {
|
||||||
ListTile(
|
final isCurrent = currentChapterIndex == chapter.id;
|
||||||
title: Text(chapter.title),
|
final isPlayed = currentChapterIndex != null &&
|
||||||
trailing: Text(
|
chapter.id < currentChapterIndex;
|
||||||
'(${chapter.duration.smartBinaryFormat})',
|
return ListTile(
|
||||||
),
|
autofocus: isCurrent,
|
||||||
selected: currentChapterIndex == chapter.id,
|
iconColor: isPlayed && !isCurrent
|
||||||
key: currentChapterIndex == chapter.id
|
? theme.disabledColor
|
||||||
? chapterKey
|
|
||||||
: null,
|
: 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: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
notifier.seek(chapter.start + 90.ms);
|
notifier.seek(chapter.start + 90.ms);
|
||||||
notifier.play();
|
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