mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-27 21:39:31 +00:00
feat: error reporting with logs (#45)
* feat: add ability to get logs file from ui * test: add unit test for log line parsing in logs_provider * refactor: update all logs to obfuscate sensitive information * feat: generate dynamic zip file name for logs export * feat: enhance logging in audiobook player and provider for better debugging * refactor: extract user display logic into UserBar widget for offline access of settings and logs * feat: add About section with app metadata and source code link in YouPage
This commit is contained in:
parent
7b0c2c4b88
commit
35a2d7cfce
44 changed files with 861 additions and 176 deletions
|
|
@ -8,6 +8,7 @@ import 'package:logging/logging.dart';
|
|||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
final _logger = Logger('AudiobookDownloadManager');
|
||||
final tq = MemoryTaskQueue();
|
||||
|
|
@ -35,7 +36,9 @@ class AudiobookDownloadManager {
|
|||
|
||||
FileDownloader().addTaskQueue(tq);
|
||||
|
||||
_logger.fine('initialized with baseUrl: $baseUrl, token: $token');
|
||||
_logger.fine(
|
||||
'initialized with baseUrl: ${Uri.parse(baseUrl).obfuscate()} and token: ${token.obfuscate()}',
|
||||
);
|
||||
_logger.fine(
|
||||
'requiresWiFi: $requiresWiFi, retries: $retries, allowPause: $allowPause',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ class SimpleDownloadManager extends _$SimpleDownloadManager {
|
|||
core.tq.maxConcurrentByGroup = downloadSettings.maxConcurrentByGroup;
|
||||
|
||||
ref.onDispose(() {
|
||||
_logger.info('disposing download manager');
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
_logger.config('initialized download manager');
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
|
|
@ -52,12 +54,14 @@ class DownloadManager extends _$DownloadManager {
|
|||
Future<void> queueAudioBookDownload(
|
||||
LibraryItemExpanded item,
|
||||
) async {
|
||||
_logger.fine('queueing download for ${item.id}');
|
||||
await state.queueAudioBookDownload(
|
||||
item,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteDownloadedItem(LibraryItemExpanded item) async {
|
||||
_logger.fine('deleting downloaded item ${item.id}');
|
||||
await state.deleteDownloadedItem(item);
|
||||
ref.notifyListeners();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ class _DownloadHistoryProviderElement
|
|||
}
|
||||
|
||||
String _$simpleDownloadManagerHash() =>
|
||||
r'cec95717c86e422f88f78aa014d29e800e5a2089';
|
||||
r'8ab13f06ec5f2f73b73064bd285813dc890b7f36';
|
||||
|
||||
/// See also [SimpleDownloadManager].
|
||||
@ProviderFor(SimpleDownloadManager)
|
||||
|
|
@ -174,7 +174,7 @@ final simpleDownloadManagerProvider = NotifierProvider<SimpleDownloadManager,
|
|||
);
|
||||
|
||||
typedef _$SimpleDownloadManager = Notifier<core.AudiobookDownloadManager>;
|
||||
String _$downloadManagerHash() => r'7296a39439230f77abbe7d3231dae748f09c7ecf';
|
||||
String _$downloadManagerHash() => r'852012e32e613f86445afc7f7e4e85bec808e982';
|
||||
|
||||
/// See also [DownloadManager].
|
||||
@ProviderFor(DownloadManager)
|
||||
|
|
|
|||
|
|
@ -518,7 +518,7 @@ Future<void> libraryItemPlayButtonOnPressed({
|
|||
required shelfsdk.BookExpanded book,
|
||||
shelfsdk.MediaProgress? userMediaProgress,
|
||||
}) async {
|
||||
debugPrint('Pressed play/resume button');
|
||||
appLogger.info('Pressed play/resume button');
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
|
||||
final isCurrentBookSetInPlayer = player.book == book;
|
||||
|
|
@ -527,8 +527,8 @@ Future<void> libraryItemPlayButtonOnPressed({
|
|||
Future<void>? setSourceFuture;
|
||||
// set the book to the player if not already set
|
||||
if (!isCurrentBookSetInPlayer) {
|
||||
debugPrint('Setting the book ${book.libraryItemId}');
|
||||
debugPrint('Initial position: ${userMediaProgress?.currentTime}');
|
||||
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);
|
||||
|
|
@ -539,9 +539,9 @@ Future<void> libraryItemPlayButtonOnPressed({
|
|||
downloadedUris: downloadedUris,
|
||||
);
|
||||
} else {
|
||||
debugPrint('Book was already set');
|
||||
appLogger.info('Book was already set');
|
||||
if (isPlayingThisBook) {
|
||||
debugPrint('Pausing the book');
|
||||
appLogger.info('Pausing the book');
|
||||
await player.pause();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ class _BookCover extends HookConsumerWidget {
|
|||
: themeData,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.shout('Error changing theme: $e');
|
||||
appLogger.severe('Error changing theme: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
36
lib/features/logging/core/logger.dart
Normal file
36
lib/features/logging/core/logger.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:logging_appenders/logging_appenders.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
|
||||
Future<String> getLoggingFilePath() async {
|
||||
final Directory directory = await getApplicationDocumentsDirectory();
|
||||
return '${directory.path}/vaani.log';
|
||||
}
|
||||
|
||||
Future<void> initLogging() async {
|
||||
final formatter = const DefaultLogRecordFormatter();
|
||||
if (kReleaseMode) {
|
||||
Logger.root.level = Level.INFO; // is also the default
|
||||
// Write to a file
|
||||
RotatingFileAppender(
|
||||
baseFilePath: await getLoggingFilePath(),
|
||||
formatter: formatter,
|
||||
).attachToLogger(Logger.root);
|
||||
} else {
|
||||
Logger.root.level = Level.FINE; // Capture all logs
|
||||
RotatingFileAppender(
|
||||
baseFilePath: await getLoggingFilePath(),
|
||||
formatter: formatter,
|
||||
).attachToLogger(Logger.root);
|
||||
Logger.root.onRecord.listen((record) {
|
||||
// Print log records to the console
|
||||
debugPrint(
|
||||
'${record.loggerName}: ${record.level.name}: ${record.time.time}: ${record.message}',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
76
lib/features/logging/providers/logs_provider.dart
Normal file
76
lib/features/logging/providers/logs_provider.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/features/logging/core/logger.dart';
|
||||
|
||||
part 'logs_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class Logs extends _$Logs {
|
||||
@override
|
||||
Future<List<LogRecord>> build() async {
|
||||
final path = await getLoggingFilePath();
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) {
|
||||
return [];
|
||||
}
|
||||
final lines = await file.readAsLines();
|
||||
return lines.map(parseLogLine).toList();
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final path = await getLoggingFilePath();
|
||||
final file = File(path);
|
||||
await file.writeAsString('');
|
||||
state = AsyncData([]);
|
||||
}
|
||||
|
||||
Future<String> getZipFilePath() async {
|
||||
var encoder = ZipFileEncoder();
|
||||
encoder.create(await generateZipFilePath());
|
||||
encoder.addFile(File(await getLoggingFilePath()));
|
||||
encoder.close();
|
||||
return encoder.zipPath;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> generateZipFilePath() async {
|
||||
Directory appDocDirectory = await getTemporaryDirectory();
|
||||
return '${appDocDirectory.path}/${generateZipFileName()}';
|
||||
}
|
||||
|
||||
String generateZipFileName() {
|
||||
return 'vaani-${DateTime.now().toIso8601String()}.zip';
|
||||
}
|
||||
|
||||
Level parseLevel(String level) {
|
||||
return Level.LEVELS
|
||||
.firstWhere((l) => l.name == level, orElse: () => Level.ALL);
|
||||
}
|
||||
|
||||
LogRecord parseLogLine(String line) {
|
||||
// 2024-10-03 00:48:58.012400 INFO GoRouter - getting location for name: "logs"
|
||||
|
||||
final RegExp logLineRegExp = RegExp(
|
||||
r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) (\w+) (\w+) - (.+)',
|
||||
);
|
||||
|
||||
final match = logLineRegExp.firstMatch(line);
|
||||
if (match == null) {
|
||||
// return as is
|
||||
return LogRecord(Level.ALL, line, 'Unknown');
|
||||
}
|
||||
|
||||
final timeString = match.group(1)!;
|
||||
final levelString = match.group(2)!;
|
||||
final loggerName = match.group(3)!;
|
||||
final message = match.group(4)!;
|
||||
|
||||
final time = DateTime.parse(timeString);
|
||||
final level = parseLevel(levelString);
|
||||
|
||||
return LogRecord(level, message, loggerName, time);
|
||||
}
|
||||
25
lib/features/logging/providers/logs_provider.g.dart
Normal file
25
lib/features/logging/providers/logs_provider.g.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'logs_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$logsHash() => r'901376741d17ddbb889d1b7b96bc2882289720a0';
|
||||
|
||||
/// See also [Logs].
|
||||
@ProviderFor(Logs)
|
||||
final logsProvider =
|
||||
AutoDisposeAsyncNotifierProvider<Logs, List<LogRecord>>.internal(
|
||||
Logs.new,
|
||||
name: r'logsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$logsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Logs = AutoDisposeAsyncNotifier<List<LogRecord>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
266
lib/features/logging/view/logs_page.dart
Normal file
266
lib/features/logging/view/logs_page.dart
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:vaani/features/logging/providers/logs_provider.dart';
|
||||
import 'package:vaani/main.dart';
|
||||
|
||||
class LogsPage extends HookConsumerWidget {
|
||||
const LogsPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final logs = ref.watch(logsProvider);
|
||||
final theme = Theme.of(context);
|
||||
final scrollController = useScrollController();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Logs'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear logs',
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
onPressed: () async {
|
||||
// ask for confirmation
|
||||
final shouldClear = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Clear logs?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (shouldClear == true) {
|
||||
ref.read(logsProvider.notifier).clear();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Share logs',
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: () async {
|
||||
appLogger.info('Preparing logs for sharing');
|
||||
final zipLogFilePath =
|
||||
await ref.read(logsProvider.notifier).getZipFilePath();
|
||||
|
||||
// submit logs
|
||||
final result = await Share.shareXFiles([XFile(zipLogFilePath)]);
|
||||
|
||||
switch (result.status) {
|
||||
case ShareResultStatus.success:
|
||||
appLogger.info('Share success');
|
||||
break;
|
||||
case ShareResultStatus.dismissed:
|
||||
appLogger.info('Share dismissed');
|
||||
break;
|
||||
case ShareResultStatus.unavailable:
|
||||
appLogger.severe('Share unavailable');
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download logs',
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: () async {
|
||||
appLogger.info('Preparing logs for download');
|
||||
final zipLogFilePath =
|
||||
await ref.read(logsProvider.notifier).getZipFilePath();
|
||||
|
||||
// save to folder
|
||||
String? outputFile = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Please select an output file:',
|
||||
fileName: zipLogFilePath.split('/').last,
|
||||
);
|
||||
if (outputFile != null) {
|
||||
try {
|
||||
final file = File(outputFile);
|
||||
final zipFile = File(zipLogFilePath);
|
||||
await zipFile.copy(file.path);
|
||||
} catch (e) {
|
||||
appLogger.severe('Error saving file: $e');
|
||||
}
|
||||
} else {
|
||||
appLogger.info('Download cancelled');
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh logs',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(logsProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Scroll to top',
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
onPressed: () {
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// a column with listview.builder and a scrollable list of logs
|
||||
body: Column(
|
||||
children: [
|
||||
// a filter for log levels, loggers, and search
|
||||
// TODO: implement filters and search
|
||||
|
||||
Expanded(
|
||||
child: logs.when(
|
||||
data: (logRecords) {
|
||||
return Scrollbar(
|
||||
controller: scrollController,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: logRecords.length,
|
||||
itemBuilder: (context, index) {
|
||||
final logRecord = logRecords[index];
|
||||
return LogRecordTile(logRecord: logRecord, theme: theme);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Error loading logs'),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(logsProvider);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogRecordTile extends StatelessWidget {
|
||||
final LogRecord logRecord;
|
||||
final ThemeData theme;
|
||||
const LogRecordTile({
|
||||
required this.logRecord,
|
||||
required this.theme,
|
||||
super.key,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: getLogLevelColor(logRecord.level, theme),
|
||||
child: Icon(
|
||||
getLogLevelIcon(logRecord.level),
|
||||
color: getLogLevelTextColor(logRecord.level, theme),
|
||||
),
|
||||
),
|
||||
title: Text(logRecord.loggerName),
|
||||
subtitle: Text(logRecord.message),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
icon: Icon(getLogLevelIcon(logRecord.level)),
|
||||
title: Text(logRecord.loggerName),
|
||||
content: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: logRecord.time.toIso8601String(),
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
const TextSpan(text: '\n\n'),
|
||||
TextSpan(
|
||||
text: logRecord.message,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData getLogLevelIcon(Level level) {
|
||||
switch (level) {
|
||||
case Level.INFO:
|
||||
return (Icons.info);
|
||||
case Level.WARNING:
|
||||
return (Icons.warning);
|
||||
case Level.SEVERE:
|
||||
case Level.SHOUT:
|
||||
return (Icons.error);
|
||||
default:
|
||||
return (Icons.bug_report);
|
||||
}
|
||||
}
|
||||
|
||||
Color? getLogLevelColor(Level level, ThemeData theme) {
|
||||
switch (level) {
|
||||
case Level.INFO:
|
||||
return theme.colorScheme.surfaceContainerLow;
|
||||
case Level.WARNING:
|
||||
return theme.colorScheme.surfaceBright;
|
||||
case Level.SEVERE:
|
||||
case Level.SHOUT:
|
||||
return theme.colorScheme.errorContainer;
|
||||
default:
|
||||
return theme.colorScheme.primaryContainer;
|
||||
}
|
||||
}
|
||||
|
||||
Color? getLogLevelTextColor(Level level, ThemeData theme) {
|
||||
switch (level) {
|
||||
case Level.INFO:
|
||||
return theme.colorScheme.onSurface;
|
||||
case Level.WARNING:
|
||||
return theme.colorScheme.onSurface;
|
||||
case Level.SEVERE:
|
||||
case Level.SHOUT:
|
||||
return theme.colorScheme.onErrorContainer;
|
||||
default:
|
||||
return theme.colorScheme.onPrimaryContainer;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import 'package:vaani/models/error_response.dart';
|
|||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/constants.dart';
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
import 'package:vaani/shared/utils.dart';
|
||||
|
||||
class UserLoginWithOpenID extends HookConsumerWidget {
|
||||
|
|
@ -89,7 +90,9 @@ class UserLoginWithOpenID extends HookConsumerWidget {
|
|||
return;
|
||||
}
|
||||
|
||||
appLogger.fine('Got OpenID login endpoint: $openIDLoginEndpoint');
|
||||
appLogger.fine(
|
||||
'Got OpenID login endpoint: ${openIDLoginEndpoint.obfuscate()}',
|
||||
);
|
||||
|
||||
// add the flow to the provider
|
||||
ref.read(oauthFlowsProvider.notifier).addFlow(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
final _logger = Logger('PlaybackReporter');
|
||||
|
||||
|
|
@ -255,9 +257,9 @@ class PlaybackReporter {
|
|||
_logger.fine('cancelled timer');
|
||||
}
|
||||
|
||||
void _responseErrorHandler(response, [error]) {
|
||||
void _responseErrorHandler(http.Response response, [error]) {
|
||||
if (response.statusCode != 200) {
|
||||
_logger.shout('Error with api: $response, $error');
|
||||
_logger.severe('Error with api: ${response.obfuscate()}, $error');
|
||||
throw PlaybackSyncError(
|
||||
'Error syncing position: ${response.body}, $error',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,38 +11,38 @@ import 'package:shelfsdk/audiobookshelf_api.dart';
|
|||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/settings/models/app_settings.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
final _logger = Logger('AudiobookPlayer');
|
||||
|
||||
/// returns the sum of the duration of all the previous tracks before the [index]
|
||||
Duration sumOfTracks(BookExpanded book, int? index) {
|
||||
_logger.fine('Calculating sum of tracks for index: $index');
|
||||
// return 0 if index is less than 0
|
||||
if (index == null || index < 0) {
|
||||
_logger.warning('Index is null or less than 0, returning 0');
|
||||
return Duration.zero;
|
||||
}
|
||||
return book.tracks.sublist(0, index).fold<Duration>(Duration.zero,
|
||||
(previousValue, element) {
|
||||
return previousValue + element.duration;
|
||||
});
|
||||
final total = book.tracks.sublist(0, index).fold<Duration>(
|
||||
Duration.zero,
|
||||
(previousValue, element) => previousValue + element.duration,
|
||||
);
|
||||
_logger.fine('Sum of tracks for index: $index is $total');
|
||||
return total;
|
||||
}
|
||||
|
||||
/// returns the [AudioTrack] to play based on the [position] in the [book]
|
||||
AudioTrack getTrackToPlay(BookExpanded book, Duration position) {
|
||||
// var totalDuration = Duration.zero;
|
||||
// for (var track in book.tracks) {
|
||||
// totalDuration += track.duration;
|
||||
// if (totalDuration >= position) {
|
||||
// return track;
|
||||
// }
|
||||
// }
|
||||
// return book.tracks.last;
|
||||
return book.tracks.firstWhere(
|
||||
_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,
|
||||
);
|
||||
_logger.fine('Track to play for position: $position is $track');
|
||||
return track;
|
||||
}
|
||||
|
||||
/// will manage the audio player instance
|
||||
|
|
@ -50,6 +50,7 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
// constructor which takes in the BookExpanded object
|
||||
AudiobookPlayer(this.token, this.baseUrl) : super() {
|
||||
// set the source of the player to the first track in the book
|
||||
_logger.config('Setting up audiobook player');
|
||||
}
|
||||
|
||||
/// the [BookExpanded] being played
|
||||
|
|
@ -84,20 +85,23 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
List<Uri>? downloadedUris,
|
||||
Uri? artworkUri,
|
||||
}) async {
|
||||
_logger.finer(
|
||||
'Initial position: $initialPosition, Downloaded URIs: $downloadedUris',
|
||||
);
|
||||
final appSettings = loadOrCreateAppSettings();
|
||||
// if the book is null, stop the player
|
||||
if (book == null) {
|
||||
_book = null;
|
||||
_logger.info('Book is null, stopping player');
|
||||
return stop();
|
||||
}
|
||||
|
||||
// see if the book is the same as the current book
|
||||
if (_book == book) {
|
||||
_logger.info('Book is the same, doing nothing');
|
||||
return;
|
||||
}
|
||||
// first stop the player and clear the source
|
||||
_logger.info('Setting source for book: $book');
|
||||
|
||||
_logger.fine('Stopping player');
|
||||
await stop();
|
||||
|
||||
_book = book;
|
||||
|
|
@ -114,6 +118,7 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
? initialPosition - trackToPlay.startOffset
|
||||
: null;
|
||||
|
||||
_logger.finer('Setting audioSource');
|
||||
await setAudioSource(
|
||||
preload: preload,
|
||||
initialIndex: initialIndex,
|
||||
|
|
@ -124,7 +129,7 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
final retrievedUri =
|
||||
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token);
|
||||
_logger.fine(
|
||||
'Setting source for track: ${track.title}, URI: $retrievedUri',
|
||||
'Setting source for track: ${track.title}, URI: ${retrievedUri.obfuscate()}',
|
||||
);
|
||||
return AudioSource.uri(
|
||||
retrievedUri,
|
||||
|
|
@ -145,7 +150,7 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
}).toList(),
|
||||
),
|
||||
).catchError((error) {
|
||||
_logger.shout('Error: $error');
|
||||
_logger.shout('Error in setting audio source: $error');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +158,7 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
Future<void> togglePlayPause() {
|
||||
// check if book is set
|
||||
if (_book == null) {
|
||||
throw StateError('No book is set');
|
||||
_logger.warning('No book is set, not toggling play/pause');
|
||||
}
|
||||
|
||||
// TODO refactor this to cover all the states
|
||||
|
|
@ -169,9 +174,11 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
@override
|
||||
Future<void> seek(Duration? positionInBook, {int? index}) async {
|
||||
if (_book == null) {
|
||||
_logger.warning('No book is set, not seeking');
|
||||
return;
|
||||
}
|
||||
if (positionInBook == null) {
|
||||
_logger.warning('Position given is null, not seeking');
|
||||
return;
|
||||
}
|
||||
final tracks = _book!.tracks;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart'
|
||||
as core;
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart' as core;
|
||||
|
||||
part 'audiobook_player.g.dart';
|
||||
|
||||
// @Riverpod(keepAlive: true)
|
||||
// core.AudiobookPlayer audiobookPlayer(
|
||||
// AudiobookPlayerRef ref,
|
||||
// ) {
|
||||
// final api = ref.watch(authenticatedApiProvider);
|
||||
// final player = core.AudiobookPlayer(api.token!, api.baseUrl);
|
||||
|
||||
// ref.onDispose(player.dispose);
|
||||
|
||||
// return player;
|
||||
// }
|
||||
final _logger = Logger('AudiobookPlayerProvider');
|
||||
|
||||
const playerId = 'audiobook_player';
|
||||
|
||||
|
|
@ -32,6 +22,7 @@ class SimpleAudiobookPlayer extends _$SimpleAudiobookPlayer {
|
|||
);
|
||||
|
||||
ref.onDispose(player.dispose);
|
||||
_logger.finer('created simple player');
|
||||
|
||||
return player;
|
||||
}
|
||||
|
|
@ -47,18 +38,16 @@ class AudiobookPlayer extends _$AudiobookPlayer {
|
|||
|
||||
// bind notify listeners to the player
|
||||
player.playerStateStream.listen((_) {
|
||||
notifyListeners();
|
||||
ref.notifyListeners();
|
||||
});
|
||||
|
||||
_logger.finer('created player');
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
void notifyListeners() {
|
||||
ref.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await state.setSpeed(speed);
|
||||
notifyListeners();
|
||||
ref.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ part of 'audiobook_player.dart';
|
|||
// **************************************************************************
|
||||
|
||||
String _$simpleAudiobookPlayerHash() =>
|
||||
r'9e11ed2791d35e308f8cbe61a79a45cf51466ebb';
|
||||
r'5e94bbff4314adceb5affa704fc4d079d4016afa';
|
||||
|
||||
/// Simple because it doesn't rebuild when the player state changes
|
||||
/// it only rebuilds when the token changes
|
||||
|
|
@ -26,7 +26,7 @@ final simpleAudiobookPlayerProvider =
|
|||
);
|
||||
|
||||
typedef _$SimpleAudiobookPlayer = Notifier<core.AudiobookPlayer>;
|
||||
String _$audiobookPlayerHash() => r'44394b1dbbf85eb19ef1f693717e8cbc15b768e5';
|
||||
String _$audiobookPlayerHash() => r'0f180308067486896fec6a65a6afb0e6686ac4a0';
|
||||
|
||||
/// See also [AudiobookPlayer].
|
||||
@ProviderFor(AudiobookPlayer)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
|
@ -5,12 +6,15 @@ import 'package:vaani/shared/extensions/model_conversions.dart';
|
|||
|
||||
part 'currently_playing_provider.g.dart';
|
||||
|
||||
final _logger = Logger('CurrentlyPlayingProvider');
|
||||
|
||||
@riverpod
|
||||
BookExpanded? currentlyPlayingBook(CurrentlyPlayingBookRef ref) {
|
||||
try {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
return player.book;
|
||||
} catch (e) {
|
||||
_logger.warning('Error getting currently playing book: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ part of 'currently_playing_provider.dart';
|
|||
// **************************************************************************
|
||||
|
||||
String _$currentlyPlayingBookHash() =>
|
||||
r'52334c7b4d68fd498a2a00208d8d7f1ba0085237';
|
||||
r'7440b0d54cb364f66e704783652e8f1490ae90e0';
|
||||
|
||||
/// See also [currentlyPlayingBook].
|
||||
@ProviderFor(currentlyPlayingBook)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/features/player/providers/currently_playing_provider.dart';
|
||||
import 'package:vaani/features/player/view/player_when_expanded.dart';
|
||||
import 'package:vaani/main.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
import 'package:vaani/shared/hooks.dart';
|
||||
|
|
@ -55,7 +56,7 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
|||
final currentChapterIndex = currentChapter?.id;
|
||||
final chapterKey = GlobalKey();
|
||||
scrollToCurrentChapter() async {
|
||||
debugPrint('scrolling to chapter');
|
||||
appLogger.fine('scrolling to chapter');
|
||||
await Scrollable.ensureVisible(
|
||||
chapterKey.currentContext!,
|
||||
duration: 200.ms,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class SleepTimer {
|
|||
}),
|
||||
);
|
||||
|
||||
_logger.fine('created with duration: $duration');
|
||||
_logger.info('created with duration: $duration');
|
||||
}
|
||||
|
||||
/// resets the timer and stops it
|
||||
|
|
@ -90,7 +90,7 @@ class SleepTimer {
|
|||
if (player.playing) {
|
||||
startCountDown();
|
||||
}
|
||||
_logger.fine('restarted timer');
|
||||
_logger.info('restarted timer');
|
||||
}
|
||||
|
||||
/// starts the timer with the given duration or the default duration
|
||||
|
|
@ -105,7 +105,7 @@ class SleepTimer {
|
|||
_logger.fine('paused player after $duration');
|
||||
});
|
||||
startedAt = DateTime.now();
|
||||
_logger.fine('started for $duration at $startedAt');
|
||||
_logger.info('started for $duration at $startedAt');
|
||||
}
|
||||
|
||||
Duration get remainingTime {
|
||||
|
|
@ -130,6 +130,6 @@ class SleepTimer {
|
|||
for (var sub in _subscriptions) {
|
||||
sub.cancel();
|
||||
}
|
||||
_logger.fine('disposed');
|
||||
_logger.info('disposed');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/api/authenticated_user_provider.dart';
|
||||
import 'package:vaani/api/server_provider.dart';
|
||||
import 'package:vaani/main.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
import 'package:vaani/shared/widgets/add_new_server.dart';
|
||||
|
||||
class ServerManagerPage extends HookConsumerWidget {
|
||||
|
|
@ -25,8 +27,8 @@ class ServerManagerPage extends HookConsumerWidget {
|
|||
final serverURIController = useTextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
debugPrint('registered servers: $registeredServers');
|
||||
debugPrint('available users: $availableUsers');
|
||||
appLogger.fine('registered servers: ${registeredServers.obfuscate()}');
|
||||
appLogger.fine('available users: ${availableUsers.obfuscate()}');
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Manage Accounts'),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/constants.dart';
|
||||
import 'package:vaani/shared/utils.dart';
|
||||
import 'package:vaani/shared/widgets/not_implemented.dart';
|
||||
|
||||
|
|
@ -12,27 +12,6 @@ class YouPage extends HookConsumerWidget {
|
|||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(meProvider);
|
||||
return me.when(
|
||||
data: (data) {
|
||||
return _YouPage(userData: data);
|
||||
},
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (error, stack) => Text('Error: $error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _YouPage extends HookConsumerWidget {
|
||||
const _YouPage({
|
||||
super.key,
|
||||
required this.userData,
|
||||
});
|
||||
|
||||
final User userData;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
|
|
@ -41,14 +20,21 @@ class _YouPage extends HookConsumerWidget {
|
|||
// title: const Text('You'),
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Logs',
|
||||
icon: const Icon(Icons.bug_report),
|
||||
onPressed: () {
|
||||
context.pushNamed(Routes.logs.name);
|
||||
},
|
||||
),
|
||||
// IconButton(
|
||||
// icon: const Icon(Icons.edit),
|
||||
// onPressed: () {
|
||||
// // Handle edit profile
|
||||
// },
|
||||
// ),
|
||||
// settings button
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
context.pushNamed(Routes.settings.name);
|
||||
|
|
@ -64,30 +50,7 @@ class _YouPage extends HookConsumerWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
// backgroundImage: NetworkImage(userData.avatarUrl),
|
||||
// first letter of the username
|
||||
child: Text(
|
||||
userData.username[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
userData.username,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
UserBar(),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
|
|
@ -121,21 +84,6 @@ class _YouPage extends HookConsumerWidget {
|
|||
title: const Text('My Playlists'),
|
||||
onTap: () {
|
||||
// Handle navigation to playlists
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.help),
|
||||
title: const Text('Help'),
|
||||
onTap: () {
|
||||
// Handle navigation to help website
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info),
|
||||
title: const Text('About'),
|
||||
onTap: () {
|
||||
// Handle navigation to about
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
|
|
@ -149,10 +97,40 @@ class _YouPage extends HookConsumerWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
// const SizedBox(height: 16),
|
||||
// const Text('App Version: 1.0.0'),
|
||||
// const Text('Server Version: 1.0.0'),
|
||||
// const Text('Author: Your Name'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.help),
|
||||
title: const Text('Help'),
|
||||
onTap: () {
|
||||
// Handle navigation to help website
|
||||
showNotImplementedToast(context);
|
||||
},
|
||||
),
|
||||
|
||||
AboutListTile(
|
||||
icon: const Icon(Icons.info),
|
||||
applicationName: AppMetadata.appName,
|
||||
applicationVersion: AppMetadata.version,
|
||||
applicationLegalese:
|
||||
'Made with ❤️ by ${AppMetadata.author}',
|
||||
aboutBoxChildren: [
|
||||
// link to github repo
|
||||
ListTile(
|
||||
leading: Icon(Icons.code),
|
||||
title: Text('Source Code'),
|
||||
onTap: () {
|
||||
handleLaunchUrl(AppMetadata.githubRepo);
|
||||
},
|
||||
),
|
||||
],
|
||||
// apply blend mode to the icon to match the primary color
|
||||
applicationIcon: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: const VaaniLogo(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -162,3 +140,71 @@ class _YouPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserBar extends HookConsumerWidget {
|
||||
const UserBar({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(meProvider);
|
||||
|
||||
return me.when(
|
||||
data: (userData) {
|
||||
return Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
// backgroundImage: NetworkImage(userData.avatarUrl),
|
||||
// first letter of the username
|
||||
child: Text(
|
||||
userData.username[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
userData.username,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (error, stack) => Text('Error: $error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VaaniLogo extends StatelessWidget {
|
||||
const VaaniLogo({
|
||||
super.key,
|
||||
this.size,
|
||||
this.duration = const Duration(milliseconds: 750),
|
||||
this.curve = Curves.fastOutSlowIn,
|
||||
});
|
||||
|
||||
final double? size;
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final IconThemeData iconTheme = IconTheme.of(context);
|
||||
final double? iconSize = size ?? iconTheme.size;
|
||||
return AnimatedContainer(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
child: Image.asset('assets/images/vaani_logo_foreground.png'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue