mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2026-01-14 14:19:32 +00:00
chore: run dart format
Some checks are pending
Flutter CI & Release / Test (push) Waiting to run
Flutter CI & Release / Build Android APKs (push) Blocked by required conditions
Flutter CI & Release / build_linux (push) Blocked by required conditions
Flutter CI & Release / Create GitHub Release (push) Blocked by required conditions
Some checks are pending
Flutter CI & Release / Test (push) Waiting to run
Flutter CI & Release / Build Android APKs (push) Blocked by required conditions
Flutter CI & Release / build_linux (push) Blocked by required conditions
Flutter CI & Release / Create GitHub Release (push) Blocked by required conditions
This commit is contained in:
parent
a520136e01
commit
e23c0b6c5f
84 changed files with 1565 additions and 1945 deletions
|
|
@ -67,17 +67,13 @@ class AudiobookDownloadManager {
|
|||
|
||||
late StreamSubscription<TaskUpdate> _updatesSubscription;
|
||||
|
||||
Future<void> queueAudioBookDownload(
|
||||
LibraryItemExpanded item,
|
||||
) async {
|
||||
Future<void> queueAudioBookDownload(LibraryItemExpanded item) async {
|
||||
_logger.info('queuing download for item: ${item.id}');
|
||||
// create a download task for each file in the item
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
for (final file in item.libraryFiles) {
|
||||
// check if the file is already downloaded
|
||||
if (isFileDownloaded(
|
||||
constructFilePath(directory, item, file),
|
||||
)) {
|
||||
if (isFileDownloaded(constructFilePath(directory, item, file))) {
|
||||
_logger.info('file already downloaded: ${file.metadata.filename}');
|
||||
continue;
|
||||
}
|
||||
|
|
@ -105,8 +101,7 @@ class AudiobookDownloadManager {
|
|||
Directory directory,
|
||||
LibraryItemExpanded item,
|
||||
LibraryFile file,
|
||||
) =>
|
||||
'${directory.path}/${item.relPath}/${file.metadata.filename}';
|
||||
) => '${directory.path}/${item.relPath}/${file.metadata.filename}';
|
||||
|
||||
void dispose() {
|
||||
_updatesSubscription.cancel();
|
||||
|
|
|
|||
|
|
@ -52,13 +52,9 @@ class DownloadManager extends _$DownloadManager {
|
|||
return manager;
|
||||
}
|
||||
|
||||
Future<void> queueAudioBookDownload(
|
||||
LibraryItemExpanded item,
|
||||
) async {
|
||||
Future<void> queueAudioBookDownload(LibraryItemExpanded item) async {
|
||||
_logger.fine('queueing download for ${item.id}');
|
||||
await state.queueAudioBookDownload(
|
||||
item,
|
||||
);
|
||||
await state.queueAudioBookDownload(item);
|
||||
}
|
||||
|
||||
Future<void> deleteDownloadedItem(LibraryItemExpanded item) async {
|
||||
|
|
@ -83,58 +79,57 @@ class ItemDownloadProgress extends _$ItemDownloadProgress {
|
|||
Future<double?> build(String id) async {
|
||||
final item = await ref.watch(libraryItemProvider(id).future);
|
||||
final manager = ref.read(downloadManagerProvider);
|
||||
manager.taskUpdateStream.map((taskUpdate) {
|
||||
if (taskUpdate is! TaskProgressUpdate) {
|
||||
return null;
|
||||
}
|
||||
if (taskUpdate.task.group == id) {
|
||||
return taskUpdate;
|
||||
}
|
||||
}).listen((task) async {
|
||||
if (task != null) {
|
||||
final totalSize = item.totalSize;
|
||||
// if total size is 0, return 0
|
||||
if (totalSize == 0) {
|
||||
state = const AsyncValue.data(0.0);
|
||||
return;
|
||||
}
|
||||
final downloadedFiles = await manager.getDownloadedFilesMetadata(item);
|
||||
// calculate total size of downloaded files and total size of item, then divide
|
||||
// to get percentage
|
||||
final downloadedSize = downloadedFiles.fold<int>(
|
||||
0,
|
||||
(previousValue, element) => previousValue + element.metadata.size,
|
||||
);
|
||||
manager.taskUpdateStream
|
||||
.map((taskUpdate) {
|
||||
if (taskUpdate is! TaskProgressUpdate) {
|
||||
return null;
|
||||
}
|
||||
if (taskUpdate.task.group == id) {
|
||||
return taskUpdate;
|
||||
}
|
||||
})
|
||||
.listen((task) async {
|
||||
if (task != null) {
|
||||
final totalSize = item.totalSize;
|
||||
// if total size is 0, return 0
|
||||
if (totalSize == 0) {
|
||||
state = const AsyncValue.data(0.0);
|
||||
return;
|
||||
}
|
||||
final downloadedFiles = await manager.getDownloadedFilesMetadata(
|
||||
item,
|
||||
);
|
||||
// calculate total size of downloaded files and total size of item, then divide
|
||||
// to get percentage
|
||||
final downloadedSize = downloadedFiles.fold<int>(
|
||||
0,
|
||||
(previousValue, element) => previousValue + element.metadata.size,
|
||||
);
|
||||
|
||||
final inProgressFileSize = task.progress * task.expectedFileSize;
|
||||
final totalDownloadedSize = downloadedSize + inProgressFileSize;
|
||||
final progress = totalDownloadedSize / totalSize;
|
||||
// if current progress is more than calculated progress, do not update
|
||||
if (progress < (state.value ?? 0.0)) {
|
||||
return;
|
||||
}
|
||||
final inProgressFileSize = task.progress * task.expectedFileSize;
|
||||
final totalDownloadedSize = downloadedSize + inProgressFileSize;
|
||||
final progress = totalDownloadedSize / totalSize;
|
||||
// if current progress is more than calculated progress, do not update
|
||||
if (progress < (state.value ?? 0.0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = AsyncValue.data(progress.clamp(0.0, 1.0));
|
||||
}
|
||||
});
|
||||
state = AsyncValue.data(progress.clamp(0.0, 1.0));
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
FutureOr<List<TaskRecord>> downloadHistory(
|
||||
Ref ref, {
|
||||
String? group,
|
||||
}) async {
|
||||
FutureOr<List<TaskRecord>> downloadHistory(Ref ref, {String? group}) async {
|
||||
return await FileDownloader().database.allRecords(group: group);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class IsItemDownloaded extends _$IsItemDownloaded {
|
||||
@override
|
||||
FutureOr<bool> build(
|
||||
LibraryItemExpanded item,
|
||||
) {
|
||||
FutureOr<bool> build(LibraryItemExpanded item) {
|
||||
final manager = ref.watch(downloadManagerProvider);
|
||||
return manager.isItemDownloaded(item);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ class DownloadsPage extends HookConsumerWidget {
|
|||
final downloadHistory = ref.watch(downloadHistoryProvider());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Downloads'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('Downloads')),
|
||||
body: Center(
|
||||
// history of downloads
|
||||
child: downloadHistory.when(
|
||||
|
|
|
|||
|
|
@ -28,18 +28,14 @@ class ExplorePage extends HookConsumerWidget {
|
|||
final settings = ref.watch(appSettingsProvider);
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Explore'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('Explore')),
|
||||
body: const MySearchBar(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MySearchBar extends HookConsumerWidget {
|
||||
const MySearchBar({
|
||||
super.key,
|
||||
});
|
||||
const MySearchBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -61,8 +57,11 @@ class MySearchBar extends HookConsumerWidget {
|
|||
currentQuery = query;
|
||||
|
||||
// In a real application, there should be some error handling here.
|
||||
final options = await api.libraries
|
||||
.search(libraryId: settings.activeLibraryId!, query: query, limit: 3);
|
||||
final options = await api.libraries.search(
|
||||
libraryId: settings.activeLibraryId!,
|
||||
query: query,
|
||||
limit: 3,
|
||||
);
|
||||
|
||||
// If another search happened after this one, throw away these options.
|
||||
if (currentQuery != query) {
|
||||
|
|
@ -97,11 +96,10 @@ class MySearchBar extends HookConsumerWidget {
|
|||
// opacity: 0.5 for the hint text
|
||||
hintStyle: WidgetStatePropertyAll(
|
||||
Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onTapOutside: (_) {
|
||||
|
|
@ -120,12 +118,7 @@ class MySearchBar extends HookConsumerWidget {
|
|||
);
|
||||
},
|
||||
viewOnSubmitted: (value) {
|
||||
context.pushNamed(
|
||||
Routes.search.name,
|
||||
queryParameters: {
|
||||
'q': value,
|
||||
},
|
||||
);
|
||||
context.pushNamed(Routes.search.name, queryParameters: {'q': value});
|
||||
},
|
||||
suggestionsBuilder: (context, controller) async {
|
||||
// check if the search controller is empty
|
||||
|
|
@ -191,14 +184,12 @@ List<Widget> buildBookSearchResult(
|
|||
SearchResultMiniSection(
|
||||
// title: 'Books',
|
||||
category: SearchResultCategory.books,
|
||||
options: options.book.map(
|
||||
(result) {
|
||||
// convert result to a book object
|
||||
final book = result.libraryItem.media.asBookExpanded;
|
||||
final metadata = book.metadata.asBookMetadataExpanded;
|
||||
return BookSearchResultMini(book: book, metadata: metadata);
|
||||
},
|
||||
),
|
||||
options: options.book.map((result) {
|
||||
// convert result to a book object
|
||||
final book = result.libraryItem.media.asBookExpanded;
|
||||
final metadata = book.metadata.asBookMetadataExpanded;
|
||||
return BookSearchResultMini(book: book, metadata: metadata);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -207,11 +198,9 @@ List<Widget> buildBookSearchResult(
|
|||
SearchResultMiniSection(
|
||||
// title: 'Authors',
|
||||
category: SearchResultCategory.authors,
|
||||
options: options.authors.map(
|
||||
(result) {
|
||||
return ListTile(title: Text(result.name));
|
||||
},
|
||||
),
|
||||
options: options.authors.map((result) {
|
||||
return ListTile(title: Text(result.name));
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -245,10 +234,7 @@ class BookSearchResultMini extends HookConsumerWidget {
|
|||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: image.when(
|
||||
data: (bytes) => Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
data: (bytes) => Image.memory(bytes, fit: BoxFit.cover),
|
||||
loading: () => const BookCoverSkeleton(),
|
||||
error: (error, _) => const Icon(Icons.error),
|
||||
),
|
||||
|
|
@ -259,11 +245,7 @@ class BookSearchResultMini extends HookConsumerWidget {
|
|||
subtitle: Text(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
metadata.authors
|
||||
.map(
|
||||
(author) => author.name,
|
||||
)
|
||||
.join(', '),
|
||||
metadata.authors.map((author) => author.name).join(', '),
|
||||
),
|
||||
onTap: () {
|
||||
// navigate to the book details page
|
||||
|
|
|
|||
|
|
@ -5,13 +5,7 @@ import 'package:vaani/features/explore/providers/search_result_provider.dart';
|
|||
import 'package:vaani/features/explore/view/explore_page.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
|
||||
enum SearchResultCategory {
|
||||
books,
|
||||
authors,
|
||||
series,
|
||||
tags,
|
||||
narrators,
|
||||
}
|
||||
enum SearchResultCategory { books, authors, series, tags, narrators }
|
||||
|
||||
class SearchResultPage extends HookConsumerWidget {
|
||||
const SearchResultPage({
|
||||
|
|
@ -41,9 +35,7 @@ class SearchResultPage extends HookConsumerWidget {
|
|||
body: results.when(
|
||||
data: (options) {
|
||||
if (options == null) {
|
||||
return Container(
|
||||
child: const Text('No data found'),
|
||||
);
|
||||
return Container(child: const Text('No data found'));
|
||||
}
|
||||
if (options is BookLibrarySearchResponse) {
|
||||
if (category == null) {
|
||||
|
|
@ -51,18 +43,15 @@ class SearchResultPage extends HookConsumerWidget {
|
|||
}
|
||||
return switch (category!) {
|
||||
SearchResultCategory.books => ListView.builder(
|
||||
itemCount: options.book.length,
|
||||
itemBuilder: (context, index) {
|
||||
final book =
|
||||
options.book[index].libraryItem.media.asBookExpanded;
|
||||
final metadata = book.metadata.asBookMetadataExpanded;
|
||||
itemCount: options.book.length,
|
||||
itemBuilder: (context, index) {
|
||||
final book =
|
||||
options.book[index].libraryItem.media.asBookExpanded;
|
||||
final metadata = book.metadata.asBookMetadataExpanded;
|
||||
|
||||
return BookSearchResultMini(
|
||||
book: book,
|
||||
metadata: metadata,
|
||||
);
|
||||
},
|
||||
),
|
||||
return BookSearchResultMini(book: book, metadata: metadata);
|
||||
},
|
||||
),
|
||||
SearchResultCategory.authors => Container(),
|
||||
SearchResultCategory.series => Container(),
|
||||
SearchResultCategory.tags => Container(),
|
||||
|
|
@ -71,12 +60,8 @@ class SearchResultPage extends HookConsumerWidget {
|
|||
}
|
||||
return null;
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Text('Error: $error'),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,7 @@ import 'package:vaani/shared/extensions/model_conversions.dart';
|
|||
import 'package:vaani/shared/utils.dart';
|
||||
|
||||
class LibraryItemActions extends HookConsumerWidget {
|
||||
const LibraryItemActions({
|
||||
super.key,
|
||||
required this.id,
|
||||
});
|
||||
const LibraryItemActions({super.key, required this.id});
|
||||
|
||||
final String id;
|
||||
|
||||
|
|
@ -68,9 +65,7 @@ class LibraryItemActions extends HookConsumerWidget {
|
|||
// read list button
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(
|
||||
Icons.playlist_add_rounded,
|
||||
),
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
),
|
||||
// share button
|
||||
IconButton(
|
||||
|
|
@ -79,8 +74,9 @@ class LibraryItemActions extends HookConsumerWidget {
|
|||
var currentServerUrl =
|
||||
apiSettings.activeServer!.serverUrl;
|
||||
if (!currentServerUrl.hasScheme) {
|
||||
currentServerUrl =
|
||||
Uri.https(currentServerUrl.toString());
|
||||
currentServerUrl = Uri.https(
|
||||
currentServerUrl.toString(),
|
||||
);
|
||||
}
|
||||
handleLaunchUrl(
|
||||
Uri.parse(
|
||||
|
|
@ -140,7 +136,8 @@ class LibraryItemActions extends HookConsumerWidget {
|
|||
.database
|
||||
.deleteRecordWithId(
|
||||
record
|
||||
.task.taskId,
|
||||
.task
|
||||
.taskId,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
|
|
@ -161,8 +158,8 @@ class LibraryItemActions extends HookConsumerWidget {
|
|||
// open the file location
|
||||
final didOpen =
|
||||
await FileDownloader().openFile(
|
||||
task: record.task,
|
||||
);
|
||||
task: record.task,
|
||||
);
|
||||
|
||||
if (!didOpen) {
|
||||
appLogger.warning(
|
||||
|
|
@ -182,16 +179,13 @@ class LibraryItemActions extends HookConsumerWidget {
|
|||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Text('Error: $error'),
|
||||
),
|
||||
error: (error, stackTrace) =>
|
||||
Center(child: Text('Error: $error')),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.more_vert_rounded,
|
||||
),
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -206,10 +200,7 @@ class LibraryItemActions extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class LibItemDownloadButton extends HookConsumerWidget {
|
||||
const LibItemDownloadButton({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
const LibItemDownloadButton({super.key, required this.item});
|
||||
|
||||
final shelfsdk.LibraryItemExpanded item;
|
||||
|
||||
|
|
@ -222,9 +213,7 @@ class LibItemDownloadButton extends HookConsumerWidget {
|
|||
final isItemDownloading = ref.watch(isItemDownloadingProvider(item.id));
|
||||
|
||||
return isItemDownloading
|
||||
? ItemCurrentlyInDownloadQueue(
|
||||
item: item,
|
||||
)
|
||||
? ItemCurrentlyInDownloadQueue(item: item)
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
appLogger.fine('Pressed download button');
|
||||
|
|
@ -233,18 +222,13 @@ class LibItemDownloadButton extends HookConsumerWidget {
|
|||
.read(downloadManagerProvider.notifier)
|
||||
.queueAudioBookDownload(item);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.download_rounded,
|
||||
),
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ItemCurrentlyInDownloadQueue extends HookConsumerWidget {
|
||||
const ItemCurrentlyInDownloadQueue({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
const ItemCurrentlyInDownloadQueue({super.key, required this.item});
|
||||
|
||||
final shelfsdk.LibraryItemExpanded item;
|
||||
|
||||
|
|
@ -263,17 +247,12 @@ class ItemCurrentlyInDownloadQueue extends HookConsumerWidget {
|
|||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
CircularProgressIndicator(value: progress, strokeWidth: 2),
|
||||
const Icon(
|
||||
Icons.download,
|
||||
// color: Theme.of(context).progressIndicatorTheme.color,
|
||||
)
|
||||
.animate(
|
||||
onPlay: (controller) => controller.repeat(),
|
||||
Icons.download,
|
||||
// color: Theme.of(context).progressIndicatorTheme.color,
|
||||
)
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.fade(
|
||||
duration: shimmerDuration,
|
||||
end: 1,
|
||||
|
|
@ -292,10 +271,7 @@ class ItemCurrentlyInDownloadQueue extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class AlreadyItemDownloadedButton extends HookConsumerWidget {
|
||||
const AlreadyItemDownloadedButton({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
const AlreadyItemDownloadedButton({super.key, required this.item});
|
||||
|
||||
final shelfsdk.LibraryItemExpanded item;
|
||||
|
||||
|
|
@ -317,25 +293,18 @@ class AlreadyItemDownloadedButton extends HookConsumerWidget {
|
|||
top: 8.0,
|
||||
bottom: (isBookPlaying ? playerMinHeight : 0) + 8,
|
||||
),
|
||||
child: DownloadSheet(
|
||||
item: item,
|
||||
),
|
||||
child: DownloadSheet(item: item),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.download_done_rounded,
|
||||
),
|
||||
icon: const Icon(Icons.download_done_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadSheet extends HookConsumerWidget {
|
||||
const DownloadSheet({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
const DownloadSheet({super.key, required this.item});
|
||||
|
||||
final shelfsdk.LibraryItemExpanded item;
|
||||
|
||||
|
|
@ -367,9 +336,7 @@ class DownloadSheet extends HookConsumerWidget {
|
|||
// ),
|
||||
ListTile(
|
||||
title: const Text('Delete'),
|
||||
leading: const Icon(
|
||||
Icons.delete_rounded,
|
||||
),
|
||||
leading: const Icon(Icons.delete_rounded),
|
||||
onTap: () async {
|
||||
// show the delete dialog
|
||||
final wasDeleted = await showDialog<bool>(
|
||||
|
|
@ -387,9 +354,7 @@ class DownloadSheet extends HookConsumerWidget {
|
|||
// delete the file
|
||||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.deleteDownloadedItem(
|
||||
item,
|
||||
);
|
||||
.deleteDownloadedItem(item);
|
||||
GoRouter.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Yes'),
|
||||
|
|
@ -409,11 +374,7 @@ class DownloadSheet extends HookConsumerWidget {
|
|||
appLogger.fine('Deleted ${item.media.metadata.title}');
|
||||
GoRouter.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Deleted ${item.media.metadata.title}',
|
||||
),
|
||||
),
|
||||
SnackBar(content: Text('Deleted ${item.media.metadata.title}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -424,9 +385,7 @@ class DownloadSheet extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class _LibraryItemPlayButton extends HookConsumerWidget {
|
||||
const _LibraryItemPlayButton({
|
||||
required this.item,
|
||||
});
|
||||
const _LibraryItemPlayButton({required this.item});
|
||||
|
||||
final shelfsdk.LibraryItemExpanded item;
|
||||
|
||||
|
|
@ -477,9 +436,7 @@ class _LibraryItemPlayButton extends HookConsumerWidget {
|
|||
),
|
||||
label: Text(getPlayDisplayText()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -502,11 +459,11 @@ class DynamicItemPlayIcon extends StatelessWidget {
|
|||
return Icon(
|
||||
isCurrentBookSetInPlayer
|
||||
? isPlayingThisBook
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded
|
||||
: isBookCompleted
|
||||
? Icons.replay_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
? Icons.replay_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -529,8 +486,9 @@ Future<void> libraryItemPlayButtonOnPressed({
|
|||
appLogger.info('Setting the book ${book.libraryItemId}');
|
||||
appLogger.info('Initial position: ${userMediaProgress?.currentTime}');
|
||||
final downloadManager = ref.watch(simpleDownloadManagerProvider);
|
||||
final libItem =
|
||||
await ref.read(libraryItemProvider(book.libraryItemId).future);
|
||||
final libItem = await ref.read(
|
||||
libraryItemProvider(book.libraryItemId).future,
|
||||
);
|
||||
final downloadedUris = await downloadManager.getDownloadedFilesUri(libItem);
|
||||
setSourceFuture = player.setSourceAudiobook(
|
||||
book,
|
||||
|
|
@ -546,8 +504,9 @@ Future<void> libraryItemPlayButtonOnPressed({
|
|||
}
|
||||
}
|
||||
// set the volume as this is the first time playing and dismissing causes the volume to go to 0
|
||||
var bookPlayerSettings =
|
||||
ref.read(bookSettingsProvider(book.libraryItemId)).playerSettings;
|
||||
var bookPlayerSettings = ref
|
||||
.read(bookSettingsProvider(book.libraryItemId))
|
||||
.playerSettings;
|
||||
var appPlayerSettings = ref.read(appSettingsProvider).playerSettings;
|
||||
|
||||
var configurePlayerForEveryBook =
|
||||
|
|
@ -559,14 +518,14 @@ Future<void> libraryItemPlayButtonOnPressed({
|
|||
player.setVolume(
|
||||
configurePlayerForEveryBook
|
||||
? bookPlayerSettings.preferredDefaultVolume ??
|
||||
appPlayerSettings.preferredDefaultVolume
|
||||
appPlayerSettings.preferredDefaultVolume
|
||||
: appPlayerSettings.preferredDefaultVolume,
|
||||
),
|
||||
// set the speed
|
||||
player.setSpeed(
|
||||
configurePlayerForEveryBook
|
||||
? bookPlayerSettings.preferredDefaultSpeed ??
|
||||
appPlayerSettings.preferredDefaultSpeed
|
||||
appPlayerSettings.preferredDefaultSpeed
|
||||
: appPlayerSettings.preferredDefaultSpeed,
|
||||
),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -42,14 +42,13 @@ class LibraryItemHeroSection extends HookConsumerWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTagPrefixes.bookCover +
|
||||
tag:
|
||||
HeroTagPrefixes.bookCover +
|
||||
itemId +
|
||||
(extraMap?.heroTagSuffix ?? ''),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: _BookCover(
|
||||
itemId: itemId,
|
||||
),
|
||||
child: _BookCover(itemId: itemId),
|
||||
),
|
||||
),
|
||||
// a progress bar
|
||||
|
|
@ -59,9 +58,7 @@ class LibraryItemHeroSection extends HookConsumerWidget {
|
|||
right: 8.0,
|
||||
left: 8.0,
|
||||
),
|
||||
child: _LibraryItemProgressIndicator(
|
||||
id: itemId,
|
||||
),
|
||||
child: _LibraryItemProgressIndicator(id: itemId),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -77,10 +74,7 @@ class LibraryItemHeroSection extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class _BookDetails extends HookConsumerWidget {
|
||||
const _BookDetails({
|
||||
required this.id,
|
||||
this.extraMap,
|
||||
});
|
||||
const _BookDetails({required this.id, this.extraMap});
|
||||
|
||||
final String id;
|
||||
final LibraryItemExtras? extraMap;
|
||||
|
|
@ -99,10 +93,7 @@ class _BookDetails extends HookConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_BookTitle(
|
||||
extraMap: extraMap,
|
||||
itemBookMetadata: itemBookMetadata,
|
||||
),
|
||||
_BookTitle(extraMap: extraMap, itemBookMetadata: itemBookMetadata),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
|
|
@ -134,9 +125,7 @@ class _BookDetails extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class _LibraryItemProgressIndicator extends HookConsumerWidget {
|
||||
const _LibraryItemProgressIndicator({
|
||||
required this.id,
|
||||
});
|
||||
const _LibraryItemProgressIndicator({required this.id});
|
||||
|
||||
final String id;
|
||||
|
||||
|
|
@ -157,13 +146,15 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget {
|
|||
Duration remainingTime;
|
||||
if (player.book?.libraryItemId == libraryItem.id) {
|
||||
// final positionStream = useStream(player.slowPositionStream);
|
||||
progress = (player.positionInBook).inSeconds /
|
||||
progress =
|
||||
(player.positionInBook).inSeconds /
|
||||
libraryItem.media.asBookExpanded.duration.inSeconds;
|
||||
remainingTime =
|
||||
libraryItem.media.asBookExpanded.duration - player.positionInBook;
|
||||
} else {
|
||||
progress = mediaProgress?.progress ?? 0;
|
||||
remainingTime = (libraryItem.media.asBookExpanded.duration -
|
||||
remainingTime =
|
||||
(libraryItem.media.asBookExpanded.duration -
|
||||
mediaProgress!.currentTime);
|
||||
}
|
||||
|
||||
|
|
@ -190,20 +181,17 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget {
|
|||
semanticsLabel: 'Book progress',
|
||||
semanticsValue: '${progressInPercent.toStringAsFixed(2)}%',
|
||||
),
|
||||
const SizedBox.square(
|
||||
dimension: 4.0,
|
||||
),
|
||||
const SizedBox.square(dimension: 4.0),
|
||||
// time remaining
|
||||
Text(
|
||||
// only show 2 decimal places
|
||||
'${remainingTime.smartBinaryFormat} left',
|
||||
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.75),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -212,10 +200,7 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
|
||||
const _HeroSectionSubLabelWithIcon({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
});
|
||||
const _HeroSectionSubLabelWithIcon({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final Widget text;
|
||||
|
|
@ -225,8 +210,10 @@ class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
|
|||
final themeData = Theme.of(context);
|
||||
final useFontAwesome =
|
||||
icon.runtimeType == FontAwesomeIcons.book.runtimeType;
|
||||
final useMaterialThemeOnItemPage =
|
||||
ref.watch(appSettingsProvider).themeSettings.useMaterialThemeOnItemPage;
|
||||
final useMaterialThemeOnItemPage = ref
|
||||
.watch(appSettingsProvider)
|
||||
.themeSettings
|
||||
.useMaterialThemeOnItemPage;
|
||||
final color = useMaterialThemeOnItemPage
|
||||
? themeData.colorScheme.primary
|
||||
: themeData.colorScheme.onSurface.withValues(alpha: 0.75);
|
||||
|
|
@ -237,20 +224,10 @@ class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
|
|||
Container(
|
||||
margin: const EdgeInsets.only(right: 8, top: 2),
|
||||
child: useFontAwesome
|
||||
? FaIcon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
)
|
||||
: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: text,
|
||||
? FaIcon(icon, size: 16, color: color)
|
||||
: Icon(icon, size: 16, color: color),
|
||||
),
|
||||
Expanded(child: text),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -338,9 +315,7 @@ class _BookNarrators extends StatelessWidget {
|
|||
}
|
||||
|
||||
class _BookCover extends HookConsumerWidget {
|
||||
const _BookCover({
|
||||
required this.itemId,
|
||||
});
|
||||
const _BookCover({required this.itemId});
|
||||
|
||||
final String itemId;
|
||||
|
||||
|
|
@ -358,7 +333,8 @@ class _BookCover extends HookConsumerWidget {
|
|||
themeOfLibraryItemProvider(
|
||||
itemId,
|
||||
brightness: Theme.of(context).brightness,
|
||||
highContrast: themeSettings.highContrast ||
|
||||
highContrast:
|
||||
themeSettings.highContrast ||
|
||||
MediaQuery.of(context).highContrast,
|
||||
),
|
||||
)
|
||||
|
|
@ -391,15 +367,10 @@ class _BookCover extends HookConsumerWidget {
|
|||
return const Icon(Icons.error);
|
||||
}
|
||||
|
||||
return Image.memory(
|
||||
image,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
return Image.memory(image, fit: BoxFit.cover);
|
||||
},
|
||||
loading: () {
|
||||
return const Center(
|
||||
child: BookCoverSkeleton(),
|
||||
);
|
||||
return const Center(child: BookCoverSkeleton());
|
||||
},
|
||||
error: (error, stack) {
|
||||
return const Center(child: Icon(Icons.error));
|
||||
|
|
@ -411,10 +382,7 @@ class _BookCover extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class _BookTitle extends StatelessWidget {
|
||||
const _BookTitle({
|
||||
required this.extraMap,
|
||||
required this.itemBookMetadata,
|
||||
});
|
||||
const _BookTitle({required this.extraMap, required this.itemBookMetadata});
|
||||
|
||||
final LibraryItemExtras? extraMap;
|
||||
final shelfsdk.BookMetadataExpanded? itemBookMetadata;
|
||||
|
|
@ -426,7 +394,8 @@ class _BookTitle extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTagPrefixes.bookTitle +
|
||||
tag:
|
||||
HeroTagPrefixes.bookTitle +
|
||||
// itemId +
|
||||
(extraMap?.heroTagSuffix ?? ''),
|
||||
child: Text(
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import 'package:vaani/api/library_item_provider.dart';
|
|||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
|
||||
class LibraryItemMetadata extends HookConsumerWidget {
|
||||
const LibraryItemMetadata({
|
||||
super.key,
|
||||
required this.id,
|
||||
});
|
||||
const LibraryItemMetadata({super.key, required this.id});
|
||||
|
||||
final String id;
|
||||
|
||||
|
|
@ -72,7 +69,8 @@ class LibraryItemMetadata extends HookConsumerWidget {
|
|||
),
|
||||
_MetadataItem(
|
||||
title: 'Published',
|
||||
value: itemBookMetadata?.publishedDate ??
|
||||
value:
|
||||
itemBookMetadata?.publishedDate ??
|
||||
itemBookMetadata?.publishedYear ??
|
||||
'Unknown',
|
||||
),
|
||||
|
|
@ -87,22 +85,18 @@ class LibraryItemMetadata extends HookConsumerWidget {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
// alternate between metadata and vertical divider
|
||||
children: List.generate(
|
||||
children.length * 2 - 1,
|
||||
(index) {
|
||||
if (index.isEven) {
|
||||
return children[index ~/ 2];
|
||||
}
|
||||
return VerticalDivider(
|
||||
indent: 6,
|
||||
endIndent: 6,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
);
|
||||
},
|
||||
),
|
||||
children: List.generate(children.length * 2 - 1, (index) {
|
||||
if (index.isEven) {
|
||||
return children[index ~/ 2];
|
||||
}
|
||||
return VerticalDivider(
|
||||
indent: 6,
|
||||
endIndent: 6,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -111,10 +105,7 @@ class LibraryItemMetadata extends HookConsumerWidget {
|
|||
|
||||
/// key-value pair to display as column
|
||||
class _MetadataItem extends StatelessWidget {
|
||||
const _MetadataItem({
|
||||
required this.title,
|
||||
required this.value,
|
||||
});
|
||||
const _MetadataItem({required this.title, required this.value});
|
||||
|
||||
final String title;
|
||||
final String value;
|
||||
|
|
|
|||
|
|
@ -16,49 +16,43 @@ import 'library_item_hero_section.dart';
|
|||
import 'library_item_metadata.dart';
|
||||
|
||||
class LibraryItemPage extends HookConsumerWidget {
|
||||
const LibraryItemPage({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
this.extra,
|
||||
});
|
||||
const LibraryItemPage({super.key, required this.itemId, this.extra});
|
||||
|
||||
final String itemId;
|
||||
final Object? extra;
|
||||
static const double _showFabThreshold = 300.0;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final additionalItemData =
|
||||
extra is LibraryItemExtras ? extra as LibraryItemExtras : null;
|
||||
final additionalItemData = extra is LibraryItemExtras
|
||||
? extra as LibraryItemExtras
|
||||
: null;
|
||||
final scrollController = useScrollController();
|
||||
final showFab = useState(false);
|
||||
|
||||
// Effect to listen to scroll changes and update FAB visibility
|
||||
useEffect(
|
||||
() {
|
||||
void listener() {
|
||||
if (!scrollController.hasClients) {
|
||||
return; // Ensure controller is attached
|
||||
}
|
||||
final shouldShow = scrollController.offset > _showFabThreshold;
|
||||
// Update state only if it changes and widget is still mounted
|
||||
if (showFab.value != shouldShow && context.mounted) {
|
||||
showFab.value = shouldShow;
|
||||
}
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
if (!scrollController.hasClients) {
|
||||
return; // Ensure controller is attached
|
||||
}
|
||||
final shouldShow = scrollController.offset > _showFabThreshold;
|
||||
// Update state only if it changes and widget is still mounted
|
||||
if (showFab.value != shouldShow && context.mounted) {
|
||||
showFab.value = shouldShow;
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(listener);
|
||||
// Initial check in case the view starts scrolled (less likely but safe)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (scrollController.hasClients && context.mounted) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
scrollController.addListener(listener);
|
||||
// Initial check in case the view starts scrolled (less likely but safe)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (scrollController.hasClients && context.mounted) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup: remove the listener when the widget is disposed
|
||||
return () => scrollController.removeListener(listener);
|
||||
},
|
||||
[scrollController],
|
||||
); // Re-run effect if scrollController changes
|
||||
// Cleanup: remove the listener when the widget is disposed
|
||||
return () => scrollController.removeListener(listener);
|
||||
}, [scrollController]); // Re-run effect if scrollController changes
|
||||
|
||||
// --- FAB Scroll-to-Top Logic ---
|
||||
void scrollToTop() {
|
||||
|
|
@ -82,10 +76,7 @@ class LibraryItemPage extends HookConsumerWidget {
|
|||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: showFab.value
|
||||
|
|
@ -96,9 +87,7 @@ class LibraryItemPage extends HookConsumerWidget {
|
|||
tooltip: 'Scroll to top',
|
||||
child: const Icon(Icons.arrow_upward),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('fab-empty'),
|
||||
),
|
||||
: const SizedBox.shrink(key: ValueKey('fab-empty')),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
|
|
@ -115,17 +104,11 @@ class LibraryItemPage extends HookConsumerWidget {
|
|||
),
|
||||
),
|
||||
// a horizontal display with dividers of metadata
|
||||
SliverToBoxAdapter(
|
||||
child: LibraryItemMetadata(id: itemId),
|
||||
),
|
||||
SliverToBoxAdapter(child: LibraryItemMetadata(id: itemId)),
|
||||
// a row of actions like play, download, share, etc
|
||||
SliverToBoxAdapter(
|
||||
child: LibraryItemActions(id: itemId),
|
||||
),
|
||||
SliverToBoxAdapter(child: LibraryItemActions(id: itemId)),
|
||||
// a expandable section for book description
|
||||
SliverToBoxAdapter(
|
||||
child: LibraryItemDescription(id: itemId),
|
||||
),
|
||||
SliverToBoxAdapter(child: LibraryItemDescription(id: itemId)),
|
||||
// a padding at the bottom to make sure the last item is not hidden by mini player
|
||||
const SliverToBoxAdapter(child: MiniPlayerBottomPadding()),
|
||||
],
|
||||
|
|
@ -137,10 +120,7 @@ class LibraryItemPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class LibraryItemDescription extends HookConsumerWidget {
|
||||
const LibraryItemDescription({
|
||||
super.key,
|
||||
required this.id,
|
||||
});
|
||||
const LibraryItemDescription({super.key, required this.id});
|
||||
|
||||
final String id;
|
||||
@override
|
||||
|
|
@ -160,16 +140,21 @@ class LibraryItemDescription extends HookConsumerWidget {
|
|||
double calculateWidth(
|
||||
BuildContext context,
|
||||
BoxConstraints constraints, {
|
||||
|
||||
/// width ratio of the cover image to the available width
|
||||
double widthRatio = 0.4,
|
||||
|
||||
/// height ratio of the cover image to the available height
|
||||
double maxHeightToUse = 0.25,
|
||||
}) {
|
||||
final availHeight =
|
||||
min(constraints.maxHeight, MediaQuery.of(context).size.height);
|
||||
final availWidth =
|
||||
min(constraints.maxWidth, MediaQuery.of(context).size.width);
|
||||
final availHeight = min(
|
||||
constraints.maxHeight,
|
||||
MediaQuery.of(context).size.height,
|
||||
);
|
||||
final availWidth = min(
|
||||
constraints.maxWidth,
|
||||
MediaQuery.of(context).size.width,
|
||||
);
|
||||
|
||||
// make the width widthRatio of the available width
|
||||
var width = availWidth * widthRatio;
|
||||
|
|
|
|||
|
|
@ -21,28 +21,26 @@ class LibraryItemSliverAppBar extends HookConsumerWidget {
|
|||
|
||||
final showTitle = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
void listener() {
|
||||
final shouldShow = scrollController.hasClients &&
|
||||
scrollController.offset > _showTitleThreshold;
|
||||
if (showTitle.value != shouldShow) {
|
||||
showTitle.value = shouldShow;
|
||||
}
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
final shouldShow =
|
||||
scrollController.hasClients &&
|
||||
scrollController.offset > _showTitleThreshold;
|
||||
if (showTitle.value != shouldShow) {
|
||||
showTitle.value = shouldShow;
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(listener);
|
||||
// Trigger listener once initially in case the view starts scrolled
|
||||
// (though unlikely for this specific use case, it's good practice)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (scrollController.hasClients) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
return () => scrollController.removeListener(listener);
|
||||
},
|
||||
[scrollController],
|
||||
);
|
||||
scrollController.addListener(listener);
|
||||
// Trigger listener once initially in case the view starts scrolled
|
||||
// (though unlikely for this specific use case, it's good practice)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (scrollController.hasClients) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
return () => scrollController.removeListener(listener);
|
||||
}, [scrollController]);
|
||||
|
||||
return SliverAppBar(
|
||||
elevation: 0,
|
||||
|
|
|
|||
|
|
@ -41,43 +41,41 @@ class LibraryBrowserPage extends HookConsumerWidget {
|
|||
title: Text(appBarTitle),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
ListTile(
|
||||
title: const Text('Authors'),
|
||||
leading: const Icon(Icons.person),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Genres'),
|
||||
leading: const Icon(Icons.category),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Series'),
|
||||
leading: const Icon(Icons.list),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
// Downloads
|
||||
ListTile(
|
||||
title: const Text('Downloads'),
|
||||
leading: const Icon(Icons.download),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(Routes.downloads.name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
title: const Text('Authors'),
|
||||
leading: const Icon(Icons.person),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Genres'),
|
||||
leading: const Icon(Icons.category),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Series'),
|
||||
leading: const Icon(Icons.list),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
// Downloads
|
||||
ListTile(
|
||||
title: const Text('Downloads'),
|
||||
leading: const Icon(Icons.download),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(Routes.downloads.name);
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -59,8 +59,10 @@ String generateZipFileName() {
|
|||
}
|
||||
|
||||
Level parseLevel(String level) {
|
||||
return Level.LEVELS
|
||||
.firstWhere((l) => l.name == level, orElse: () => Level.ALL);
|
||||
return Level.LEVELS.firstWhere(
|
||||
(l) => l.name == level,
|
||||
orElse: () => Level.ALL,
|
||||
);
|
||||
}
|
||||
|
||||
LogRecord parseLogLine(String line) {
|
||||
|
|
|
|||
|
|
@ -54,8 +54,9 @@ class LogsPage extends HookConsumerWidget {
|
|||
icon: const Icon(Icons.share),
|
||||
onPressed: () async {
|
||||
appLogger.info('Preparing logs for sharing');
|
||||
final zipLogFilePath =
|
||||
await ref.read(logsProvider.notifier).getZipFilePath();
|
||||
final zipLogFilePath = await ref
|
||||
.read(logsProvider.notifier)
|
||||
.getZipFilePath();
|
||||
|
||||
// submit logs
|
||||
final result = await Share.shareXFiles([XFile(zipLogFilePath)]);
|
||||
|
|
@ -169,7 +170,6 @@ class LogsPage extends HookConsumerWidget {
|
|||
children: [
|
||||
// a filter for log levels, loggers, and search
|
||||
// TODO: implement filters and search
|
||||
|
||||
Expanded(
|
||||
child: logs.when(
|
||||
data: (logRecords) {
|
||||
|
|
@ -243,9 +243,7 @@ class LogRecordTile extends StatelessWidget {
|
|||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
const TextSpan(text: '\n\n'),
|
||||
TextSpan(
|
||||
text: logRecord.message,
|
||||
),
|
||||
TextSpan(text: logRecord.message),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -53,8 +53,10 @@ class OauthFlows extends _$OauthFlows {
|
|||
}
|
||||
state = {
|
||||
...state,
|
||||
oauthState: state[oauthState]!
|
||||
.copyWith(isFlowComplete: true, authToken: authToken),
|
||||
oauthState: state[oauthState]!.copyWith(
|
||||
isFlowComplete: true,
|
||||
authToken: authToken,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ class CallbackPage extends HookConsumerWidget {
|
|||
|
||||
// check if the state is in the flows
|
||||
if (!flows.containsKey(state)) {
|
||||
return const _SomethingWentWrong(
|
||||
message: 'State not found',
|
||||
);
|
||||
return const _SomethingWentWrong(message: 'State not found');
|
||||
}
|
||||
|
||||
// get the token
|
||||
|
|
@ -45,26 +43,21 @@ class CallbackPage extends HookConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Contacting server...\nPlease wait\n\nGot:'
|
||||
'\nState: $state\nCode: $code'),
|
||||
Text(
|
||||
'Contacting server...\nPlease wait\n\nGot:'
|
||||
'\nState: $state\nCode: $code',
|
||||
),
|
||||
loginAuthToken.when(
|
||||
data: (authenticationToken) {
|
||||
if (authenticationToken == null) {
|
||||
handleServerError(
|
||||
context,
|
||||
serverErrorResponse,
|
||||
);
|
||||
handleServerError(context, serverErrorResponse);
|
||||
return const BackToLoginButton();
|
||||
}
|
||||
return Text('Token: $authenticationToken');
|
||||
},
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (error, _) {
|
||||
handleServerError(
|
||||
context,
|
||||
serverErrorResponse,
|
||||
e: error,
|
||||
);
|
||||
handleServerError(context, serverErrorResponse, e: error);
|
||||
return Column(
|
||||
children: [
|
||||
Text('Error with OAuth flow: $error'),
|
||||
|
|
@ -81,9 +74,7 @@ class CallbackPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class BackToLoginButton extends StatelessWidget {
|
||||
const BackToLoginButton({
|
||||
super.key,
|
||||
});
|
||||
const BackToLoginButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -97,9 +88,7 @@ class BackToLoginButton extends StatelessWidget {
|
|||
}
|
||||
|
||||
class _SomethingWentWrong extends StatelessWidget {
|
||||
const _SomethingWentWrong({
|
||||
this.message = 'Error with OAuth flow',
|
||||
});
|
||||
const _SomethingWentWrong({this.message = 'Error with OAuth flow'});
|
||||
|
||||
final String message;
|
||||
|
||||
|
|
@ -109,10 +98,7 @@ class _SomethingWentWrong extends StatelessWidget {
|
|||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(message),
|
||||
const BackToLoginButton(),
|
||||
],
|
||||
children: [Text(message), const BackToLoginButton()],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ import 'package:vaani/shared/utils.dart';
|
|||
import 'package:vaani/shared/widgets/add_new_server.dart';
|
||||
|
||||
class OnboardingSinglePage extends HookConsumerWidget {
|
||||
const OnboardingSinglePage({
|
||||
super.key,
|
||||
});
|
||||
const OnboardingSinglePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -23,8 +21,9 @@ class OnboardingSinglePage extends HookConsumerWidget {
|
|||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600,
|
||||
minWidth:
|
||||
constraints.maxWidth < 600 ? constraints.maxWidth : 0,
|
||||
minWidth: constraints.maxWidth < 600
|
||||
? constraints.maxWidth
|
||||
: 0,
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20.0),
|
||||
|
|
@ -39,10 +38,7 @@ class OnboardingSinglePage extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Widget fadeSlideTransitionBuilder(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
Widget fadeSlideTransitionBuilder(Widget child, Animation<double> animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
|
|
@ -56,9 +52,7 @@ Widget fadeSlideTransitionBuilder(
|
|||
}
|
||||
|
||||
class OnboardingBody extends HookConsumerWidget {
|
||||
const OnboardingBody({
|
||||
super.key,
|
||||
});
|
||||
const OnboardingBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -81,9 +75,7 @@ class OnboardingBody extends HookConsumerWidget {
|
|||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox.square(
|
||||
dimension: 16.0,
|
||||
),
|
||||
const SizedBox.square(dimension: 16.0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AnimatedSwitcher(
|
||||
|
|
@ -112,21 +104,17 @@ class OnboardingBody extends HookConsumerWidget {
|
|||
},
|
||||
),
|
||||
),
|
||||
const SizedBox.square(
|
||||
dimension: 16.0,
|
||||
),
|
||||
const SizedBox.square(dimension: 16.0),
|
||||
AnimatedSwitcher(
|
||||
duration: 500.ms,
|
||||
transitionBuilder: fadeSlideTransitionBuilder,
|
||||
child: canUserLogin.value
|
||||
? UserLoginWidget(
|
||||
server: audiobookshelfUri,
|
||||
)
|
||||
? UserLoginWidget(server: audiobookshelfUri)
|
||||
// ).animate().fade(duration: 600.ms).slideY(begin: 0.3, end: 0)
|
||||
: const RedirectToABS().animate().fadeIn().slideY(
|
||||
curve: Curves.easeInOut,
|
||||
duration: 500.ms,
|
||||
),
|
||||
curve: Curves.easeInOut,
|
||||
duration: 500.ms,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -134,9 +122,7 @@ class OnboardingBody extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class RedirectToABS extends StatelessWidget {
|
||||
const RedirectToABS({
|
||||
super.key,
|
||||
});
|
||||
const RedirectToABS({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -152,18 +138,14 @@ class RedirectToABS extends StatelessWidget {
|
|||
isSemanticButton: false,
|
||||
style: ButtonStyle(
|
||||
elevation: WidgetStateProperty.all(0),
|
||||
padding: WidgetStateProperty.all(
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.all(0)),
|
||||
),
|
||||
onPressed: () async {
|
||||
// open the github page
|
||||
// ignore: avoid_print
|
||||
print('Opening the github page');
|
||||
await handleLaunchUrl(
|
||||
Uri.parse(
|
||||
'https://www.audiobookshelf.org',
|
||||
),
|
||||
Uri.parse('https://www.audiobookshelf.org'),
|
||||
);
|
||||
},
|
||||
child: const Text('Click here'),
|
||||
|
|
|
|||
|
|
@ -22,11 +22,7 @@ import 'package:vaani/settings/api_settings_provider.dart'
|
|||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
|
||||
class UserLoginWidget extends HookConsumerWidget {
|
||||
const UserLoginWidget({
|
||||
super.key,
|
||||
required this.server,
|
||||
this.onSuccess,
|
||||
});
|
||||
const UserLoginWidget({super.key, required this.server, this.onSuccess});
|
||||
|
||||
final Uri server;
|
||||
final Function(model.AuthenticatedUser)? onSuccess;
|
||||
|
|
@ -34,8 +30,9 @@ class UserLoginWidget extends HookConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverStatusError = useMemoized(() => ErrorResponseHandler(), []);
|
||||
final serverStatus =
|
||||
ref.watch(serverStatusProvider(server, serverStatusError.storeError));
|
||||
final serverStatus = ref.watch(
|
||||
serverStatusProvider(server, serverStatusError.storeError),
|
||||
);
|
||||
|
||||
return serverStatus.when(
|
||||
data: (value) {
|
||||
|
|
@ -55,9 +52,7 @@ class UserLoginWidget extends HookConsumerWidget {
|
|||
);
|
||||
},
|
||||
loading: () {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
error: (error, _) {
|
||||
return Center(
|
||||
|
|
@ -68,10 +63,7 @@ class UserLoginWidget extends HookConsumerWidget {
|
|||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(
|
||||
serverStatusProvider(
|
||||
server,
|
||||
serverStatusError.storeError,
|
||||
),
|
||||
serverStatusProvider(server, serverStatusError.storeError),
|
||||
);
|
||||
},
|
||||
child: const Text('Try again'),
|
||||
|
|
@ -84,11 +76,7 @@ class UserLoginWidget extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
enum AuthMethodChoice {
|
||||
local,
|
||||
openid,
|
||||
authToken,
|
||||
}
|
||||
enum AuthMethodChoice { local, openid, authToken }
|
||||
|
||||
class UserLoginMultipleAuth extends HookConsumerWidget {
|
||||
const UserLoginMultipleAuth({
|
||||
|
|
@ -117,21 +105,17 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
);
|
||||
|
||||
model.AudiobookShelfServer addServer() {
|
||||
var newServer = model.AudiobookShelfServer(
|
||||
serverUrl: server,
|
||||
);
|
||||
var newServer = model.AudiobookShelfServer(serverUrl: server);
|
||||
try {
|
||||
// add the server to the list of servers
|
||||
ref.read(audiobookShelfServerProvider.notifier).addServer(
|
||||
newServer,
|
||||
);
|
||||
ref.read(audiobookShelfServerProvider.notifier).addServer(newServer);
|
||||
} on ServerAlreadyExistsException catch (e) {
|
||||
newServer = e.server;
|
||||
} finally {
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
ref.read(apiSettingsProvider).copyWith(
|
||||
activeServer: newServer,
|
||||
),
|
||||
ref
|
||||
.read(apiSettingsProvider.notifier)
|
||||
.updateState(
|
||||
ref.read(apiSettingsProvider).copyWith(activeServer: newServer),
|
||||
);
|
||||
}
|
||||
return newServer;
|
||||
|
|
@ -150,42 +134,49 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
runAlignment: WrapAlignment.center,
|
||||
runSpacing: 10,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
// a small label to show the user what to do
|
||||
if (localAvailable)
|
||||
ChoiceChip(
|
||||
label: const Text('Local'),
|
||||
selected: methodChoice.value == AuthMethodChoice.local,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
methodChoice.value = AuthMethodChoice.local;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (openIDAvailable)
|
||||
ChoiceChip(
|
||||
label: const Text('OpenID'),
|
||||
selected: methodChoice.value == AuthMethodChoice.openid,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
methodChoice.value = AuthMethodChoice.openid;
|
||||
}
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Token'),
|
||||
selected:
|
||||
methodChoice.value == AuthMethodChoice.authToken,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
methodChoice.value = AuthMethodChoice.authToken;
|
||||
}
|
||||
},
|
||||
),
|
||||
].animate(interval: 100.ms).fadeIn(
|
||||
duration: 150.ms,
|
||||
curve: Curves.easeIn,
|
||||
),
|
||||
children:
|
||||
[
|
||||
// a small label to show the user what to do
|
||||
if (localAvailable)
|
||||
ChoiceChip(
|
||||
label: const Text('Local'),
|
||||
selected:
|
||||
methodChoice.value ==
|
||||
AuthMethodChoice.local,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
methodChoice.value = AuthMethodChoice.local;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (openIDAvailable)
|
||||
ChoiceChip(
|
||||
label: const Text('OpenID'),
|
||||
selected:
|
||||
methodChoice.value ==
|
||||
AuthMethodChoice.openid,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
methodChoice.value =
|
||||
AuthMethodChoice.openid;
|
||||
}
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Token'),
|
||||
selected:
|
||||
methodChoice.value ==
|
||||
AuthMethodChoice.authToken,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
methodChoice.value =
|
||||
AuthMethodChoice.authToken;
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
.animate(interval: 100.ms)
|
||||
.fadeIn(duration: 150.ms, curve: Curves.easeIn),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
|
@ -195,21 +186,21 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
transitionBuilder: fadeSlideTransitionBuilder,
|
||||
child: switch (methodChoice.value) {
|
||||
AuthMethodChoice.authToken => UserLoginWithToken(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
AuthMethodChoice.local => UserLoginWithPassword(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
AuthMethodChoice.openid => UserLoginWithOpenID(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
openIDButtonText: openIDButtonText,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
openIDButtonText: openIDButtonText,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ class UserLoginWithOpenID extends HookConsumerWidget {
|
|||
|
||||
if (openIDLoginEndpoint == null) {
|
||||
if (responseErrorHandler.response.statusCode == 400 &&
|
||||
responseErrorHandler.response.body
|
||||
.toLowerCase()
|
||||
.contains(RegExp(r'invalid.*redirect.*uri'))) {
|
||||
responseErrorHandler.response.body.toLowerCase().contains(
|
||||
RegExp(r'invalid.*redirect.*uri'),
|
||||
)) {
|
||||
// show error
|
||||
handleServerError(
|
||||
context,
|
||||
|
|
@ -97,16 +97,16 @@ class UserLoginWithOpenID extends HookConsumerWidget {
|
|||
);
|
||||
|
||||
// add the flow to the provider
|
||||
ref.read(oauthFlowsProvider.notifier).addFlow(
|
||||
ref
|
||||
.read(oauthFlowsProvider.notifier)
|
||||
.addFlow(
|
||||
oauthState,
|
||||
verifier: verifier,
|
||||
serverUri: server,
|
||||
cookie: Cookie.fromSetCookieValue(authCookie!),
|
||||
);
|
||||
|
||||
await handleLaunchUrl(
|
||||
openIDLoginEndpoint,
|
||||
);
|
||||
await handleLaunchUrl(openIDLoginEndpoint);
|
||||
}
|
||||
|
||||
return Column(
|
||||
|
|
|
|||
|
|
@ -39,17 +39,14 @@ class UserLoginWithPassword extends HookConsumerWidget {
|
|||
final api = ref.watch(audiobookshelfApiProvider(server));
|
||||
|
||||
// forward animation when the password visibility changes
|
||||
useEffect(
|
||||
() {
|
||||
if (isPasswordVisible.value) {
|
||||
isPasswordVisibleAnimationController.forward();
|
||||
} else {
|
||||
isPasswordVisibleAnimationController.reverse();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[isPasswordVisible.value],
|
||||
);
|
||||
useEffect(() {
|
||||
if (isPasswordVisible.value) {
|
||||
isPasswordVisibleAnimationController.forward();
|
||||
} else {
|
||||
isPasswordVisibleAnimationController.reverse();
|
||||
}
|
||||
return null;
|
||||
}, [isPasswordVisible.value]);
|
||||
|
||||
/// Login to the server and save the user
|
||||
Future<void> loginAndSave() async {
|
||||
|
|
@ -109,10 +106,9 @@ class UserLoginWithPassword extends HookConsumerWidget {
|
|||
decoration: InputDecoration(
|
||||
labelText: 'Username',
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
|
|
@ -129,18 +125,16 @@ class UserLoginWithPassword extends HookConsumerWidget {
|
|||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.8),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.8),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: InkWell(
|
||||
|
|
@ -157,9 +151,7 @@ class UserLoginWithPassword extends HookConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
maxHeight: 45,
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(maxHeight: 45),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
|
@ -197,10 +189,12 @@ Future<void> handleServerError(
|
|||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Error'),
|
||||
content: SelectableText('$title\n'
|
||||
'Got response: ${responseErrorHandler.response.body} (${responseErrorHandler.response.statusCode})\n'
|
||||
'Stacktrace: $e\n\n'
|
||||
'$body\n\n'),
|
||||
content: SelectableText(
|
||||
'$title\n'
|
||||
'Got response: ${responseErrorHandler.response.body} (${responseErrorHandler.response.statusCode})\n'
|
||||
'Stacktrace: $e\n\n'
|
||||
'$body\n\n',
|
||||
),
|
||||
actions: [
|
||||
if (outLink != null)
|
||||
TextButton(
|
||||
|
|
@ -214,8 +208,8 @@ Future<void> handleServerError(
|
|||
// open an issue on the github page
|
||||
handleLaunchUrl(
|
||||
AppMetadata.githubRepo
|
||||
// append the issue url
|
||||
.replace(
|
||||
// append the issue url
|
||||
.replace(
|
||||
path: '${AppMetadata.githubRepo.path}/issues/new',
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,10 +89,9 @@ class UserLoginWithToken extends HookConsumerWidget {
|
|||
decoration: InputDecoration(
|
||||
labelText: 'API Token',
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
|
|
@ -107,10 +106,7 @@ class UserLoginWithToken extends HookConsumerWidget {
|
|||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: loginAndSave,
|
||||
child: const Text('Login'),
|
||||
),
|
||||
ElevatedButton(onPressed: loginAndSave, child: const Text('Login')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -126,9 +126,7 @@ class PlaybackReporter {
|
|||
}
|
||||
|
||||
Future<void> tryReportPlayback(_) async {
|
||||
_logger.fine(
|
||||
'callback called when elapsed ${_stopwatch.elapsed}',
|
||||
);
|
||||
_logger.fine('callback called when elapsed ${_stopwatch.elapsed}');
|
||||
if (player.book != null &&
|
||||
player.positionInBook >=
|
||||
player.book!.duration - markCompleteWhenTimeLeft) {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ class PlaybackReporter extends _$PlaybackReporter {
|
|||
final deviceName = await ref.watch(deviceNameProvider.future);
|
||||
final deviceModel = await ref.watch(deviceModelProvider.future);
|
||||
final deviceSdkVersion = await ref.watch(deviceSdkVersionProvider.future);
|
||||
final deviceManufacturer =
|
||||
await ref.watch(deviceManufacturerProvider.future);
|
||||
final deviceManufacturer = await ref.watch(
|
||||
deviceManufacturerProvider.future,
|
||||
);
|
||||
|
||||
final reporter = core.PlaybackReporter(
|
||||
player,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ Duration sumOfTracks(BookExpanded book, int? index) {
|
|||
_logger.warning('Index is null or less than 0, returning 0');
|
||||
return Duration.zero;
|
||||
}
|
||||
final total = book.tracks.sublist(0, index).fold<Duration>(
|
||||
final total = book.tracks
|
||||
.sublist(0, index)
|
||||
.fold<Duration>(
|
||||
Duration.zero,
|
||||
(previousValue, element) => previousValue + element.duration,
|
||||
);
|
||||
|
|
@ -34,13 +36,10 @@ Duration sumOfTracks(BookExpanded book, int? index) {
|
|||
/// returns the [AudioTrack] to play based on the [position] in the [book]
|
||||
AudioTrack getTrackToPlay(BookExpanded book, Duration position) {
|
||||
_logger.fine('Getting track to play for position: $position');
|
||||
final track = book.tracks.firstWhere(
|
||||
(element) {
|
||||
return element.startOffset <= position &&
|
||||
(element.startOffset + element.duration) >= position;
|
||||
},
|
||||
orElse: () => book.tracks.last,
|
||||
);
|
||||
final track = book.tracks.firstWhere((element) {
|
||||
return element.startOffset <= position &&
|
||||
(element.startOffset + element.duration) >= position;
|
||||
}, orElse: () => book.tracks.last);
|
||||
_logger.fine('Track to play for position: $position is $track');
|
||||
return track;
|
||||
}
|
||||
|
|
@ -126,8 +125,12 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
ConcatenatingAudioSource(
|
||||
useLazyPreparation: true,
|
||||
children: book.tracks.map((track) {
|
||||
final retrievedUri =
|
||||
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token);
|
||||
final retrievedUri = _getUri(
|
||||
track,
|
||||
downloadedUris,
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
_logger.fine(
|
||||
'Setting source for track: ${track.title}, URI: ${retrievedUri.obfuscate()}',
|
||||
);
|
||||
|
|
@ -141,7 +144,8 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
.formatNotificationTitle(book),
|
||||
album: appSettings.notificationSettings.secondaryTitle
|
||||
.formatNotificationTitle(book),
|
||||
artUri: artworkUri ??
|
||||
artUri:
|
||||
artworkUri ??
|
||||
Uri.parse(
|
||||
'$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800',
|
||||
),
|
||||
|
|
@ -255,12 +259,9 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
if (_book!.chapters.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _book!.chapters.firstWhere(
|
||||
(element) {
|
||||
return element.start <= positionInBook && element.end >= positionInBook;
|
||||
},
|
||||
orElse: () => _book!.chapters.first,
|
||||
);
|
||||
return _book!.chapters.firstWhere((element) {
|
||||
return element.start <= positionInBook && element.end >= positionInBook;
|
||||
}, orElse: () => _book!.chapters.first);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,11 +272,9 @@ Uri _getUri(
|
|||
required String token,
|
||||
}) {
|
||||
// check if the track is in the downloadedUris
|
||||
final uri = downloadedUris?.firstWhereOrNull(
|
||||
(element) {
|
||||
return element.pathSegments.last == track.metadata?.filename;
|
||||
},
|
||||
);
|
||||
final uri = downloadedUris?.firstWhereOrNull((element) {
|
||||
return element.pathSegments.last == track.metadata?.filename;
|
||||
});
|
||||
|
||||
return uri ??
|
||||
Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token');
|
||||
|
|
@ -283,17 +282,14 @@ Uri _getUri(
|
|||
|
||||
extension FormatNotificationTitle on String {
|
||||
String formatNotificationTitle(BookExpanded book) {
|
||||
return replaceAllMapped(
|
||||
RegExp(r'\$(\w+)'),
|
||||
(match) {
|
||||
final type = match.group(1);
|
||||
return NotificationTitleType.values
|
||||
.firstWhere((element) => element.name == type)
|
||||
.extractFrom(book) ??
|
||||
match.group(0) ??
|
||||
'';
|
||||
},
|
||||
);
|
||||
return replaceAllMapped(RegExp(r'\$(\w+)'), (match) {
|
||||
final type = match.group(1);
|
||||
return NotificationTitleType.values
|
||||
.firstWhere((element) => element.name == type)
|
||||
.extractFrom(book) ??
|
||||
match.group(0) ??
|
||||
'';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,23 +30,28 @@ Future<void> configurePlayer() async {
|
|||
androidShowNotificationBadge: false,
|
||||
notificationConfigBuilder: (state) {
|
||||
final controls = [
|
||||
if (appSettings.notificationSettings.mediaControls
|
||||
.contains(NotificationMediaControl.skipToPreviousChapter) &&
|
||||
if (appSettings.notificationSettings.mediaControls.contains(
|
||||
NotificationMediaControl.skipToPreviousChapter,
|
||||
) &&
|
||||
state.hasPrevious)
|
||||
MediaControl.skipToPrevious,
|
||||
if (appSettings.notificationSettings.mediaControls
|
||||
.contains(NotificationMediaControl.rewind))
|
||||
if (appSettings.notificationSettings.mediaControls.contains(
|
||||
NotificationMediaControl.rewind,
|
||||
))
|
||||
MediaControl.rewind,
|
||||
if (state.playing) MediaControl.pause else MediaControl.play,
|
||||
if (appSettings.notificationSettings.mediaControls
|
||||
.contains(NotificationMediaControl.fastForward))
|
||||
if (appSettings.notificationSettings.mediaControls.contains(
|
||||
NotificationMediaControl.fastForward,
|
||||
))
|
||||
MediaControl.fastForward,
|
||||
if (appSettings.notificationSettings.mediaControls
|
||||
.contains(NotificationMediaControl.skipToNextChapter) &&
|
||||
if (appSettings.notificationSettings.mediaControls.contains(
|
||||
NotificationMediaControl.skipToNextChapter,
|
||||
) &&
|
||||
state.hasNext)
|
||||
MediaControl.skipToNext,
|
||||
if (appSettings.notificationSettings.mediaControls
|
||||
.contains(NotificationMediaControl.stop))
|
||||
if (appSettings.notificationSettings.mediaControls.contains(
|
||||
NotificationMediaControl.stop,
|
||||
))
|
||||
MediaControl.stop,
|
||||
];
|
||||
return NotificationConfig(
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ class AudiobookPlaylist {
|
|||
this.books = const [],
|
||||
currentIndex = 0,
|
||||
subCurrentIndex = 0,
|
||||
}) : _currentIndex = currentIndex,
|
||||
_subCurrentIndex = subCurrentIndex;
|
||||
}) : _currentIndex = currentIndex,
|
||||
_subCurrentIndex = subCurrentIndex;
|
||||
|
||||
// most important method, gets the audio file to play
|
||||
// this is needed as a library item is a list of audio files
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ class SimpleAudiobookPlayer extends _$SimpleAudiobookPlayer {
|
|||
@override
|
||||
core.AudiobookPlayer build() {
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
final player = core.AudiobookPlayer(
|
||||
api.token!,
|
||||
api.baseUrl,
|
||||
);
|
||||
final player = core.AudiobookPlayer(api.token!, api.baseUrl);
|
||||
|
||||
ref.onDispose(player.dispose);
|
||||
_logger.finer('created simple player');
|
||||
|
|
|
|||
|
|
@ -26,11 +26,10 @@ extension on Ref {
|
|||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Raw<ValueNotifier<double>> playerExpandProgressNotifier(
|
||||
Ref ref,
|
||||
) {
|
||||
final ValueNotifier<double> playerExpandProgress =
|
||||
ValueNotifier(playerMinHeight);
|
||||
Raw<ValueNotifier<double>> playerExpandProgressNotifier(Ref ref) {
|
||||
final ValueNotifier<double> playerExpandProgress = ValueNotifier(
|
||||
playerMinHeight,
|
||||
);
|
||||
|
||||
return ref.disposeAndListenChangeNotifier(playerExpandProgress);
|
||||
}
|
||||
|
|
@ -46,9 +45,7 @@ Raw<ValueNotifier<double>> playerExpandProgressNotifier(
|
|||
|
||||
// a provider that will listen to the playerExpandProgressNotifier and return the percentage of the player expanded
|
||||
@Riverpod(keepAlive: true)
|
||||
double playerHeight(
|
||||
Ref ref,
|
||||
) {
|
||||
double playerHeight(Ref ref) {
|
||||
final playerExpandProgress = ref.watch(playerExpandProgressProvider);
|
||||
|
||||
// on change of the playerExpandProgress invalidate
|
||||
|
|
@ -63,9 +60,7 @@ double playerHeight(
|
|||
final audioBookMiniplayerController = MiniplayerController();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
bool isPlayerActive(
|
||||
Ref ref,
|
||||
) {
|
||||
bool isPlayerActive(Ref ref) {
|
||||
try {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
if (player.book != null) {
|
||||
|
|
|
|||
|
|
@ -31,19 +31,15 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
if (currentBook == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final itemBeingPlayed =
|
||||
ref.watch(libraryItemProvider(currentBook.libraryItemId));
|
||||
final itemBeingPlayed = ref.watch(
|
||||
libraryItemProvider(currentBook.libraryItemId),
|
||||
);
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
final imageOfItemBeingPlayed = itemBeingPlayed.value != null
|
||||
? ref.watch(
|
||||
coverImageProvider(itemBeingPlayed.value!.id),
|
||||
)
|
||||
? ref.watch(coverImageProvider(itemBeingPlayed.value!.id))
|
||||
: null;
|
||||
final imgWidget = imageOfItemBeingPlayed?.value != null
|
||||
? Image.memory(
|
||||
imageOfItemBeingPlayed!.value!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
? Image.memory(imageOfItemBeingPlayed!.value!, fit: BoxFit.cover)
|
||||
: const BookCoverSkeleton();
|
||||
|
||||
final playPauseController = useAnimationController(
|
||||
|
|
@ -65,7 +61,8 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
themeOfLibraryItemProvider(
|
||||
itemBeingPlayed.value?.id,
|
||||
brightness: Theme.of(context).brightness,
|
||||
highContrast: appSettings.themeSettings.highContrast ||
|
||||
highContrast:
|
||||
appSettings.themeSettings.highContrast ||
|
||||
MediaQuery.of(context).highContrast,
|
||||
),
|
||||
);
|
||||
|
|
@ -88,8 +85,9 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
onDragDown: (percentage) async {
|
||||
// preferred volume
|
||||
// set volume to 0 when dragging down
|
||||
await player
|
||||
.setVolume(preferredVolume * (1 - percentage.clamp(0, .75)));
|
||||
await player.setVolume(
|
||||
preferredVolume * (1 - percentage.clamp(0, .75)),
|
||||
);
|
||||
},
|
||||
minHeight: playerMinHeight,
|
||||
// subtract the height of notches and other system UI
|
||||
|
|
@ -109,17 +107,14 @@ class AudiobookPlayer extends HookConsumerWidget {
|
|||
// also at this point the image should be at its max size and in the center of the player
|
||||
final miniplayerPercentageDeclaration =
|
||||
(maxImgSize - playerMinHeight) /
|
||||
(playerMaxHeight - playerMinHeight);
|
||||
(playerMaxHeight - playerMinHeight);
|
||||
final bool isFormMiniplayer =
|
||||
percentage < miniplayerPercentageDeclaration;
|
||||
|
||||
if (!isFormMiniplayer) {
|
||||
// this calculation needs a refactor
|
||||
var percentageExpandedPlayer = percentage
|
||||
.inverseLerp(
|
||||
miniplayerPercentageDeclaration,
|
||||
1,
|
||||
)
|
||||
.inverseLerp(miniplayerPercentageDeclaration, 1)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return PlayerWhenExpanded(
|
||||
|
|
@ -164,37 +159,33 @@ class AudiobookPlayerPlayPauseButton extends HookConsumerWidget {
|
|||
|
||||
return switch (player.processingState) {
|
||||
ProcessingState.loading || ProcessingState.buffering => const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await player.seek(const Duration(seconds: 0));
|
||||
await player.play();
|
||||
},
|
||||
icon: const Icon(Icons.replay),
|
||||
),
|
||||
ProcessingState.ready => IconButton(
|
||||
onPressed: () async {
|
||||
await player.togglePlayPause();
|
||||
},
|
||||
iconSize: iconSize,
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: playPauseController,
|
||||
),
|
||||
onPressed: () async {
|
||||
await player.togglePlayPause();
|
||||
},
|
||||
iconSize: iconSize,
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: playPauseController,
|
||||
),
|
||||
),
|
||||
ProcessingState.idle => const SizedBox.shrink(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AudiobookChapterProgressBar extends HookConsumerWidget {
|
||||
const AudiobookChapterProgressBar({
|
||||
super.key,
|
||||
});
|
||||
const AudiobookChapterProgressBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
|
|||
|
|
@ -38,10 +38,7 @@ class PlayerWhenExpanded extends HookConsumerWidget {
|
|||
const lateStart = 0.4;
|
||||
const earlyEnd = 1;
|
||||
final earlyPercentage = percentageExpandedPlayer
|
||||
.inverseLerp(
|
||||
lateStart,
|
||||
earlyEnd,
|
||||
)
|
||||
.inverseLerp(lateStart, earlyEnd)
|
||||
.clamp(0.0, 1.0);
|
||||
final currentChapter = ref.watch(currentPlayingChapterProvider);
|
||||
final currentBookMetadata = ref.watch(currentBookMetadataProvider);
|
||||
|
|
@ -49,15 +46,11 @@ class PlayerWhenExpanded extends HookConsumerWidget {
|
|||
return Column(
|
||||
children: [
|
||||
// sized box for system status bar; not needed as not full screen
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.top * earlyPercentage,
|
||||
),
|
||||
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,
|
||||
),
|
||||
constraints: BoxConstraints(maxHeight: 100 * earlyPercentage),
|
||||
child: Opacity(
|
||||
opacity: earlyPercentage,
|
||||
child: Padding(
|
||||
|
|
@ -104,10 +97,9 @@ class PlayerWhenExpanded extends HookConsumerWidget {
|
|||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.1),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
blurRadius: 32 * earlyPercentage,
|
||||
spreadRadius: 8 * earlyPercentage,
|
||||
// offset: Offset(0, 16 * earlyPercentage),
|
||||
|
|
@ -170,11 +162,10 @@ class PlayerWhenExpanded extends HookConsumerWidget {
|
|||
currentBookMetadata?.authorName ?? '',
|
||||
].join(' - '),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -32,8 +32,10 @@ class PlayerWhenMinimized extends HookConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
final vanishingPercentage = 1 - percentageMiniplayer;
|
||||
final progress =
|
||||
useStream(player.slowPositionStream, initialData: Duration.zero);
|
||||
final progress = useStream(
|
||||
player.slowPositionStream,
|
||||
initialData: Duration.zero,
|
||||
);
|
||||
|
||||
final bookMetaExpanded = ref.watch(currentBookMetadataProvider);
|
||||
|
||||
|
|
@ -61,9 +63,7 @@ class PlayerWhenMinimized extends HookConsumerWidget {
|
|||
);
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxImgSize,
|
||||
),
|
||||
constraints: BoxConstraints(maxWidth: maxImgSize),
|
||||
child: imgWidget,
|
||||
),
|
||||
),
|
||||
|
|
@ -80,7 +80,8 @@ class PlayerWhenMinimized extends HookConsumerWidget {
|
|||
// AutoScrollText(
|
||||
Text(
|
||||
bookMetaExpanded?.title ?? '',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// velocity:
|
||||
// const Velocity(pixelsPerSecond: Offset(16, 0)),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
|
|
@ -90,11 +91,10 @@ class PlayerWhenMinimized extends HookConsumerWidget {
|
|||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -135,7 +135,8 @@ class PlayerWhenMinimized extends HookConsumerWidget {
|
|||
SizedBox(
|
||||
height: barHeight,
|
||||
child: LinearProgressIndicator(
|
||||
value: (progress.data ?? Duration.zero).inSeconds /
|
||||
value:
|
||||
(progress.data ?? Duration.zero).inSeconds /
|
||||
player.book!.duration.inSeconds,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import 'package:vaani/constants/sizes.dart';
|
|||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
||||
class AudiobookPlayerSeekButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerSeekButton({
|
||||
super.key,
|
||||
required this.isForward,
|
||||
});
|
||||
const AudiobookPlayerSeekButton({super.key, required this.isForward});
|
||||
|
||||
/// if true, the button seeks forward, else it seeks backwards
|
||||
final bool isForward;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import 'package:vaani/constants/sizes.dart';
|
|||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
||||
class AudiobookPlayerSeekChapterButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerSeekChapterButton({
|
||||
super.key,
|
||||
required this.isForward,
|
||||
});
|
||||
const AudiobookPlayerSeekChapterButton({super.key, required this.isForward});
|
||||
|
||||
/// if true, the button seeks forward, else it seeks backwards
|
||||
final bool isForward;
|
||||
|
|
@ -27,9 +24,7 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget {
|
|||
void seekForward() {
|
||||
final index = player.book!.chapters.indexOf(player.currentChapter!);
|
||||
if (index < player.book!.chapters.length - 1) {
|
||||
player.seek(
|
||||
player.book!.chapters[index + 1].start + offset,
|
||||
);
|
||||
player.seek(player.book!.chapters[index + 1].start + offset);
|
||||
} else {
|
||||
player.seek(player.currentChapter!.end);
|
||||
}
|
||||
|
|
@ -37,8 +32,9 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget {
|
|||
|
||||
/// seek backward to the previous chapter or the start of the current chapter
|
||||
void seekBackward() {
|
||||
final currentPlayingChapterIndex =
|
||||
player.book!.chapters.indexOf(player.currentChapter!);
|
||||
final currentPlayingChapterIndex = player.book!.chapters.indexOf(
|
||||
player.currentChapter!,
|
||||
);
|
||||
final chapterPosition =
|
||||
player.positionInBook - player.currentChapter!.start;
|
||||
BookChapter chapterToSeekTo;
|
||||
|
|
@ -49,9 +45,7 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget {
|
|||
} else {
|
||||
chapterToSeekTo = player.currentChapter!;
|
||||
}
|
||||
player.seek(
|
||||
chapterToSeekTo.start + offset,
|
||||
);
|
||||
player.seek(chapterToSeekTo.start + offset);
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ import 'package:vaani/shared/extensions/duration_format.dart'
|
|||
import 'package:vaani/shared/hooks.dart' show useTimer;
|
||||
|
||||
class ChapterSelectionButton extends HookConsumerWidget {
|
||||
const ChapterSelectionButton({
|
||||
super.key,
|
||||
});
|
||||
const ChapterSelectionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -49,9 +47,7 @@ class ChapterSelectionButton extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class ChapterSelectionModal extends HookConsumerWidget {
|
||||
const ChapterSelectionModal({
|
||||
super.key,
|
||||
});
|
||||
const ChapterSelectionModal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -87,41 +83,40 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
|||
child: currentBook?.chapters == null
|
||||
? const Text('No chapters found')
|
||||
: Column(
|
||||
children: currentBook!.chapters.map(
|
||||
(chapter) {
|
||||
final isCurrent = currentChapterIndex == chapter.id;
|
||||
final isPlayed = currentChapterIndex != null &&
|
||||
chapter.id < currentChapterIndex;
|
||||
return ListTile(
|
||||
autofocus: isCurrent,
|
||||
iconColor: isPlayed && !isCurrent
|
||||
? theme.disabledColor
|
||||
children: currentBook!.chapters.map((chapter) {
|
||||
final isCurrent = currentChapterIndex == chapter.id;
|
||||
final isPlayed =
|
||||
currentChapterIndex != null &&
|
||||
chapter.id < currentChapterIndex;
|
||||
return ListTile(
|
||||
autofocus: isCurrent,
|
||||
iconColor: isPlayed && !isCurrent
|
||||
? theme.disabledColor
|
||||
: null,
|
||||
title: Text(
|
||||
chapter.title,
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
title: Text(
|
||||
chapter.title,
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: isCurrent
|
||||
? const PlayingIndicatorIcon()
|
||||
: const Icon(Icons.play_arrow),
|
||||
selected: isCurrent,
|
||||
key: isCurrent ? chapterKey : null,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
notifier.seek(chapter.start + 90.ms);
|
||||
notifier.play();
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
subtitle: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: isCurrent
|
||||
? const PlayingIndicatorIcon()
|
||||
: const Icon(Icons.play_arrow),
|
||||
selected: isCurrent,
|
||||
key: isCurrent ? chapterKey : null,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
notifier.seek(chapter.start + 90.ms);
|
||||
notifier.play();
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ import 'package:vaani/settings/app_settings_provider.dart';
|
|||
final _logger = Logger('PlayerSpeedAdjustButton');
|
||||
|
||||
class PlayerSpeedAdjustButton extends HookConsumerWidget {
|
||||
const PlayerSpeedAdjustButton({
|
||||
super.key,
|
||||
});
|
||||
const PlayerSpeedAdjustButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -35,21 +33,19 @@ class PlayerSpeedAdjustButton extends HookConsumerWidget {
|
|||
notifier.setSpeed(speed);
|
||||
if (appSettings.playerSettings.configurePlayerForEveryBook) {
|
||||
ref
|
||||
.read(
|
||||
bookSettingsProvider(bookId).notifier,
|
||||
)
|
||||
.read(bookSettingsProvider(bookId).notifier)
|
||||
.update(
|
||||
bookSettings.copyWith
|
||||
.playerSettings(preferredDefaultSpeed: speed),
|
||||
bookSettings.copyWith.playerSettings(
|
||||
preferredDefaultSpeed: speed,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(
|
||||
appSettingsProvider.notifier,
|
||||
)
|
||||
.read(appSettingsProvider.notifier)
|
||||
.update(
|
||||
appSettings.copyWith
|
||||
.playerSettings(preferredDefaultSpeed: speed),
|
||||
appSettings.copyWith.playerSettings(
|
||||
preferredDefaultSpeed: speed,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,8 +59,11 @@ class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationParams =
|
||||
List.generate(widget.barCount, _createRandomParams, growable: false);
|
||||
_animationParams = List.generate(
|
||||
widget.barCount,
|
||||
_createRandomParams,
|
||||
growable: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to generate random parameters for one bar's animation cycle
|
||||
|
|
@ -72,10 +75,12 @@ class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
|||
|
||||
// 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 +
|
||||
final targetHeightFactor1 =
|
||||
widget.minHeightFactor +
|
||||
_random.nextDouble() *
|
||||
(widget.maxHeightFactor - widget.minHeightFactor);
|
||||
final targetHeightFactor2 = widget.minHeightFactor +
|
||||
final targetHeightFactor2 =
|
||||
widget.minHeightFactor +
|
||||
_random.nextDouble() *
|
||||
(widget.maxHeightFactor - widget.minHeightFactor);
|
||||
|
||||
|
|
@ -95,7 +100,8 @@ class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.color ??
|
||||
final color =
|
||||
widget.color ??
|
||||
IconTheme.of(context).color ??
|
||||
Theme.of(context).colorScheme.primary;
|
||||
|
||||
|
|
@ -110,8 +116,9 @@ class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
|||
final double maxHeight = widget.size;
|
||||
|
||||
// Determine the alignment for scaling based on the symmetric flag
|
||||
final Alignment scaleAlignment =
|
||||
widget.centerSymmetric ? Alignment.center : Alignment.bottomCenter;
|
||||
final Alignment scaleAlignment = widget.centerSymmetric
|
||||
? Alignment.center
|
||||
: Alignment.bottomCenter;
|
||||
|
||||
// Determine the cross axis alignment for the Row
|
||||
final CrossAxisAlignment rowAlignment = widget.centerSymmetric
|
||||
|
|
@ -129,47 +136,40 @@ class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
|||
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,
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ import 'package:vaani/settings/app_settings_provider.dart';
|
|||
const double itemExtent = 25;
|
||||
|
||||
class SpeedSelector extends HookConsumerWidget {
|
||||
const SpeedSelector({
|
||||
super.key,
|
||||
required this.onSpeedSelected,
|
||||
});
|
||||
const SpeedSelector({super.key, required this.onSpeedSelected});
|
||||
|
||||
final void Function(double speed) onSpeedSelected;
|
||||
|
||||
|
|
@ -26,34 +23,22 @@ class SpeedSelector extends HookConsumerWidget {
|
|||
final speedState = useState(currentSpeed);
|
||||
|
||||
// hook the onSpeedSelected function to the state
|
||||
useEffect(
|
||||
() {
|
||||
onSpeedSelected(speedState.value);
|
||||
return null;
|
||||
},
|
||||
[speedState.value],
|
||||
);
|
||||
useEffect(() {
|
||||
onSpeedSelected(speedState.value);
|
||||
return null;
|
||||
}, [speedState.value]);
|
||||
|
||||
// the speed options
|
||||
final minSpeed = min(
|
||||
speeds.reduce(min),
|
||||
playerSettings.minSpeed,
|
||||
);
|
||||
final maxSpeed = max(
|
||||
speeds.reduce(max),
|
||||
playerSettings.maxSpeed,
|
||||
);
|
||||
final minSpeed = min(speeds.reduce(min), playerSettings.minSpeed);
|
||||
final maxSpeed = max(speeds.reduce(max), playerSettings.maxSpeed);
|
||||
final speedIncrement = playerSettings.speedIncrement;
|
||||
final availableSpeeds = ((maxSpeed - minSpeed) / speedIncrement).ceil() + 1;
|
||||
final availableSpeedsList = List.generate(
|
||||
availableSpeeds,
|
||||
(index) {
|
||||
// need to round to 2 decimal place to avoid floating point errors
|
||||
return double.parse(
|
||||
(minSpeed + index * speedIncrement).toStringAsFixed(2),
|
||||
);
|
||||
},
|
||||
);
|
||||
final availableSpeedsList = List.generate(availableSpeeds, (index) {
|
||||
// need to round to 2 decimal place to avoid floating point errors
|
||||
return double.parse(
|
||||
(minSpeed + index * speedIncrement).toStringAsFixed(2),
|
||||
);
|
||||
});
|
||||
|
||||
final scrollController = useFixedExtentScrollController(
|
||||
initialItem: availableSpeedsList.indexOf(currentSpeed),
|
||||
|
|
@ -107,18 +92,19 @@ class SpeedSelector extends HookConsumerWidget {
|
|||
(speed) => TextButton(
|
||||
style: speed == speedState.value
|
||||
? TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
)
|
||||
// border if not selected
|
||||
: TextButton.styleFrom(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
|
|
@ -195,14 +181,13 @@ class SpeedWheel extends StatelessWidget {
|
|||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemExtent: itemExtent,
|
||||
diameterRatio: 1.5, squeeze: 1.2,
|
||||
diameterRatio: 1.5,
|
||||
squeeze: 1.2,
|
||||
// useMagnifier: true,
|
||||
// magnification: 1.5,
|
||||
physics: const FixedExtentScrollPhysics(),
|
||||
children: availableSpeedsList
|
||||
.map(
|
||||
(speed) => SpeedLine(speed: speed),
|
||||
)
|
||||
.map((speed) => SpeedLine(speed: speed))
|
||||
.toList(),
|
||||
onSelectedItemChanged: (index) {
|
||||
speedState.value = availableSpeedsList[index];
|
||||
|
|
@ -232,10 +217,7 @@ class SpeedWheel extends StatelessWidget {
|
|||
}
|
||||
|
||||
class SpeedLine extends StatelessWidget {
|
||||
const SpeedLine({
|
||||
super.key,
|
||||
required this.speed,
|
||||
});
|
||||
const SpeedLine({super.key, required this.speed});
|
||||
|
||||
final double speed;
|
||||
|
||||
|
|
@ -250,8 +232,8 @@ class SpeedLine extends StatelessWidget {
|
|||
width: speed % 0.5 == 0
|
||||
? 3
|
||||
: speed % 0.25 == 0
|
||||
? 2
|
||||
: 0.5,
|
||||
? 2
|
||||
: 0.5,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class ShakeDetector {
|
|||
DateTime _lastShakeTime = DateTime.now();
|
||||
|
||||
final StreamController<UserAccelerometerEvent>
|
||||
_detectedShakeStreamController = StreamController.broadcast();
|
||||
_detectedShakeStreamController = StreamController.broadcast();
|
||||
|
||||
void start() {
|
||||
if (_accelerometerSubscription != null) {
|
||||
|
|
@ -37,26 +37,27 @@ class ShakeDetector {
|
|||
return;
|
||||
}
|
||||
_accelerometerSubscription =
|
||||
userAccelerometerEventStream(samplingPeriod: _settings.samplingPeriod)
|
||||
.listen((event) {
|
||||
_logger.finest('RMS: ${event.rms}');
|
||||
if (event.rms > _settings.threshold) {
|
||||
_currentShakeCount++;
|
||||
userAccelerometerEventStream(
|
||||
samplingPeriod: _settings.samplingPeriod,
|
||||
).listen((event) {
|
||||
_logger.finest('RMS: ${event.rms}');
|
||||
if (event.rms > _settings.threshold) {
|
||||
_currentShakeCount++;
|
||||
|
||||
if (_currentShakeCount >= _settings.shakeTriggerCount &&
|
||||
!isCoolDownNeeded()) {
|
||||
_logger.fine('Shake detected $_currentShakeCount times');
|
||||
if (_currentShakeCount >= _settings.shakeTriggerCount &&
|
||||
!isCoolDownNeeded()) {
|
||||
_logger.fine('Shake detected $_currentShakeCount times');
|
||||
|
||||
onShakeDetected?.call();
|
||||
_detectedShakeStreamController.add(event);
|
||||
onShakeDetected?.call();
|
||||
_detectedShakeStreamController.add(event);
|
||||
|
||||
_lastShakeTime = DateTime.now();
|
||||
_currentShakeCount = 0;
|
||||
}
|
||||
} else {
|
||||
_currentShakeCount = 0;
|
||||
}
|
||||
});
|
||||
_lastShakeTime = DateTime.now();
|
||||
_currentShakeCount = 0;
|
||||
}
|
||||
} else {
|
||||
_currentShakeCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
_logger.fine('ShakeDetector started');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,34 +59,29 @@ class ShakeDetector extends _$ShakeDetector {
|
|||
final sleepTimer = ref.watch(sleepTimerProvider);
|
||||
if (!shakeDetectionSettings.shakeAction.isPlaybackManagementEnabled &&
|
||||
sleepTimer == null) {
|
||||
_logger
|
||||
.config('No playback management is enabled and sleep timer is off, '
|
||||
'so shake detection is disabled');
|
||||
_logger.config(
|
||||
'No playback management is enabled and sleep timer is off, '
|
||||
'so shake detection is disabled',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.config('Creating shake detector');
|
||||
final detector = core.ShakeDetector(
|
||||
shakeDetectionSettings,
|
||||
() {
|
||||
final wasActionComplete = doShakeAction(
|
||||
shakeDetectionSettings.shakeAction,
|
||||
ref: ref,
|
||||
);
|
||||
if (wasActionComplete) {
|
||||
shakeDetectionSettings.feedback.forEach(postShakeFeedback);
|
||||
}
|
||||
},
|
||||
);
|
||||
final detector = core.ShakeDetector(shakeDetectionSettings, () {
|
||||
final wasActionComplete = doShakeAction(
|
||||
shakeDetectionSettings.shakeAction,
|
||||
ref: ref,
|
||||
);
|
||||
if (wasActionComplete) {
|
||||
shakeDetectionSettings.feedback.forEach(postShakeFeedback);
|
||||
}
|
||||
});
|
||||
ref.onDispose(detector.dispose);
|
||||
return detector;
|
||||
}
|
||||
|
||||
/// Perform the shake action and return whether the action was successful
|
||||
bool doShakeAction(
|
||||
ShakeAction shakeAction, {
|
||||
required Ref ref,
|
||||
}) {
|
||||
bool doShakeAction(ShakeAction shakeAction, {required Ref ref}) {
|
||||
final player = ref.read(simpleAudiobookPlayerProvider);
|
||||
if (player.book == null && shakeAction.isPlaybackManagementEnabled) {
|
||||
_logger.warning('No book is loaded');
|
||||
|
|
@ -166,8 +161,11 @@ extension on ShakeAction {
|
|||
}
|
||||
|
||||
bool get isPlaybackManagementEnabled {
|
||||
return {ShakeAction.playPause, ShakeAction.fastForward, ShakeAction.rewind}
|
||||
.contains(this);
|
||||
return {
|
||||
ShakeAction.playPause,
|
||||
ShakeAction.fastForward,
|
||||
ShakeAction.rewind,
|
||||
}.contains(this);
|
||||
}
|
||||
|
||||
bool get shouldActOnSleepTimer {
|
||||
|
|
|
|||
|
|
@ -94,9 +94,7 @@ class SleepTimer {
|
|||
}
|
||||
|
||||
/// starts the timer with the given duration or the default duration
|
||||
void startCountDown([
|
||||
Duration? forDuration,
|
||||
]) {
|
||||
void startCountDown([Duration? forDuration]) {
|
||||
clearCountDownTimer();
|
||||
duration = forDuration ?? duration;
|
||||
timer = Timer(duration, () {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import 'package:vaani/settings/app_settings_provider.dart';
|
|||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
|
||||
class SleepTimerButton extends HookConsumerWidget {
|
||||
const SleepTimerButton({
|
||||
super.key,
|
||||
});
|
||||
const SleepTimerButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -47,8 +45,9 @@ class SleepTimerButton extends HookConsumerWidget {
|
|||
);
|
||||
pendingPlayerModals--;
|
||||
ref.read(sleepTimerProvider.notifier).setTimer(durationState.value);
|
||||
appLogger
|
||||
.fine('Sleep Timer dialog closed with ${durationState.value}');
|
||||
appLogger.fine(
|
||||
'Sleep Timer dialog closed with ${durationState.value}',
|
||||
);
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
|
|
@ -57,9 +56,7 @@ class SleepTimerButton extends HookConsumerWidget {
|
|||
Symbols.bedtime,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
)
|
||||
: RemainingSleepTimeDisplay(
|
||||
timer: sleepTimer,
|
||||
),
|
||||
: RemainingSleepTimeDisplay(timer: sleepTimer),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -67,10 +64,7 @@ class SleepTimerButton extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class SleepTimerBottomSheet extends HookConsumerWidget {
|
||||
const SleepTimerBottomSheet({
|
||||
super.key,
|
||||
this.onDurationSelected,
|
||||
});
|
||||
const SleepTimerBottomSheet({super.key, this.onDurationSelected});
|
||||
|
||||
final void Function(Duration?)? onDurationSelected;
|
||||
|
||||
|
|
@ -91,8 +85,9 @@ class SleepTimerBottomSheet extends HookConsumerWidget {
|
|||
];
|
||||
|
||||
final scrollController = useFixedExtentScrollController(
|
||||
initialItem:
|
||||
allPossibleDurations.indexOf(sleepTimer?.duration ?? minDuration),
|
||||
initialItem: allPossibleDurations.indexOf(
|
||||
sleepTimer?.duration ?? minDuration,
|
||||
),
|
||||
);
|
||||
|
||||
final durationState = useState<Duration>(
|
||||
|
|
@ -100,13 +95,10 @@ class SleepTimerBottomSheet extends HookConsumerWidget {
|
|||
);
|
||||
|
||||
// useEffect to rebuild the sleep timer when the duration changes
|
||||
useEffect(
|
||||
() {
|
||||
onDurationSelected?.call(durationState.value);
|
||||
return null;
|
||||
},
|
||||
[durationState.value],
|
||||
);
|
||||
useEffect(() {
|
||||
onDurationSelected?.call(durationState.value);
|
||||
return null;
|
||||
}, [durationState.value]);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -171,18 +163,19 @@ class SleepTimerBottomSheet extends HookConsumerWidget {
|
|||
(timerDuration) => TextButton(
|
||||
style: timerDuration == durationState.value
|
||||
? TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
)
|
||||
// border if not selected
|
||||
: TextButton.styleFrom(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
|
|
@ -215,10 +208,7 @@ class SleepTimerBottomSheet extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class RemainingSleepTimeDisplay extends HookConsumerWidget {
|
||||
const RemainingSleepTimeDisplay({
|
||||
super.key,
|
||||
required this.timer,
|
||||
});
|
||||
const RemainingSleepTimeDisplay({super.key, required this.timer});
|
||||
|
||||
final SleepTimer timer;
|
||||
|
||||
|
|
@ -230,17 +220,14 @@ class RemainingSleepTimeDisplay extends HookConsumerWidget {
|
|||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
timer.timer == null
|
||||
? timer.duration.smartBinaryFormat
|
||||
: remainingTime?.smartBinaryFormat ?? '',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -272,8 +259,9 @@ class SleepTimerWheel extends StatelessWidget {
|
|||
icon: const Icon(Icons.remove),
|
||||
onPressed: () {
|
||||
// animate to index - 1
|
||||
final index = availableDurations
|
||||
.indexOf(durationState.value ?? Duration.zero);
|
||||
final index = availableDurations.indexOf(
|
||||
durationState.value ?? Duration.zero,
|
||||
);
|
||||
if (index > 0) {
|
||||
scrollController.animateToItem(
|
||||
index - 1,
|
||||
|
|
@ -289,14 +277,13 @@ class SleepTimerWheel extends StatelessWidget {
|
|||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemExtent: itemExtent,
|
||||
diameterRatio: 1.5, squeeze: 1.2,
|
||||
diameterRatio: 1.5,
|
||||
squeeze: 1.2,
|
||||
// useMagnifier: true,
|
||||
// magnification: 1.5,
|
||||
physics: const FixedExtentScrollPhysics(),
|
||||
children: availableDurations
|
||||
.map(
|
||||
(duration) => DurationLine(duration: duration),
|
||||
)
|
||||
.map((duration) => DurationLine(duration: duration))
|
||||
.toList(),
|
||||
onSelectedItemChanged: (index) {
|
||||
durationState.value = availableDurations[index];
|
||||
|
|
@ -310,8 +297,9 @@ class SleepTimerWheel extends StatelessWidget {
|
|||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
// animate to index + 1
|
||||
final index = availableDurations
|
||||
.indexOf(durationState.value ?? Duration.zero);
|
||||
final index = availableDurations.indexOf(
|
||||
durationState.value ?? Duration.zero,
|
||||
);
|
||||
if (index < availableDurations.length - 1) {
|
||||
scrollController.animateToItem(
|
||||
index + 1,
|
||||
|
|
@ -327,10 +315,7 @@ class SleepTimerWheel extends StatelessWidget {
|
|||
}
|
||||
|
||||
class DurationLine extends StatelessWidget {
|
||||
const DurationLine({
|
||||
super.key,
|
||||
required this.duration,
|
||||
});
|
||||
const DurationLine({super.key, required this.duration});
|
||||
|
||||
final Duration duration;
|
||||
|
||||
|
|
@ -345,8 +330,8 @@ class DurationLine extends StatelessWidget {
|
|||
width: duration.inMinutes % 5 == 0
|
||||
? 3
|
||||
: duration.inMinutes % 2.5 == 0
|
||||
? 2
|
||||
: 0.5,
|
||||
? 2
|
||||
: 0.5,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,16 +20,12 @@ import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet;
|
|||
import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer;
|
||||
|
||||
class ServerManagerPage extends HookConsumerWidget {
|
||||
const ServerManagerPage({
|
||||
super.key,
|
||||
});
|
||||
const ServerManagerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Manage Accounts'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('Manage Accounts')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
|
|
@ -41,9 +37,7 @@ class ServerManagerPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class ServerManagerBody extends HookConsumerWidget {
|
||||
const ServerManagerBody({
|
||||
super.key,
|
||||
});
|
||||
const ServerManagerBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -61,9 +55,7 @@ class ServerManagerBody extends HookConsumerWidget {
|
|||
// crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
'Registered Servers',
|
||||
),
|
||||
const Text('Registered Servers'),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: registeredServers.length,
|
||||
|
|
@ -76,21 +68,17 @@ class ServerManagerBody extends HookConsumerWidget {
|
|||
'Users: ${availableUsers.where((element) => element.server == registeredServer).length}',
|
||||
),
|
||||
// children are list of users of this server
|
||||
children: availableUsers
|
||||
.where(
|
||||
(element) => element.server == registeredServer,
|
||||
)
|
||||
.map<Widget>(
|
||||
(e) => AvailableUserTile(user: e),
|
||||
)
|
||||
.nonNulls
|
||||
.toList()
|
||||
|
||||
// add buttons of delete server and add user to server at the end
|
||||
..addAll([
|
||||
AddUserTile(server: registeredServer),
|
||||
DeleteServerTile(server: registeredServer),
|
||||
]),
|
||||
children:
|
||||
availableUsers
|
||||
.where((element) => element.server == registeredServer)
|
||||
.map<Widget>((e) => AvailableUserTile(user: e))
|
||||
.nonNulls
|
||||
.toList()
|
||||
// add buttons of delete server and add user to server at the end
|
||||
..addAll([
|
||||
AddUserTile(server: registeredServer),
|
||||
DeleteServerTile(server: registeredServer),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -111,28 +99,24 @@ class ServerManagerBody extends HookConsumerWidget {
|
|||
final newServer = model.AudiobookShelfServer(
|
||||
serverUrl: makeBaseUrl(serverURIController.text),
|
||||
);
|
||||
ref.read(audiobookShelfServerProvider.notifier).addServer(
|
||||
newServer,
|
||||
);
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeServer: newServer,
|
||||
),
|
||||
ref
|
||||
.read(audiobookShelfServerProvider.notifier)
|
||||
.addServer(newServer);
|
||||
ref
|
||||
.read(apiSettingsProvider.notifier)
|
||||
.updateState(
|
||||
apiSettings.copyWith(activeServer: newServer),
|
||||
);
|
||||
serverURIController.clear();
|
||||
} on ServerAlreadyExistsException catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid URL'),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Invalid URL')));
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -144,10 +128,7 @@ class ServerManagerBody extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class DeleteServerTile extends HookConsumerWidget {
|
||||
const DeleteServerTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
});
|
||||
const DeleteServerTile({super.key, required this.server});
|
||||
|
||||
final model.AudiobookShelfServer server;
|
||||
|
||||
|
|
@ -167,9 +148,7 @@ class DeleteServerTile extends HookConsumerWidget {
|
|||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: 'This will remove the server ',
|
||||
),
|
||||
const TextSpan(text: 'This will remove the server '),
|
||||
TextSpan(
|
||||
text: server.serverUrl.host,
|
||||
style: TextStyle(
|
||||
|
|
@ -194,13 +173,8 @@ class DeleteServerTile extends HookConsumerWidget {
|
|||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
audiobookShelfServerProvider.notifier,
|
||||
)
|
||||
.removeServer(
|
||||
server,
|
||||
removeUsers: true,
|
||||
);
|
||||
.read(audiobookShelfServerProvider.notifier)
|
||||
.removeServer(server, removeUsers: true);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
|
|
@ -215,10 +189,7 @@ class DeleteServerTile extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class AddUserTile extends HookConsumerWidget {
|
||||
const AddUserTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
});
|
||||
const AddUserTile({super.key, required this.server});
|
||||
|
||||
final model.AudiobookShelfServer server;
|
||||
|
||||
|
|
@ -252,10 +223,12 @@ class AddUserTile extends HookConsumerWidget {
|
|||
label: 'Switch',
|
||||
onPressed: () {
|
||||
// Switch to the new user
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
ref.read(apiSettingsProvider).copyWith(
|
||||
activeUser: user,
|
||||
),
|
||||
ref
|
||||
.read(apiSettingsProvider.notifier)
|
||||
.updateState(
|
||||
ref
|
||||
.read(apiSettingsProvider)
|
||||
.copyWith(activeUser: user),
|
||||
);
|
||||
context.goNamed(Routes.home.name);
|
||||
},
|
||||
|
|
@ -283,10 +256,7 @@ class AddUserTile extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class AvailableUserTile extends HookConsumerWidget {
|
||||
const AvailableUserTile({
|
||||
super.key,
|
||||
required this.user,
|
||||
});
|
||||
const AvailableUserTile({super.key, required this.user});
|
||||
|
||||
final model.AuthenticatedUser user;
|
||||
|
||||
|
|
@ -303,18 +273,14 @@ class AvailableUserTile extends HookConsumerWidget {
|
|||
onTap: apiSettings.activeUser == user
|
||||
? null
|
||||
: () {
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeUser: user,
|
||||
),
|
||||
);
|
||||
ref
|
||||
.read(apiSettingsProvider.notifier)
|
||||
.updateState(apiSettings.copyWith(activeUser: user));
|
||||
// pop all routes and go to the home page
|
||||
// while (context.canPop()) {
|
||||
// context.pop();
|
||||
// }
|
||||
context.goNamed(
|
||||
Routes.home.name,
|
||||
);
|
||||
context.goNamed(Routes.home.name);
|
||||
},
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
|
|
@ -337,9 +303,7 @@ class AvailableUserTile extends HookConsumerWidget {
|
|||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' from this app.',
|
||||
),
|
||||
const TextSpan(text: ' from this app.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -353,9 +317,7 @@ class AvailableUserTile extends HookConsumerWidget {
|
|||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
authenticatedUsersProvider.notifier,
|
||||
)
|
||||
.read(authenticatedUsersProvider.notifier)
|
||||
.removeUser(user);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:vaani/main.dart' show appLogger;
|
||||
|
||||
class LibrarySwitchChip extends HookConsumerWidget {
|
||||
const LibrarySwitchChip({
|
||||
super.key,
|
||||
required this.libraries,
|
||||
});
|
||||
const LibrarySwitchChip({super.key, required this.libraries});
|
||||
final List<Library> libraries;
|
||||
|
||||
@override
|
||||
|
|
@ -26,30 +23,22 @@ class LibrarySwitchChip extends HookConsumerWidget {
|
|||
AbsIcons.getIconByName(
|
||||
apiSettings.activeLibraryId != null
|
||||
? libraries
|
||||
.firstWhere(
|
||||
(lib) => lib.id == apiSettings.activeLibraryId,
|
||||
)
|
||||
.icon
|
||||
.firstWhere((lib) => lib.id == apiSettings.activeLibraryId)
|
||||
.icon
|
||||
: libraries.first.icon,
|
||||
),
|
||||
), // Replace with your icon
|
||||
label: const Text('Change Library'),
|
||||
// Enable only if libraries are loaded and not empty
|
||||
onPressed: libraries.isNotEmpty
|
||||
? () => showLibrarySwitcher(
|
||||
context,
|
||||
ref,
|
||||
)
|
||||
? () => showLibrarySwitcher(context, ref)
|
||||
: null, // Disable if no libraries
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper Function to Show the Switcher ---
|
||||
void showLibrarySwitcher(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
void showLibrarySwitcher(BuildContext context, WidgetRef ref) {
|
||||
final content = _LibrarySelectionContent();
|
||||
|
||||
// --- Platform-Specific UI ---
|
||||
|
|
@ -209,7 +198,9 @@ class _LibrarySelectionContent extends ConsumerWidget {
|
|||
// Get current settings state
|
||||
final currentSettings = ref.read(apiSettingsProvider);
|
||||
// Update the active library ID
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
ref
|
||||
.read(apiSettingsProvider.notifier)
|
||||
.updateState(
|
||||
currentSettings.copyWith(activeLibraryId: library.id),
|
||||
);
|
||||
// Close the dialog/bottom sheet
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ import 'package:vaani/shared/widgets/not_implemented.dart';
|
|||
import 'package:vaani/shared/widgets/vaani_logo.dart';
|
||||
|
||||
class YouPage extends HookConsumerWidget {
|
||||
const YouPage({
|
||||
super.key,
|
||||
});
|
||||
const YouPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -88,8 +86,9 @@ class YouPage extends HookConsumerWidget {
|
|||
// Maybe show error details or allow retry
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Failed to load libraries: $error'),
|
||||
content: Text(
|
||||
'Failed to load libraries: $error',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -159,9 +158,7 @@ class YouPage extends HookConsumerWidget {
|
|||
Theme.of(context).colorScheme.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: const VaaniLogo(
|
||||
size: 48,
|
||||
),
|
||||
child: const VaaniLogo(size: 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -176,9 +173,7 @@ class YouPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
class UserBar extends HookConsumerWidget {
|
||||
const UserBar({
|
||||
super.key,
|
||||
});
|
||||
const UserBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -217,8 +212,9 @@ class UserBar extends HookConsumerWidget {
|
|||
Text(
|
||||
api.baseUrl.toString(),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
themeData.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: themeData.colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue