player seek and chapter change

This commit is contained in:
Dr-Blank 2024-05-19 08:53:21 -04:00
parent 01b3dead49
commit d01855c218
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
17 changed files with 1721 additions and 305 deletions

View file

@ -8,6 +8,30 @@ import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
/// returns the sum of the duration of all the previous tracks before the [index]
Duration sumOfTracks(BookExpanded book, int? index) {
// return 0 if index is less than 0
if (index == null || index < 0) {
return Duration.zero;
}
return book.tracks.sublist(0, index).fold<Duration>(Duration.zero,
(previousValue, element) {
return previousValue + element.duration;
});
}
/// returns the [AudioTrack] to play based on the [position] in the [book]
AudioTrack getTrackToPlay(BookExpanded book, Duration position) {
var totalDuration = Duration.zero;
for (var track in book.tracks) {
totalDuration += track.duration;
if (totalDuration >= position) {
return track;
}
}
return book.tracks.last;
}
/// will manage the audio player instance
class AudiobookPlayer extends AudioPlayer {
// constructor which takes in the BookExpanded object
@ -18,6 +42,9 @@ class AudiobookPlayer extends AudioPlayer {
/// the [BookExpanded] being played
BookExpanded? _book;
// /// the [BookExpanded] trying to be played
// BookExpanded? _intended_book;
/// the [BookExpanded] being played
///
/// to set the book, use [setSourceAudioBook]
@ -36,7 +63,12 @@ class AudiobookPlayer extends AudioPlayer {
int? get availableTracks => _book?.tracks.length;
/// sets the current [AudioTrack] as the source of the player
Future<void> setSourceAudioBook(BookExpanded? book) async {
Future<void> setSourceAudioBook(
BookExpanded? book, {
bool preload = true,
// int? initialIndex,
Duration? initialPosition,
}) async {
// if the book is null, stop the player
if (book == null) {
_book = null;
@ -51,7 +83,24 @@ class AudiobookPlayer extends AudioPlayer {
// first stop the player and clear the source
await stop();
_book = book;
// some calculations to set the initial index and position
// initialPosition is of the entire book not just the current track
// hence first we need to calculate the current track which will be used to set the initial position
// then we set the initial index to the current track index and position as the remaining duration from the position
// after subtracting the duration of all the previous tracks
final trackToPlay = getTrackToPlay(book, initialPosition ?? Duration.zero);
final initialIndex = book.tracks.indexOf(trackToPlay);
final initialPositionInTrack = initialPosition != null
? initialPosition - sumOfTracks(book, initialIndex - 1)
: null;
await setAudioSource(
preload: preload,
initialIndex: initialIndex,
initialPosition: initialPositionInTrack,
ConcatenatingAudioSource(
useLazyPreparation: true,
children: book.tracks.map((track) {
@ -73,8 +122,6 @@ class AudiobookPlayer extends AudioPlayer {
).catchError((error) {
debugPrint('Error: $error');
});
_book = book;
}
/// toggles the player between play and pause
@ -84,7 +131,7 @@ class AudiobookPlayer extends AudioPlayer {
throw StateError('No book is set');
}
// ! refactor this
// TODO refactor this
return switch (playerState) {
PlayerState(playing: var isPlaying) => isPlaying ? pause() : play(),
};
@ -93,27 +140,58 @@ class AudiobookPlayer extends AudioPlayer {
/// need to override getDuration and getCurrentPosition to return according to the book instead of the current track
/// this is because the book can be a list of audio files and the player is only aware of the current track
/// so we need to calculate the duration and current position based on the book
// @override
// Future<Duration?> getDuration() async {
// if (_book == null) {
// return null;
// }
// return _book!.tracks.fold<Duration>(
// Duration.zero,
// (previousValue, element) => previousValue + element.duration,
// );
// }
// @override
// Future<Duration?> getCurrentPosition() async {
// if (_book == null) {
// return null;
// }
// var currentTrack = _book!.tracks[_currentIndex];
// var currentTrackDuration = currentTrack.duration;
// var currentTrackPosition = await super.getCurrentPosition();
// return currentTrackPosition != null
// ? currentTrackPosition + currentTrackDuration
// : null;
// }
@override
Future<void> seek(Duration? position, {int? index}) async {
if (_book == null) {
return;
}
if (position == null) {
return;
}
final trackToPlay = getTrackToPlay(_book!, position);
final index = _book!.tracks.indexOf(trackToPlay);
final positionInTrack = position - sumOfTracks(_book!, index - 1);
return super.seek(positionInTrack, index: index);
}
/// streams to override to suit the book instead of the current track
// - positionStream
// - bufferedPositionStream
@override
Stream<Duration> get positionStream {
return super.positionStream.map((position) {
if (_book == null) {
return Duration.zero;
}
return position + sumOfTracks(_book!, sequenceState!.currentIndex);
});
}
@override
Stream<Duration> get bufferedPositionStream {
return super.bufferedPositionStream.map((position) {
if (_book == null) {
return Duration.zero;
}
return position + sumOfTracks(_book!, sequenceState!.currentIndex);
});
}
/// get current chapter
BookChapter? get currentChapter {
if (_book == null) {
return null;
}
return _book!.chapters.firstWhere(
(element) {
return element.start <= position && element.end >= position;
},
orElse: () => _book!.chapters.first,
);
}
}

View file

@ -9,3 +9,30 @@ BookExpanded? currentlyPlayingBook(CurrentlyPlayingBookRef ref) {
final player = ref.watch(audiobookPlayerProvider);
return player.book;
}
/// provided the current chapter of the book being played
@riverpod
BookChapter? currentPlayingChapter(CurrentPlayingChapterRef ref) {
final player = ref.watch(audiobookPlayerProvider);
// get the current timestamp
final currentTimestamp = player.position;
// get the chapter that contains the current timestamp
return player.book?.chapters.firstWhere(
(element) =>
element.start <= currentTimestamp && element.end >= currentTimestamp,
);
}
/// provides the book metadata of the currently playing book
@riverpod
BookMetadataExpanded? currentBookMetadata(CurrentBookMetadataRef ref) {
final player = ref.watch(audiobookPlayerProvider);
if (player.book == null) return null;
return BookMetadataExpanded.fromJson(player.book!.metadata.toJson());
}
// /// volume of the player [0, 1]
// @riverpod
// double currentVolume(CurrentVolumeRef ref) {
// return 1;
// }

View file

@ -23,5 +23,43 @@ final currentlyPlayingBookProvider =
);
typedef CurrentlyPlayingBookRef = AutoDisposeProviderRef<BookExpanded?>;
String _$currentPlayingChapterHash() =>
r'562416b7e0068aaba9138cb8e0ed7a5ddba8e6c6';
/// provided the current chapter of the book being played
///
/// Copied from [currentPlayingChapter].
@ProviderFor(currentPlayingChapter)
final currentPlayingChapterProvider =
AutoDisposeProvider<BookChapter?>.internal(
currentPlayingChapter,
name: r'currentPlayingChapterProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$currentPlayingChapterHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef CurrentPlayingChapterRef = AutoDisposeProviderRef<BookChapter?>;
String _$currentBookMetadataHash() =>
r'02b462a051fce5bcbdad6fdb708b60256fbb588c';
/// provides the book metadata of the currently playing book
///
/// Copied from [currentBookMetadata].
@ProviderFor(currentBookMetadata)
final currentBookMetadataProvider =
AutoDisposeProvider<BookMetadataExpanded?>.internal(
currentBookMetadata,
name: r'currentBookMetadataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$currentBookMetadataHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef CurrentBookMetadataRef = AutoDisposeProviderRef<BookMetadataExpanded?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -10,7 +10,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'player_form.g.dart';
const double playerMinHeight = 70;
const miniplayerPercentageDeclaration = 0.2;
// const miniplayerPercentageDeclaration = 0.2;
extension on Ref {
// We can move the previous logic to a Ref extension.

View file

@ -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,
);
}
}

View file

@ -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);
}
}
},
);
}
}

View file

@ -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,
// ),
// ),
],
);
}