mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-25 04:19:30 +00:00
player seek and chapter change
This commit is contained in:
parent
01b3dead49
commit
d01855c218
17 changed files with 1721 additions and 305 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:audio_video_progress_bar/audio_video_progress_bar.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
|
@ -9,31 +10,20 @@ import 'package:whispering_pages/api/library_item_provider.dart';
|
|||
import 'package:whispering_pages/features/player/providers/audiobook_player.dart';
|
||||
import 'package:whispering_pages/features/player/providers/currently_playing_provider.dart';
|
||||
import 'package:whispering_pages/features/player/providers/player_form.dart';
|
||||
import 'package:whispering_pages/settings/app_settings_provider.dart';
|
||||
import 'package:whispering_pages/shared/extensions/inverse_lerp.dart';
|
||||
import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
|
||||
import 'package:whispering_pages/theme/theme_from_cover_provider.dart';
|
||||
|
||||
import 'player_when_expanded.dart';
|
||||
import 'player_when_minimized.dart';
|
||||
|
||||
double valueFromPercentageInRange({
|
||||
required final double min,
|
||||
max,
|
||||
percentage,
|
||||
}) {
|
||||
return percentage * (max - min) + min;
|
||||
}
|
||||
|
||||
double percentageFromValueInRange({required final double min, max, value}) {
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
|
||||
|
||||
class AudiobookPlayer extends HookConsumerWidget {
|
||||
const AudiobookPlayer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final currentBook = ref.watch(currentlyPlayingBookProvider);
|
||||
if (currentBook == null) {
|
||||
return const SizedBox.shrink();
|
||||
|
|
@ -67,11 +57,8 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
}
|
||||
});
|
||||
|
||||
final playPauseButton = AudiobookPlayerPlayPauseButton(
|
||||
playPauseController: playPauseController,
|
||||
);
|
||||
|
||||
const progressBar = AudiobookTotalProgressBar();
|
||||
const chapterProgressBar = AudiobookChapterProgressBar();
|
||||
|
||||
// theme from image
|
||||
final imageTheme = ref.watch(
|
||||
|
|
@ -84,14 +71,25 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
// max height of the player is the height of the screen
|
||||
final playerMaxHeight = MediaQuery.of(context).size.height;
|
||||
|
||||
final availWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
// the image width when the player is expanded
|
||||
final maxImgSize = availWidth * 0.9;
|
||||
|
||||
final preferredVolume = appSettings.playerSettings.preferredVolume;
|
||||
return Theme(
|
||||
data: ThemeData(
|
||||
colorScheme: imageTheme.valueOrNull ?? Theme.of(context).colorScheme,
|
||||
),
|
||||
child: Miniplayer(
|
||||
valueNotifier: ref.watch(playerExpandProgressNotifierProvider),
|
||||
onDragDown: (percentage) async {
|
||||
// preferred volume
|
||||
// set volume to 0 when dragging down
|
||||
await player.setVolume(preferredVolume * (1 - percentage));
|
||||
},
|
||||
minHeight: playerMinHeight,
|
||||
// subtract the height of notches and other system UI
|
||||
maxHeight: playerMaxHeight,
|
||||
controller: ref.watch(miniplayerControllerProvider),
|
||||
elevation: 4,
|
||||
|
|
@ -102,81 +100,47 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
builder: (height, percentage) {
|
||||
// return SafeArea(
|
||||
// child: Text(
|
||||
// 'percentage: ${percentage.toStringAsFixed(2)}, height: ${height.toStringAsFixed(2)}',
|
||||
// 'percentage: ${percentage.toStringAsFixed(2)}, height: ${height.toStringAsFixed(2)} volume: ${player.volume.toStringAsFixed(2)}',
|
||||
// ),
|
||||
// );
|
||||
|
||||
// at what point should the player switch from miniplayer to expanded player
|
||||
// at this point the image should be at its max size and in the center of the player
|
||||
final miniplayerPercentageDeclaration =
|
||||
(maxImgSize - playerMinHeight) /
|
||||
(playerMaxHeight - playerMinHeight);
|
||||
final bool isFormMiniplayer =
|
||||
percentage < miniplayerPercentageDeclaration;
|
||||
final double availWidth = MediaQuery.of(context).size.width;
|
||||
final maxImgSize = availWidth * 0.4;
|
||||
|
||||
final bookTitle = Text(player.book?.metadata.title ?? '');
|
||||
|
||||
//Declare additional widgets (eg. SkipButton) and variables
|
||||
|
||||
if (!isFormMiniplayer) {
|
||||
var percentageExpandedPlayer = percentageFromValueInRange(
|
||||
min: playerMaxHeight * miniplayerPercentageDeclaration +
|
||||
playerMinHeight,
|
||||
max: playerMaxHeight,
|
||||
value: height,
|
||||
);
|
||||
if (percentageExpandedPlayer < 0) percentageExpandedPlayer = 0;
|
||||
final paddingVertical = valueFromPercentageInRange(
|
||||
min: 0,
|
||||
max: 16,
|
||||
percentage: percentageExpandedPlayer,
|
||||
);
|
||||
final double heightWithoutPadding = height - paddingVertical * 2;
|
||||
final double imageSize = heightWithoutPadding > maxImgSize
|
||||
? maxImgSize
|
||||
: heightWithoutPadding;
|
||||
final paddingLeft = valueFromPercentageInRange(
|
||||
min: 0,
|
||||
max: availWidth - imageSize,
|
||||
percentage: percentageExpandedPlayer,
|
||||
) /
|
||||
2;
|
||||
|
||||
const buttonSkipForward = IconButton(
|
||||
icon: Icon(Icons.forward_30),
|
||||
iconSize: 33,
|
||||
onPressed: onTap,
|
||||
);
|
||||
const buttonSkipBackwards = IconButton(
|
||||
icon: Icon(Icons.replay_10),
|
||||
iconSize: 33,
|
||||
onPressed: onTap,
|
||||
);
|
||||
// this calculation needs a refactor
|
||||
var percentageExpandedPlayer = percentage
|
||||
.inverseLerp(
|
||||
miniplayerPercentageDeclaration,
|
||||
1,
|
||||
)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return PlayerWhenExpanded(
|
||||
imgPaddingLeft: paddingLeft,
|
||||
imgPaddingVertical: paddingVertical,
|
||||
imageSize: imageSize,
|
||||
imageSize: maxImgSize,
|
||||
img: imgWidget,
|
||||
percentageExpandedPlayer: percentageExpandedPlayer,
|
||||
text: bookTitle,
|
||||
buttonSkipBackwards: buttonSkipBackwards,
|
||||
playPauseButton: playPauseButton,
|
||||
buttonSkipForward: buttonSkipForward,
|
||||
progressIndicator: progressBar,
|
||||
playPauseController: playPauseController,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//Miniplayer
|
||||
final percentageMiniplayer = percentageFromValueInRange(
|
||||
min: playerMinHeight,
|
||||
max: playerMaxHeight * miniplayerPercentageDeclaration +
|
||||
playerMinHeight,
|
||||
value: height,
|
||||
final percentageMiniplayer = percentage.inverseLerp(
|
||||
0,
|
||||
miniplayerPercentageDeclaration,
|
||||
);
|
||||
|
||||
final elementOpacity = 1 - 1 * percentageMiniplayer;
|
||||
|
||||
|
||||
return PlayerWhenMinimized(
|
||||
maxImgSize: maxImgSize,
|
||||
availWidth: availWidth,
|
||||
imgWidget: imgWidget,
|
||||
elementOpacity: elementOpacity,
|
||||
playPauseButton: playPauseButton,
|
||||
progressIndicator: progressBar,
|
||||
playPauseController: playPauseController,
|
||||
percentageMiniplayer: percentageMiniplayer,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -188,32 +152,37 @@ class AudiobookPlayerPlayPauseButton extends HookConsumerWidget {
|
|||
const AudiobookPlayerPlayPauseButton({
|
||||
super.key,
|
||||
required this.playPauseController,
|
||||
this.iconSize = 48.0,
|
||||
});
|
||||
|
||||
final double iconSize;
|
||||
final AnimationController playPauseController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
|
||||
return switch (player.processingState) {
|
||||
ProcessingState.loading ||
|
||||
ProcessingState.buffering =>
|
||||
const CircularProgressIndicator(),
|
||||
ProcessingState.loading || ProcessingState.buffering => const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
ProcessingState.completed => IconButton(
|
||||
onPressed: () async {
|
||||
await player.seek(const Duration(seconds: 0));
|
||||
await player.play();
|
||||
},
|
||||
icon: const Icon(Icons.replay),
|
||||
icon: const Icon(
|
||||
Icons.replay,
|
||||
),
|
||||
),
|
||||
ProcessingState.ready => IconButton(
|
||||
onPressed: () async {
|
||||
await player.togglePlayPause();
|
||||
},
|
||||
iconSize: iconSize,
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: playPauseController,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
ProcessingState.idle => const SizedBox.shrink(),
|
||||
|
|
@ -227,56 +196,130 @@ class AudiobookPlayerPlayPauseButton extends HookConsumerWidget {
|
|||
class AudiobookTotalProgressBar extends HookConsumerWidget {
|
||||
const AudiobookTotalProgressBar({
|
||||
super.key,
|
||||
this.barHeight = 5.0,
|
||||
this.barCapShape = BarCapShape.round,
|
||||
this.thumbRadius = 10.0,
|
||||
this.thumbGlowRadius = 30.0,
|
||||
this.thumbCanPaintOutsideBar = true,
|
||||
this.timeLabelLocation,
|
||||
this.timeLabelType,
|
||||
this.timeLabelTextStyle,
|
||||
this.timeLabelPadding = 0.0,
|
||||
});
|
||||
|
||||
final double barHeight;
|
||||
final BarCapShape barCapShape;
|
||||
final double thumbRadius;
|
||||
final double thumbGlowRadius;
|
||||
final bool thumbCanPaintOutsideBar;
|
||||
final TimeLabelLocation? timeLabelLocation;
|
||||
final TimeLabelType? timeLabelType;
|
||||
final TextStyle? timeLabelTextStyle;
|
||||
final double timeLabelPadding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
final position = useStream(
|
||||
player.positionStream,
|
||||
initialData: const Duration(seconds: 0),
|
||||
);
|
||||
final buffered = useStream(
|
||||
player.bufferedPositionStream,
|
||||
initialData: const Duration(seconds: 0),
|
||||
);
|
||||
final currentIndex = useStream(
|
||||
player.currentIndexStream,
|
||||
initialData: 0,
|
||||
);
|
||||
var durationOfPreviousTracks =
|
||||
player.book?.tracks.sublist(0, currentIndex.data).fold(
|
||||
const Duration(seconds: 0),
|
||||
(previousValue, element) => previousValue + element.duration,
|
||||
) ??
|
||||
const Duration(seconds: 0);
|
||||
final totalProgress = durationOfPreviousTracks +
|
||||
(position.data ?? const Duration(seconds: 0));
|
||||
final totalBuffered = durationOfPreviousTracks +
|
||||
(buffered.data ?? const Duration(seconds: 0));
|
||||
|
||||
return ProgressBar(
|
||||
progress: totalProgress,
|
||||
total: player.book?.duration ?? const Duration(seconds: 0),
|
||||
onSeek: player.seek,
|
||||
buffered: totalBuffered,
|
||||
bufferedBarColor: Theme.of(context).colorScheme.secondary,
|
||||
thumbRadius: thumbRadius,
|
||||
thumbGlowRadius: thumbGlowRadius,
|
||||
thumbCanPaintOutsideBar: thumbCanPaintOutsideBar,
|
||||
barHeight: barHeight,
|
||||
barCapShape: barCapShape,
|
||||
timeLabelLocation: timeLabelLocation,
|
||||
timeLabelType: timeLabelType,
|
||||
timeLabelTextStyle: timeLabelTextStyle,
|
||||
timeLabelPadding: timeLabelPadding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AudiobookChapterProgressBar extends HookConsumerWidget {
|
||||
const AudiobookChapterProgressBar({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
// final playerState = useState(player.processingState);
|
||||
// add a listener to the player state
|
||||
// player.processingStateStream.listen((state) {
|
||||
// playerState.value = state;
|
||||
// });
|
||||
return StreamBuilder(
|
||||
stream: player.currentIndexStream,
|
||||
builder: (context, currentTrackIndex) {
|
||||
return StreamBuilder(
|
||||
stream: player.positionStream,
|
||||
builder: (context, progress) {
|
||||
// totalProgress is the sum of the duration of all the tracks before the current track + the current track position
|
||||
final totalProgress =
|
||||
player.book?.tracks.sublist(0, currentTrackIndex.data).fold(
|
||||
const Duration(seconds: 0),
|
||||
(previousValue, element) =>
|
||||
previousValue + element.duration,
|
||||
) ??
|
||||
const Duration(seconds: 0) +
|
||||
(progress.data ?? const Duration(seconds: 0));
|
||||
final position = useStream(
|
||||
player.positionStream,
|
||||
initialData: const Duration(seconds: 0),
|
||||
);
|
||||
final buffered = useStream(
|
||||
player.bufferedPositionStream,
|
||||
initialData: const Duration(seconds: 0),
|
||||
);
|
||||
final currentIndex = useStream(
|
||||
player.currentIndexStream,
|
||||
initialData: 0,
|
||||
);
|
||||
final durationOfPreviousTracks =
|
||||
player.book?.tracks.sublist(0, currentIndex.data).fold(
|
||||
const Duration(seconds: 0),
|
||||
(previousValue, element) => previousValue + element.duration,
|
||||
) ??
|
||||
const Duration(seconds: 0);
|
||||
final totalProgress = durationOfPreviousTracks +
|
||||
(position.data ?? const Duration(seconds: 0));
|
||||
final totalBuffered = durationOfPreviousTracks +
|
||||
(buffered.data ?? const Duration(seconds: 0));
|
||||
// now find the chapter that corresponds to the current time
|
||||
// and calculate the progress of the current chapter
|
||||
final currentChapter = player.book?.chapters.firstWhereOrNull(
|
||||
(element) =>
|
||||
(element.start <= totalProgress) && (element.end >= totalProgress),
|
||||
);
|
||||
final currentChapterProgress =
|
||||
currentChapter == null ? null : (totalProgress - currentChapter.start);
|
||||
|
||||
return StreamBuilder(
|
||||
stream: player.bufferedPositionStream,
|
||||
builder: (context, buffered) {
|
||||
final totalBuffered =
|
||||
player.book?.tracks.sublist(0, currentTrackIndex.data).fold(
|
||||
const Duration(seconds: 0),
|
||||
(previousValue, element) =>
|
||||
previousValue + element.duration,
|
||||
) ??
|
||||
const Duration(seconds: 0) +
|
||||
(buffered.data ?? const Duration(seconds: 0));
|
||||
return ProgressBar(
|
||||
progress: totalProgress,
|
||||
total: player.book?.duration ?? const Duration(seconds: 0),
|
||||
onSeek: player.seek,
|
||||
thumbRadius: 8,
|
||||
buffered: totalBuffered,
|
||||
bufferedBarColor: Theme.of(context).colorScheme.secondary,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
final currentChapterBuffered =
|
||||
currentChapter == null ? null : (totalBuffered - currentChapter.start);
|
||||
|
||||
return ProgressBar(
|
||||
progress: currentChapterProgress ?? totalProgress,
|
||||
total: currentChapter == null
|
||||
? player.book?.duration ?? const Duration(seconds: 0)
|
||||
: currentChapter.end - currentChapter.start,
|
||||
// ! TODO add onSeek
|
||||
onSeek: (duration) {
|
||||
player.seek(
|
||||
duration + (currentChapter?.start ?? const Duration(seconds: 0)),
|
||||
);
|
||||
},
|
||||
thumbRadius: 8,
|
||||
buffered: currentChapterBuffered ?? totalBuffered,
|
||||
bufferedBarColor: Theme.of(context).colorScheme.secondary,
|
||||
timeLabelType: TimeLabelType.remainingTime,
|
||||
timeLabelLocation: TimeLabelLocation.below,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +1,257 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:miniplayer/miniplayer.dart';
|
||||
import 'package:whispering_pages/constants/sizes.dart';
|
||||
import 'package:whispering_pages/features/player/providers/audiobook_player.dart';
|
||||
import 'package:whispering_pages/features/player/providers/currently_playing_provider.dart';
|
||||
import 'package:whispering_pages/features/player/providers/player_form.dart';
|
||||
import 'package:whispering_pages/features/player/view/audiobook_player.dart';
|
||||
import 'package:whispering_pages/shared/extensions/inverse_lerp.dart';
|
||||
|
||||
class PlayerWhenExpanded extends StatelessWidget {
|
||||
class PlayerWhenExpanded extends HookConsumerWidget {
|
||||
const PlayerWhenExpanded({
|
||||
super.key,
|
||||
required this.imgPaddingLeft,
|
||||
required this.imgPaddingVertical,
|
||||
required this.imageSize,
|
||||
required this.img,
|
||||
required this.percentageExpandedPlayer,
|
||||
required this.text,
|
||||
required this.buttonSkipBackwards,
|
||||
required this.playPauseButton,
|
||||
required this.buttonSkipForward,
|
||||
required this.progressIndicator,
|
||||
required this.playPauseController,
|
||||
});
|
||||
|
||||
/// padding values control the position of the image
|
||||
final double imgPaddingLeft;
|
||||
final double imgPaddingVertical;
|
||||
final double imageSize;
|
||||
final Widget img;
|
||||
final double percentageExpandedPlayer;
|
||||
final Text text;
|
||||
final IconButton buttonSkipBackwards;
|
||||
final Widget playPauseButton;
|
||||
final IconButton buttonSkipForward;
|
||||
final Widget progressIndicator;
|
||||
final AnimationController playPauseController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// all the properties that help in building the widget are calculated from the [percentageExpandedPlayer]
|
||||
/// however, some properties need to start later than 0% and end before 100%
|
||||
const lateStart = 0.4;
|
||||
const earlyEnd = 1;
|
||||
final earlyPercentage = percentageExpandedPlayer
|
||||
.inverseLerp(
|
||||
lateStart,
|
||||
earlyEnd,
|
||||
)
|
||||
.clamp(0.0, 1.0);
|
||||
final currentBook = ref.watch(currentlyPlayingBookProvider);
|
||||
final currentChapter = ref.watch(currentPlayingChapterProvider);
|
||||
final currentBookMetadata = ref.watch(currentBookMetadataProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: imgPaddingLeft,
|
||||
top: imgPaddingVertical,
|
||||
// bottom: paddingVertical,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: imageSize,
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
child: img,
|
||||
// sized box for system status bar
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.top * earlyPercentage,
|
||||
),
|
||||
// a row with a down arrow to minimize the player, a pill shaped container to drag the player, and a cast button
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 100 * earlyPercentage,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 8.0 * earlyPercentage),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// the down arrow
|
||||
IconButton(
|
||||
iconSize: 30,
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: () {
|
||||
// minimize the player
|
||||
ref.read(miniplayerControllerProvider).animateToHeight(
|
||||
state: PanelState.MIN,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// the pill shaped container
|
||||
// SizedBox(
|
||||
// height: 6,
|
||||
// width: 32,
|
||||
// child: Container(
|
||||
// decoration: BoxDecoration(
|
||||
// color: Theme.of(context).colorScheme.secondary,
|
||||
// borderRadius: BorderRadius.circular(32),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// the cast button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cast),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 33),
|
||||
child: Opacity(
|
||||
opacity: percentageExpandedPlayer,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Flexible(child: text),
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
buttonSkipBackwards,
|
||||
playPauseButton,
|
||||
buttonSkipForward,
|
||||
],
|
||||
),
|
||||
// the image
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 8.0 * earlyPercentage),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
// add a shadow to the image elevation hovering effect
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
blurRadius: 32 * earlyPercentage,
|
||||
spreadRadius: 8 * earlyPercentage,
|
||||
// offset: Offset(0, 16 * earlyPercentage),
|
||||
),
|
||||
Flexible(child: progressIndicator),
|
||||
],
|
||||
),
|
||||
child: SizedBox(
|
||||
height: imageSize,
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppElementSizes.borderRadiusRegular * earlyPercentage,
|
||||
),
|
||||
child: img,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// the chapter title
|
||||
currentChapter == null
|
||||
? const SizedBox()
|
||||
: Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: AppElementSizes.paddingRegular * 4 * earlyPercentage,
|
||||
// horizontal: 16.0,
|
||||
),
|
||||
// child: SizedBox(
|
||||
// same as the image width
|
||||
// width: imageSize,
|
||||
child: Text(
|
||||
currentChapter.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// ),
|
||||
),
|
||||
),
|
||||
|
||||
// the book name and author
|
||||
Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: AppElementSizes.paddingRegular * earlyPercentage,
|
||||
// horizontal: 16.0,
|
||||
),
|
||||
// child: SizedBox(
|
||||
// same as the image width
|
||||
// width: imageSize,
|
||||
child: Text(
|
||||
[
|
||||
currentBookMetadata?.title ?? '',
|
||||
currentBookMetadata?.authorName ?? '',
|
||||
].join(' - '),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onBackground
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// ),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
// the progress bar
|
||||
Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: SizedBox(
|
||||
width: imageSize,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: AppElementSizes.paddingRegular * earlyPercentage,
|
||||
left: AppElementSizes.paddingRegular * earlyPercentage,
|
||||
right: AppElementSizes.paddingRegular * earlyPercentage,
|
||||
),
|
||||
child: const AudiobookChapterProgressBar(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// the chapter skip buttons, seek 30 seconds back and forward, and play/pause button
|
||||
Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: SizedBox(
|
||||
width: imageSize,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// previous chapter
|
||||
const AudiobookPlayerSeekChapterButton(isForward: false),
|
||||
// buttonSkipBackwards
|
||||
const AudiobookPlayerSeekButton(isForward: false),
|
||||
AudiobookPlayerPlayPauseButton(
|
||||
playPauseController: playPauseController,
|
||||
),
|
||||
// buttonSkipForwards
|
||||
const AudiobookPlayerSeekButton(isForward: true),
|
||||
// next chapter
|
||||
const AudiobookPlayerSeekChapterButton(isForward: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// speed control, sleep timer, chapter list, and settings
|
||||
Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: AppElementSizes.paddingRegular * 4 * earlyPercentage,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// speed control
|
||||
IconButton(
|
||||
icon: const Icon(Icons.speed),
|
||||
onPressed: () {},
|
||||
),
|
||||
// sleep timer
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timer),
|
||||
onPressed: () {},
|
||||
),
|
||||
// chapter list
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu_book_rounded),
|
||||
onPressed: () {},
|
||||
),
|
||||
// settings
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -77,3 +259,73 @@ class PlayerWhenExpanded extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AudiobookPlayerSeekButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerSeekButton({
|
||||
super.key,
|
||||
required this.isForward,
|
||||
});
|
||||
|
||||
/// if true, the button seeks forward, else it seeks backwards
|
||||
final bool isForward;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
isForward ? Icons.forward_30 : Icons.replay_30,
|
||||
size: AppElementSizes.iconSizeSmall,
|
||||
),
|
||||
onPressed: () {
|
||||
if (isForward) {
|
||||
player.seek(player.position + const Duration(seconds: 30));
|
||||
} else {
|
||||
player.seek(player.position - const Duration(seconds: 30));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AudiobookPlayerSeekChapterButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerSeekChapterButton({
|
||||
super.key,
|
||||
required this.isForward,
|
||||
});
|
||||
|
||||
/// if true, the button seeks forward, else it seeks backwards
|
||||
final bool isForward;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
isForward ? Icons.skip_next : Icons.skip_previous,
|
||||
size: AppElementSizes.iconSizeSmall,
|
||||
),
|
||||
onPressed: () {
|
||||
if (player.book == null) {
|
||||
return;
|
||||
}
|
||||
if (isForward) {
|
||||
player.seek(player.currentChapter!.end);
|
||||
} else {
|
||||
// if player position is less than 5 seconds into the chapter, go to the previous chapter
|
||||
final chapterPosition =
|
||||
player.position - player.currentChapter!.start;
|
||||
if (chapterPosition < const Duration(seconds: 5)) {
|
||||
final index = player.book!.chapters.indexOf(player.currentChapter!);
|
||||
if (index > 0) {
|
||||
player.seek(player.book!.chapters[index - 1].start);
|
||||
}
|
||||
} else {
|
||||
player.seek(player.currentChapter!.start);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,146 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:whispering_pages/constants/sizes.dart';
|
||||
import 'package:whispering_pages/features/player/providers/audiobook_player.dart';
|
||||
import 'package:whispering_pages/features/player/providers/player_form.dart';
|
||||
import 'package:whispering_pages/features/player/providers/currently_playing_provider.dart';
|
||||
import 'package:whispering_pages/features/player/view/audiobook_player.dart';
|
||||
import 'package:whispering_pages/router/router.dart';
|
||||
|
||||
class PlayerWhenMinimized extends HookConsumerWidget {
|
||||
const PlayerWhenMinimized({
|
||||
super.key,
|
||||
required this.availWidth,
|
||||
required this.maxImgSize,
|
||||
required this.imgWidget,
|
||||
required this.elementOpacity,
|
||||
required this.playPauseButton,
|
||||
required this.progressIndicator,
|
||||
required this.playPauseController,
|
||||
required this.percentageMiniplayer,
|
||||
});
|
||||
|
||||
final double availWidth;
|
||||
final double maxImgSize;
|
||||
final Widget imgWidget;
|
||||
final double elementOpacity;
|
||||
final Widget playPauseButton;
|
||||
final Widget progressIndicator;
|
||||
final AnimationController playPauseController;
|
||||
|
||||
/// 0 - 1, from minimized to when switched to expanded player
|
||||
///
|
||||
/// by the time 1 is reached only image should be visible in the center of the widget
|
||||
final double percentageMiniplayer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
final controller = ref.watch(miniplayerControllerProvider);
|
||||
return Column(
|
||||
final vanishingPercentage = 1 - percentageMiniplayer;
|
||||
final progress =
|
||||
useStream(player.positionStream, initialData: Duration.zero);
|
||||
|
||||
final bookMetaExpanded = ref.watch(currentBookMetadataProvider);
|
||||
|
||||
var barHeight = vanishingPercentage * 3;
|
||||
return Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxImgSize),
|
||||
child: imgWidget,
|
||||
Row(
|
||||
children: [
|
||||
// image
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
((availWidth - maxImgSize) / 2) * percentageMiniplayer,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: Opacity(
|
||||
opacity: elementOpacity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
player.book?.metadata.title ?? '',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.copyWith(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
'audioObject.subtitle',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.color!
|
||||
.withOpacity(0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// navigate to item page
|
||||
context.pushNamed(
|
||||
Routes.libraryItem.name,
|
||||
pathParameters: {
|
||||
Routes.libraryItem.pathParamName!:
|
||||
player.book!.libraryItemId,
|
||||
},
|
||||
);
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxImgSize,
|
||||
),
|
||||
child: imgWidget,
|
||||
),
|
||||
),
|
||||
// IconButton(
|
||||
// icon: const Icon(Icons.fullscreen),
|
||||
// onPressed: () {
|
||||
// controller.animateToHeight(state: PanelState.MAX);
|
||||
// },
|
||||
// ),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Opacity(
|
||||
opacity: elementOpacity,
|
||||
child: playPauseButton,
|
||||
),
|
||||
// author and title of the book
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// AutoScrollText(
|
||||
Text(
|
||||
bookMetaExpanded?.title ?? '',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
// velocity:
|
||||
// const Velocity(pixelsPerSecond: Offset(16, 0)),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
bookMetaExpanded?.authorName ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onBackground
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// IconButton(
|
||||
// icon: const Icon(Icons.fullscreen),
|
||||
// onPressed: () {
|
||||
// controller.animateToHeight(state: PanelState.MAX);
|
||||
// },
|
||||
// ),
|
||||
// rewind button
|
||||
Opacity(
|
||||
opacity: vanishingPercentage,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.replay_30,
|
||||
size: AppElementSizes.iconSizeSmall,
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
// play/pause button
|
||||
Opacity(
|
||||
opacity: vanishingPercentage,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: AudiobookPlayerPlayPauseButton(
|
||||
playPauseController: playPauseController,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: barHeight,
|
||||
child: LinearProgressIndicator(
|
||||
value: (progress.data ?? Duration.zero).inSeconds /
|
||||
player.book!.duration.inSeconds,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
// SizedBox(
|
||||
// height: progressIndicatorHeight,
|
||||
// child: Opacity(
|
||||
// opacity: elementOpacity,
|
||||
// child: progressIndicator,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue