feat: Add miniplayer

This commit is contained in:
Dr-Blank 2024-05-14 10:11:25 -04:00
parent 610d9a2aa0
commit 7f5309d10a
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
25 changed files with 355 additions and 29 deletions

View file

@ -11,6 +11,8 @@
"cSpell.words": [ "cSpell.words": [
"audioplayers", "audioplayers",
"Autovalidate", "Autovalidate",
"fullscreen",
"miniplayer",
"mocktail", "mocktail",
"riverpod", "riverpod",
"shelfsdk", "shelfsdk",

View file

@ -8,15 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk;
import 'package:whispering_pages/api/image_provider.dart'; import 'package:whispering_pages/api/image_provider.dart';
import 'package:whispering_pages/api/library_item_provider.dart'; import 'package:whispering_pages/api/library_item_provider.dart';
import 'package:whispering_pages/extensions/hero_tag_conventions.dart'; import 'package:whispering_pages/constants/hero_tag_conventions.dart';
import 'package:whispering_pages/features/player/providers/audiobook_player_provider.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player_provider.dart';
import 'package:whispering_pages/router/models/library_item_extras.dart'; import 'package:whispering_pages/router/models/library_item_extras.dart';
import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart';
import 'package:whispering_pages/theme/theme_from_cover_provider.dart'; import 'package:whispering_pages/theme/theme_from_cover_provider.dart';
import 'package:whispering_pages/widgets/shelves/book_shelf.dart'; import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
import '../../../widgets/expandable_description.dart'; import '../../../shared/widgets/expandable_description.dart';
import '../../../widgets/library_item_sliver_app_bar.dart'; import 'library_item_sliver_app_bar.dart';
class LibraryItemPage extends HookConsumerWidget { class LibraryItemPage extends HookConsumerWidget {
const LibraryItemPage({ const LibraryItemPage({

View file

@ -10,8 +10,8 @@ import 'package:whispering_pages/api/server_provider.dart';
import 'package:whispering_pages/router/router.dart'; import 'package:whispering_pages/router/router.dart';
import 'package:whispering_pages/settings/api_settings_provider.dart'; import 'package:whispering_pages/settings/api_settings_provider.dart';
import 'package:whispering_pages/settings/models/models.dart' as model; import 'package:whispering_pages/settings/models/models.dart' as model;
import 'package:whispering_pages/widgets/add_new_server.dart'; import 'package:whispering_pages/shared/widgets/add_new_server.dart';
import 'package:whispering_pages/widgets/user_login.dart'; import 'package:whispering_pages/features/onboarding/view/user_login.dart';
class OnboardingSinglePage extends HookConsumerWidget { class OnboardingSinglePage extends HookConsumerWidget {
const OnboardingSinglePage({ const OnboardingSinglePage({

View file

@ -9,7 +9,7 @@ import 'package:shelfsdk/audiobookshelf_api.dart';
/// will manage the audio player instance /// will manage the audio player instance
class AudiobookPlayer extends AudioPlayer { class AudiobookPlayer extends AudioPlayer {
// constructor which takes in the BookExpanded object // constructor which takes in the BookExpanded object
AudiobookPlayer(this.token, this.baseUrl) : super() { AudiobookPlayer(this.token, this.baseUrl, {super.playerId}) : super() {
// set the source of the player to the first track in the book // set the source of the player to the first track in the book
} }
@ -31,12 +31,20 @@ class AudiobookPlayer extends AudioPlayer {
final int _currentIndex = 0; final int _currentIndex = 0;
/// sets the current [AudioTrack] as the source of the player /// sets the current [AudioTrack] as the source of the player
Future<void> setSourceAudioBook(BookExpanded book) async { Future<void> setSourceAudioBook(BookExpanded? book) async {
// if the book is null, stop the player
if (book == null) {
_book = null;
return stop();
}
// see if the book is the same as the current book // see if the book is the same as the current book
if (_book == book) { if (_book == book) {
// if the book is the same, do nothing // if the book is the same, do nothing
return; return;
} }
// first stop the player
await stop();
var track = book.tracks[_currentIndex]; var track = book.tracks[_currentIndex];
var url = '$baseUrl${track.contentUrl}?token=$token'; var url = '$baseUrl${track.contentUrl}?token=$token';
@ -64,9 +72,14 @@ class AudiobookPlayer extends AudioPlayer {
PlayerState.disposed => throw StateError('Player is disposed'), PlayerState.disposed => throw StateError('Player is disposed'),
}; };
} }
/// override resume to set the source if the book is not set
@override
Future<void> resume() async {
if (_book == null) {
throw StateError('No book is set');
}
return super.resume();
}
} }
void main(List<String> args) {
final AudiobookPlayer abPlayer = AudiobookPlayer('', Uri.parse(''));
print(abPlayer.resume());
}

View file

@ -1,6 +1,6 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:whispering_pages/api/api_provider.dart'; import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/features/player/audiobook_payer.dart' as abp; import 'package:whispering_pages/features/player/core/audiobook_payer.dart' as abp;
part 'audiobook_player_provider.g.dart'; part 'audiobook_player_provider.g.dart';
@ -16,15 +16,23 @@ part 'audiobook_player_provider.g.dart';
// return player; // return player;
// } // }
const playerId = 'audiobook_player';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class AudiobookPlayer extends _$AudiobookPlayer { class AudiobookPlayer extends _$AudiobookPlayer {
@override @override
abp.AudiobookPlayer build() { abp.AudiobookPlayer build() {
final api = ref.watch(authenticatedApiProvider); final api = ref.watch(authenticatedApiProvider);
final player = abp.AudiobookPlayer(api.token!, api.baseUrl); final player =
abp.AudiobookPlayer(api.token!, api.baseUrl, playerId: playerId);
ref.onDispose(player.dispose); ref.onDispose(player.dispose);
// bind notify listeners to the player
player.onPlayerStateChanged.listen((_) {
notifyListeners();
});
return player; return player;
} }

View file

@ -6,7 +6,7 @@ part of 'audiobook_player_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$audiobookPlayerHash() => r'8cbadcb264382300e63b3dbaf167a3bea1638a6e'; String _$audiobookPlayerHash() => r'a636d5e8e73dc6bbf7b3f47f83884bb3af3b9370';
/// See also [AudiobookPlayer]. /// See also [AudiobookPlayer].
@ProviderFor(AudiobookPlayer) @ProviderFor(AudiobookPlayer)

View file

@ -0,0 +1,11 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/features/player/providers/audiobook_player_provider.dart';
part 'currently_playing_provider.g.dart';
@riverpod
BookExpanded? currentlyPlayingBook(CurrentlyPlayingBookRef ref) {
final player = ref.watch(audiobookPlayerProvider);
return player.book;
}

View file

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'currently_playing_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$currentlyPlayingBookHash() =>
r'c777ea8b463d8441a0da5e08b4c41b501ce68aad';
/// See also [currentlyPlayingBook].
@ProviderFor(currentlyPlayingBook)
final currentlyPlayingBookProvider =
AutoDisposeProvider<BookExpanded?>.internal(
currentlyPlayingBook,
name: r'currentlyPlayingBookProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$currentlyPlayingBookHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef CurrentlyPlayingBookRef = AutoDisposeProviderRef<BookExpanded?>;
// 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

@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:miniplayer/miniplayer.dart';
import 'package:whispering_pages/api/image_provider.dart';
import 'package:whispering_pages/api/library_item_provider.dart';
import 'package:whispering_pages/features/player/providers/audiobook_player_provider.dart';
import 'package:whispering_pages/features/player/providers/currently_playing_provider.dart';
import 'package:whispering_pages/shared/widgets/shelves/book_shelf.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);
}
const double playerMinHeight = 70;
const double playerMaxHeight = 370;
const miniplayerPercentageDeclaration = 0.2;
final ValueNotifier<double> playerExpandProgress =
ValueNotifier(playerMinHeight);
final MiniplayerController controller = MiniplayerController();
class AudiobookPlayer extends HookConsumerWidget {
const AudiobookPlayer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentBook = ref.watch(currentlyPlayingBookProvider);
if (currentBook == null) {
return const SizedBox.shrink();
}
final item = ref.watch(libraryItemProvider(currentBook.libraryItemId));
final player = ref.watch(audiobookPlayerProvider);
final image = item.valueOrNull != null
? ref.watch(
coverImageProvider(item.valueOrNull!),
)
: null;
final imgWidget = image?.valueOrNull != null
? Image.memory(
image!.valueOrNull!,
fit: BoxFit.cover,
)
: const BookCoverSkeleton();
return Miniplayer(
valueNotifier: playerExpandProgress,
minHeight: playerMinHeight,
maxHeight: playerMaxHeight,
controller: controller,
elevation: 4,
onDismissed: () {
player.setSourceAudioBook(null);
},
curve: Curves.easeOut,
builder: (height, percentage) {
final bool isFormMiniplayer =
percentage < miniplayerPercentageDeclaration;
final double availWidth = MediaQuery.of(context).size.width;
final maxImgSize = availWidth * 0.4;
final text = Text(player.book?.metadata.title ?? '');
const buttonPlay = IconButton(
icon: Icon(Icons.pause),
onPressed: onTap,
);
const progressIndicator = LinearProgressIndicator(value: 0.3);
//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: 10,
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,
);
const buttonPlayExpanded = IconButton(
icon: Icon(Icons.pause_circle_filled),
iconSize: 50,
onPressed: onTap,
);
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
left: paddingLeft,
top: paddingVertical,
bottom: paddingVertical,
),
child: SizedBox(
height: imageSize,
child: imgWidget,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 33),
child: Opacity(
opacity: percentageExpandedPlayer,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Flexible(child: text),
const Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
buttonSkipBackwards,
buttonPlayExpanded,
buttonSkipForward,
],
),
),
const Flexible(child: progressIndicator),
Container(),
Container(),
],
),
),
),
),
],
);
}
//Miniplayer
final percentageMiniplayer = percentageFromValueInRange(
min: playerMinHeight,
max: playerMaxHeight * miniplayerPercentageDeclaration +
playerMinHeight,
value: height,
);
final elementOpacity = 1 - 1 * percentageMiniplayer;
final progressIndicatorHeight = 4 - 4 * percentageMiniplayer;
return Column(
children: [
Expanded(
child: Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxImgSize),
child: imgWidget,
),
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),
),
),
],
),
),
),
),
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
controller.animateToHeight(state: PanelState.MAX);
},
),
Padding(
padding: const EdgeInsets.only(right: 3),
child: Opacity(
opacity: elementOpacity,
child: buttonPlay,
),
),
],
),
),
SizedBox(
height: progressIndicatorHeight,
child: Opacity(
opacity: elementOpacity,
child: progressIndicator,
),
),
],
);
},
);
}
}
void onTap() {}

View file

@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:whispering_pages/api/api_provider.dart'; import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart';
import '../widgets/drawer.dart'; import '../shared/widgets/drawer.dart';
import '../widgets/shelves/home_shelf.dart'; import '../shared/widgets/shelves/home_shelf.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
const HomePage({super.key}); const HomePage({super.key});

View file

@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:whispering_pages/api/api_provider.dart'; import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/settings/api_settings_provider.dart'; import 'package:whispering_pages/settings/api_settings_provider.dart';
import '../widgets/drawer.dart'; import '../shared/widgets/drawer.dart';
import '../widgets/shelves/home_shelf.dart'; import '../shared/widgets/shelves/home_shelf.dart';
// TODO: implement the library page // TODO: implement the library page
class LibraryPage extends HookConsumerWidget { class LibraryPage extends HookConsumerWidget {

View file

@ -5,7 +5,7 @@ import 'package:whispering_pages/api/authenticated_user_provider.dart';
import 'package:whispering_pages/api/server_provider.dart'; import 'package:whispering_pages/api/server_provider.dart';
import 'package:whispering_pages/settings/models/audiobookshelf_server.dart' as model; import 'package:whispering_pages/settings/models/audiobookshelf_server.dart' as model;
import 'package:whispering_pages/settings/api_settings_provider.dart'; import 'package:whispering_pages/settings/api_settings_provider.dart';
import 'package:whispering_pages/widgets/add_new_server.dart'; import 'package:whispering_pages/shared/widgets/add_new_server.dart';
class ServerManagerPage extends HookConsumerWidget { class ServerManagerPage extends HookConsumerWidget {
const ServerManagerPage({ const ServerManagerPage({

View file

@ -4,7 +4,7 @@ import 'package:whispering_pages/pages/app_settings.dart';
import 'package:whispering_pages/pages/home_page.dart'; import 'package:whispering_pages/pages/home_page.dart';
import 'package:whispering_pages/features/item_viewer/view/library_item_page.dart'; import 'package:whispering_pages/features/item_viewer/view/library_item_page.dart';
import 'package:whispering_pages/pages/library_page.dart'; import 'package:whispering_pages/pages/library_page.dart';
import 'package:whispering_pages/pages/onboarding/onboarding_single_page.dart'; import 'package:whispering_pages/features/onboarding/view/onboarding_single_page.dart';
import 'scaffold_with_nav_bar.dart'; import 'scaffold_with_nav_bar.dart';
import 'transitions/slide.dart'; import 'transitions/slide.dart';
@ -28,6 +28,7 @@ class MyAppRouter {
name: Routes.onboarding.name, name: Routes.onboarding.name,
builder: (context, state) => const OnboardingSinglePage(), builder: (context, state) => const OnboardingSinglePage(),
), ),
// The main app shell
StatefulShellRoute.indexedStack( StatefulShellRoute.indexedStack(
builder: ( builder: (
BuildContext context, BuildContext context,

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:whispering_pages/features/player/view/audioobok_player.dart';
/// Builds the "shell" for the app by building a Scaffold with a /// Builds the "shell" for the app by building a Scaffold with a
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold. /// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
class ScaffoldWithNavBar extends StatelessWidget { class ScaffoldWithNavBar extends HookConsumerWidget {
/// Constructs an [ScaffoldWithNavBar]. /// Constructs an [ScaffoldWithNavBar].
const ScaffoldWithNavBar({ const ScaffoldWithNavBar({
required this.navigationShell, required this.navigationShell,
@ -14,9 +16,14 @@ class ScaffoldWithNavBar extends StatelessWidget {
final StatefulNavigationShell navigationShell; final StatefulNavigationShell navigationShell;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
body: navigationShell, body: Stack(
children: [
navigationShell,
const AudiobookPlayer(),
],
),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
elevation: 0.0, elevation: 0.0,
landscapeLayout: BottomNavigationBarLandscapeLayout.centered, landscapeLayout: BottomNavigationBarLandscapeLayout.centered,

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/api/image_provider.dart'; import 'package:whispering_pages/api/image_provider.dart';
import 'package:whispering_pages/widgets/shelves/home_shelf.dart'; import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart';
/// A shelf that displays Authors on the home page /// A shelf that displays Authors on the home page
class AuthorHomeShelf extends HookConsumerWidget { class AuthorHomeShelf extends HookConsumerWidget {

View file

@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:shimmer/shimmer.dart' show Shimmer; import 'package:shimmer/shimmer.dart' show Shimmer;
import 'package:whispering_pages/api/image_provider.dart'; import 'package:whispering_pages/api/image_provider.dart';
import 'package:whispering_pages/extensions/hero_tag_conventions.dart'; import 'package:whispering_pages/constants/hero_tag_conventions.dart';
import 'package:whispering_pages/router/models/library_item_extras.dart'; import 'package:whispering_pages/router/models/library_item_extras.dart';
import 'package:whispering_pages/router/router.dart'; import 'package:whispering_pages/router/router.dart';
import 'package:whispering_pages/widgets/shelves/home_shelf.dart'; import 'package:whispering_pages/shared/widgets/shelves/home_shelf.dart';
/// A shelf that displays books on the home page /// A shelf that displays books on the home page
class BookHomeShelf extends HookConsumerWidget { class BookHomeShelf extends HookConsumerWidget {

View file

@ -3,8 +3,8 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/widgets/shelves/author_shelf.dart'; import 'package:whispering_pages/shared/widgets/shelves/author_shelf.dart';
import 'package:whispering_pages/widgets/shelves/book_shelf.dart'; import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
/// A shelf that displays books/authors/series on the home page /// A shelf that displays books/authors/series on the home page
/// ///

View file

@ -688,6 +688,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
miniplayer:
dependency: "direct main"
description:
name: miniplayer
sha256: "6e12c27aef7432fc16508460a6dc824f3edfeb01761bd0dbfbccc84d516121bf"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:

View file

@ -54,6 +54,7 @@ dependencies:
isar_flutter_libs: ^4.0.0-dev.13 isar_flutter_libs: ^4.0.0-dev.13
json_annotation: ^4.9.0 json_annotation: ^4.9.0
lottie: ^3.1.0 lottie: ^3.1.0
miniplayer: ^1.0.1
path: ^1.9.0 path: ^1.9.0
path_provider: ^2.1.0 path_provider: ^2.1.0
riverpod_annotation: ^2.3.5 riverpod_annotation: ^2.3.5