From 792448b0efcfc749f696d13b7ca1acbce677312b Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Sun, 22 Sep 2024 22:05:28 -0400 Subject: [PATCH] feat: downloads support (#22) * feat: enhance download manager with improved logging and task handling * feat: add total size calculation for library items and improve download manager functionality * refactor: simplify parameters in queueAudioBookDownload and improve logging message in deleteDownloadedItem --- .../downloads/core/download_manager.dart | 120 +++-- .../downloads/providers/download_manager.dart | 121 ++++- .../providers/download_manager.g.dart | 434 +++++++++++++++--- .../view/library_item_actions.dart | 226 ++++++--- lib/main.dart | 4 - lib/shared/extensions/item_files.dart | 10 + 6 files changed, 723 insertions(+), 192 deletions(-) create mode 100644 lib/shared/extensions/item_files.dart diff --git a/lib/features/downloads/core/download_manager.dart b/lib/features/downloads/core/download_manager.dart index 99f9f1c..5b6229d 100644 --- a/lib/features/downloads/core/download_manager.dart +++ b/lib/features/downloads/core/download_manager.dart @@ -1,5 +1,6 @@ // download manager to handle download tasks of files +import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; @@ -38,6 +39,7 @@ class AudiobookDownloadManager { _logger.fine( 'requiresWiFi: $requiresWiFi, retries: $retries, allowPause: $allowPause', ); + initDownloadManager(); } // the base url for the audio files @@ -55,10 +57,17 @@ class AudiobookDownloadManager { // whether to allow pausing of downloads final bool allowPause; - // final List _downloadTasks = []; + final StreamController _taskStatusController = + StreamController.broadcast(); - Future queueAudioBookDownload(LibraryItemExpanded item) async { - _logger.info('queuing download for item: ${item.toJson()}'); + Stream get taskUpdateStream => _taskStatusController.stream; + + late StreamSubscription _updatesSubscription; + + Future 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) { @@ -80,35 +89,13 @@ class AudiobookDownloadManager { allowPause: allowPause, group: item.id, baseDirectory: downloadDirectory, + updates: Updates.statusAndProgress, // metaData: token ); // _downloadTasks.add(task); tq.add(task); - _logger.info('queued task: ${task.toJson()}'); + _logger.info('queued task: ${task.taskId}'); } - - FileDownloader().registerCallbacks( - group: item.id, - taskProgressCallback: (update) { - _logger.info('Group: ${item.id}, Progress Update: ${update.progress}'); - }, - taskStatusCallback: (update) { - switch (update.status) { - case TaskStatus.complete: - _logger.info('Group: ${item.id}, Download Complete'); - break; - case TaskStatus.failed: - _logger.warning('Group: ${item.id}, Download Failed'); - break; - default: - _logger - .info('Group: ${item.id}, Download Status: ${update.status}'); - } - }, - taskNotificationTapCallback: (task, notificationType) { - _logger.info('Group: ${item.id}, Task: ${task.toJson()}'); - }, - ); } String constructFilePath( @@ -118,23 +105,41 @@ class AudiobookDownloadManager { ) => '${directory.path}/${item.relPath}/${file.metadata.filename}'; - // void startDownload() { - // for (final task in _downloadTasks) { - // _logger.fine('enqueuing task: $task'); - // FileDownloader().enqueue(task); - // } - // } - void dispose() { - // tq.removeAll(); - _logger.fine('disposed'); + _updatesSubscription.cancel(); + FileDownloader().destroy(); + _logger.fine('Destroyed download manager'); + } + + bool isItemDownloading(String id) { + return tq.enqueued.any((task) => task.group == id); } bool isFileDownloaded(String filePath) { - // Check if the file exists - final fileExists = File(filePath).existsSync(); + return File(filePath).existsSync(); + } - return fileExists; + Future> getDownloadedFilesMetadata( + LibraryItemExpanded item, + ) async { + final directory = await getApplicationSupportDirectory(); + final downloadedFiles = []; + for (final file in item.libraryFiles) { + final filePath = constructFilePath(directory, item, file); + if (isFileDownloaded(filePath)) { + downloadedFiles.add(file); + } + } + + return downloadedFiles; + } + + Future getDownloadedSize(LibraryItemExpanded item) async { + final files = await getDownloadedFilesMetadata(item); + return files.fold( + 0, + (previousValue, element) => previousValue + element.metadata.size, + ); } Future isItemDownloaded(LibraryItemExpanded item) async { @@ -145,11 +150,12 @@ class AudiobookDownloadManager { return false; } } - + _logger.info('all files downloaded for item id: ${item.id}'); return true; } Future deleteDownloadedItem(LibraryItemExpanded item) async { + _logger.info('deleting downloaded item with id: ${item.id}'); final directory = await getApplicationSupportDirectory(); for (final file in item.libraryFiles) { final filePath = constructFilePath(directory, item, file); @@ -160,7 +166,7 @@ class AudiobookDownloadManager { } } - Future> getDownloadedFiles(LibraryItemExpanded item) async { + Future> getDownloadedFilesUri(LibraryItemExpanded item) async { final directory = await getApplicationSupportDirectory(); final files = []; for (final file in item.libraryFiles) { @@ -172,14 +178,28 @@ class AudiobookDownloadManager { return files; } -} -Future initDownloadManager() async { - // initialize the download manager - var fileDownloader = FileDownloader(); - fileDownloader.configureNotification( - running: const TaskNotification('Downloading', 'file: {filename}'), - progressBar: true, - ); - await fileDownloader.trackTasks(); + Future initDownloadManager() async { + // initialize the download manager + _logger.info('Initializing download manager'); + final fileDownloader = FileDownloader(); + + _logger.info('Configuring Notification'); + fileDownloader.configureNotification( + running: const TaskNotification('Downloading', 'file: {filename}'), + progressBar: true, + ); + + await fileDownloader.trackTasks(); + + try { + _updatesSubscription = fileDownloader.updates.listen((event) { + _logger.finer('Got event: $event'); + _taskStatusController.add(event); + }); + _logger.info('Listening to download manager updates'); + } catch (e) { + _logger.warning('Error when listening to download manager updates: $e'); + } + } } diff --git a/lib/features/downloads/providers/download_manager.dart b/lib/features/downloads/providers/download_manager.dart index 6a04a8f..9fb52dd 100644 --- a/lib/features/downloads/providers/download_manager.dart +++ b/lib/features/downloads/providers/download_manager.dart @@ -1,13 +1,17 @@ import 'package:background_downloader/background_downloader.dart'; +import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/api_provider.dart'; -import 'package:vaani/features/downloads/core/download_manager.dart' - as core; +import 'package:vaani/api/library_item_provider.dart'; +import 'package:vaani/features/downloads/core/download_manager.dart' as core; import 'package:vaani/settings/app_settings_provider.dart'; +import 'package:vaani/shared/extensions/item_files.dart'; part 'download_manager.g.dart'; +final _logger = Logger('AudiobookDownloadManagerProvider'); + @Riverpod(keepAlive: true) class SimpleDownloadManager extends _$SimpleDownloadManager { @override @@ -25,6 +29,7 @@ class SimpleDownloadManager extends _$SimpleDownloadManager { core.tq.maxConcurrent = downloadSettings.maxConcurrent; core.tq.maxConcurrentByHost = downloadSettings.maxConcurrentByHost; core.tq.maxConcurrentByGroup = downloadSettings.maxConcurrentByGroup; + ref.onDispose(() { manager.dispose(); }); @@ -33,6 +38,102 @@ class SimpleDownloadManager extends _$SimpleDownloadManager { } } +@Riverpod(keepAlive: true) +class DownloadManager extends _$DownloadManager { + @override + core.AudiobookDownloadManager build() { + final manager = ref.watch(simpleDownloadManagerProvider); + manager.taskUpdateStream.listen((_) { + ref.notifyListeners(); + }); + return manager; + } + + Future queueAudioBookDownload( + LibraryItemExpanded item, { + void Function(TaskStatusUpdate)? taskStatusCallback, + void Function(TaskProgressUpdate)? taskProgressCallback, + }) async { + await state.queueAudioBookDownload( + item, + taskStatusCallback: (item) { + try { + taskStatusCallback?.call(item); + } catch (e) { + _logger.severe('Error in taskStatusCallback', e); + } + ref.notifyListeners(); + }, + taskProgressCallback: (item) { + try { + taskProgressCallback?.call(item); + } catch (e) { + _logger.severe('Error in taskProgressCallback', e); + } + ref.notifyListeners(); + }, + ); + } + + Future deleteDownloadedItem(LibraryItemExpanded item) async { + await state.deleteDownloadedItem(item); + ref.notifyListeners(); + } +} + +@riverpod +class IsItemDownloading extends _$IsItemDownloading { + @override + bool build(String id) { + final manager = ref.watch(downloadManagerProvider); + return manager.isItemDownloading(id); + } +} + +@riverpod +class ItemDownloadProgress extends _$ItemDownloadProgress { + @override + Future 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( + 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.valueOrNull ?? 0.0)) { + return; + } + + state = AsyncValue.data(progress.clamp(0.0, 1.0)); + } + }); + return null; + } +} + @riverpod FutureOr> downloadHistory( DownloadHistoryRef ref, { @@ -41,11 +142,13 @@ FutureOr> downloadHistory( return await FileDownloader().database.allRecords(group: group); } -@Riverpod(keepAlive: false) -FutureOr downloadStatus( - DownloadStatusRef ref, - LibraryItemExpanded item, -) async { - final manager = ref.read(simpleDownloadManagerProvider); - return manager.isItemDownloaded(item); +@riverpod +class IsItemDownloaded extends _$IsItemDownloaded { + @override + FutureOr build( + LibraryItemExpanded item, + ) { + final manager = ref.watch(downloadManagerProvider); + return manager.isItemDownloaded(item); + } } diff --git a/lib/features/downloads/providers/download_manager.g.dart b/lib/features/downloads/providers/download_manager.g.dart index 7a21240..54eb02c 100644 --- a/lib/features/downloads/providers/download_manager.g.dart +++ b/lib/features/downloads/providers/download_manager.g.dart @@ -157,29 +157,358 @@ class _DownloadHistoryProviderElement String? get group => (origin as DownloadHistoryProvider).group; } -String _$downloadStatusHash() => r'f37b4678d3c2a7c6e985b0149d72ea0f9b1b42ca'; +String _$simpleDownloadManagerHash() => + r'cec95717c86e422f88f78aa014d29e800e5a2089'; -/// See also [downloadStatus]. -@ProviderFor(downloadStatus) -const downloadStatusProvider = DownloadStatusFamily(); +/// See also [SimpleDownloadManager]. +@ProviderFor(SimpleDownloadManager) +final simpleDownloadManagerProvider = NotifierProvider.internal( + SimpleDownloadManager.new, + name: r'simpleDownloadManagerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$simpleDownloadManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); -/// See also [downloadStatus]. -class DownloadStatusFamily extends Family> { - /// See also [downloadStatus]. - const DownloadStatusFamily(); +typedef _$SimpleDownloadManager = Notifier; +String _$downloadManagerHash() => r'9566b772d792b32e1b199d4aa834e28de3b034d0'; - /// See also [downloadStatus]. - DownloadStatusProvider call( +/// See also [DownloadManager]. +@ProviderFor(DownloadManager) +final downloadManagerProvider = + NotifierProvider.internal( + DownloadManager.new, + name: r'downloadManagerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$downloadManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$DownloadManager = Notifier; +String _$isItemDownloadingHash() => r'ea43c06393beec828134e08d5f896ddbcfbac8f0'; + +abstract class _$IsItemDownloading extends BuildlessAutoDisposeNotifier { + late final String id; + + bool build( + String id, + ); +} + +/// See also [IsItemDownloading]. +@ProviderFor(IsItemDownloading) +const isItemDownloadingProvider = IsItemDownloadingFamily(); + +/// See also [IsItemDownloading]. +class IsItemDownloadingFamily extends Family { + /// See also [IsItemDownloading]. + const IsItemDownloadingFamily(); + + /// See also [IsItemDownloading]. + IsItemDownloadingProvider call( + String id, + ) { + return IsItemDownloadingProvider( + id, + ); + } + + @override + IsItemDownloadingProvider getProviderOverride( + covariant IsItemDownloadingProvider provider, + ) { + return call( + provider.id, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'isItemDownloadingProvider'; +} + +/// See also [IsItemDownloading]. +class IsItemDownloadingProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [IsItemDownloading]. + IsItemDownloadingProvider( + String id, + ) : this._internal( + () => IsItemDownloading()..id = id, + from: isItemDownloadingProvider, + name: r'isItemDownloadingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$isItemDownloadingHash, + dependencies: IsItemDownloadingFamily._dependencies, + allTransitiveDependencies: + IsItemDownloadingFamily._allTransitiveDependencies, + id: id, + ); + + IsItemDownloadingProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + bool runNotifierBuild( + covariant IsItemDownloading notifier, + ) { + return notifier.build( + id, + ); + } + + @override + Override overrideWith(IsItemDownloading Function() create) { + return ProviderOverride( + origin: this, + override: IsItemDownloadingProvider._internal( + () => create()..id = id, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _IsItemDownloadingProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is IsItemDownloadingProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin IsItemDownloadingRef on AutoDisposeNotifierProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _IsItemDownloadingProviderElement + extends AutoDisposeNotifierProviderElement + with IsItemDownloadingRef { + _IsItemDownloadingProviderElement(super.provider); + + @override + String get id => (origin as IsItemDownloadingProvider).id; +} + +String _$itemDownloadProgressHash() => + r'd007c55c6e2e4b992069d0306df8a600225d8598'; + +abstract class _$ItemDownloadProgress + extends BuildlessAutoDisposeAsyncNotifier { + late final String id; + + FutureOr build( + String id, + ); +} + +/// See also [ItemDownloadProgress]. +@ProviderFor(ItemDownloadProgress) +const itemDownloadProgressProvider = ItemDownloadProgressFamily(); + +/// See also [ItemDownloadProgress]. +class ItemDownloadProgressFamily extends Family> { + /// See also [ItemDownloadProgress]. + const ItemDownloadProgressFamily(); + + /// See also [ItemDownloadProgress]. + ItemDownloadProgressProvider call( + String id, + ) { + return ItemDownloadProgressProvider( + id, + ); + } + + @override + ItemDownloadProgressProvider getProviderOverride( + covariant ItemDownloadProgressProvider provider, + ) { + return call( + provider.id, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'itemDownloadProgressProvider'; +} + +/// See also [ItemDownloadProgress]. +class ItemDownloadProgressProvider extends AutoDisposeAsyncNotifierProviderImpl< + ItemDownloadProgress, double?> { + /// See also [ItemDownloadProgress]. + ItemDownloadProgressProvider( + String id, + ) : this._internal( + () => ItemDownloadProgress()..id = id, + from: itemDownloadProgressProvider, + name: r'itemDownloadProgressProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$itemDownloadProgressHash, + dependencies: ItemDownloadProgressFamily._dependencies, + allTransitiveDependencies: + ItemDownloadProgressFamily._allTransitiveDependencies, + id: id, + ); + + ItemDownloadProgressProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + FutureOr runNotifierBuild( + covariant ItemDownloadProgress notifier, + ) { + return notifier.build( + id, + ); + } + + @override + Override overrideWith(ItemDownloadProgress Function() create) { + return ProviderOverride( + origin: this, + override: ItemDownloadProgressProvider._internal( + () => create()..id = id, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement + createElement() { + return _ItemDownloadProgressProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ItemDownloadProgressProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin ItemDownloadProgressRef on AutoDisposeAsyncNotifierProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _ItemDownloadProgressProviderElement + extends AutoDisposeAsyncNotifierProviderElement with ItemDownloadProgressRef { + _ItemDownloadProgressProviderElement(super.provider); + + @override + String get id => (origin as ItemDownloadProgressProvider).id; +} + +String _$isItemDownloadedHash() => r'9bb7ba28bdb73e1ba706e849fedc9c7bd67f4b67'; + +abstract class _$IsItemDownloaded + extends BuildlessAutoDisposeAsyncNotifier { + late final LibraryItemExpanded item; + + FutureOr build( + LibraryItemExpanded item, + ); +} + +/// See also [IsItemDownloaded]. +@ProviderFor(IsItemDownloaded) +const isItemDownloadedProvider = IsItemDownloadedFamily(); + +/// See also [IsItemDownloaded]. +class IsItemDownloadedFamily extends Family> { + /// See also [IsItemDownloaded]. + const IsItemDownloadedFamily(); + + /// See also [IsItemDownloaded]. + IsItemDownloadedProvider call( LibraryItemExpanded item, ) { - return DownloadStatusProvider( + return IsItemDownloadedProvider( item, ); } @override - DownloadStatusProvider getProviderOverride( - covariant DownloadStatusProvider provider, + IsItemDownloadedProvider getProviderOverride( + covariant IsItemDownloadedProvider provider, ) { return call( provider.item, @@ -198,32 +527,30 @@ class DownloadStatusFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'downloadStatusProvider'; + String? get name => r'isItemDownloadedProvider'; } -/// See also [downloadStatus]. -class DownloadStatusProvider extends AutoDisposeFutureProvider { - /// See also [downloadStatus]. - DownloadStatusProvider( +/// See also [IsItemDownloaded]. +class IsItemDownloadedProvider + extends AutoDisposeAsyncNotifierProviderImpl { + /// See also [IsItemDownloaded]. + IsItemDownloadedProvider( LibraryItemExpanded item, ) : this._internal( - (ref) => downloadStatus( - ref as DownloadStatusRef, - item, - ), - from: downloadStatusProvider, - name: r'downloadStatusProvider', + () => IsItemDownloaded()..item = item, + from: isItemDownloadedProvider, + name: r'isItemDownloadedProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$downloadStatusHash, - dependencies: DownloadStatusFamily._dependencies, + : _$isItemDownloadedHash, + dependencies: IsItemDownloadedFamily._dependencies, allTransitiveDependencies: - DownloadStatusFamily._allTransitiveDependencies, + IsItemDownloadedFamily._allTransitiveDependencies, item: item, ); - DownloadStatusProvider._internal( + IsItemDownloadedProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -236,13 +563,20 @@ class DownloadStatusProvider extends AutoDisposeFutureProvider { final LibraryItemExpanded item; @override - Override overrideWith( - FutureOr Function(DownloadStatusRef provider) create, + FutureOr runNotifierBuild( + covariant IsItemDownloaded notifier, ) { + return notifier.build( + item, + ); + } + + @override + Override overrideWith(IsItemDownloaded Function() create) { return ProviderOverride( origin: this, - override: DownloadStatusProvider._internal( - (ref) => create(ref as DownloadStatusRef), + override: IsItemDownloadedProvider._internal( + () => create()..item = item, from: from, name: null, dependencies: null, @@ -254,13 +588,14 @@ class DownloadStatusProvider extends AutoDisposeFutureProvider { } @override - AutoDisposeFutureProviderElement createElement() { - return _DownloadStatusProviderElement(this); + AutoDisposeAsyncNotifierProviderElement + createElement() { + return _IsItemDownloadedProviderElement(this); } @override bool operator ==(Object other) { - return other is DownloadStatusProvider && other.item == item; + return other is IsItemDownloadedProvider && other.item == item; } @override @@ -272,35 +607,18 @@ class DownloadStatusProvider extends AutoDisposeFutureProvider { } } -mixin DownloadStatusRef on AutoDisposeFutureProviderRef { +mixin IsItemDownloadedRef on AutoDisposeAsyncNotifierProviderRef { /// The parameter `item` of this provider. LibraryItemExpanded get item; } -class _DownloadStatusProviderElement - extends AutoDisposeFutureProviderElement with DownloadStatusRef { - _DownloadStatusProviderElement(super.provider); +class _IsItemDownloadedProviderElement + extends AutoDisposeAsyncNotifierProviderElement + with IsItemDownloadedRef { + _IsItemDownloadedProviderElement(super.provider); @override - LibraryItemExpanded get item => (origin as DownloadStatusProvider).item; + LibraryItemExpanded get item => (origin as IsItemDownloadedProvider).item; } - -String _$simpleDownloadManagerHash() => - r'cec95717c86e422f88f78aa014d29e800e5a2089'; - -/// See also [SimpleDownloadManager]. -@ProviderFor(SimpleDownloadManager) -final simpleDownloadManagerProvider = NotifierProvider.internal( - SimpleDownloadManager.new, - name: r'simpleDownloadManagerProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$simpleDownloadManagerHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$SimpleDownloadManager = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart index f92d32d..1e96e14 100644 --- a/lib/features/item_viewer/view/library_item_actions.dart +++ b/lib/features/item_viewer/view/library_item_actions.dart @@ -1,5 +1,6 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; @@ -8,7 +9,10 @@ import 'package:vaani/constants/hero_tag_conventions.dart'; import 'package:vaani/features/downloads/providers/download_manager.dart' show downloadHistoryProvider, - downloadStatusProvider, + downloadManagerProvider, + isItemDownloadedProvider, + isItemDownloadingProvider, + itemDownloadProgressProvider, simpleDownloadManagerProvider; import 'package:vaani/features/item_viewer/view/library_item_page.dart'; import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart'; @@ -33,10 +37,7 @@ class LibraryItemActions extends HookConsumerWidget { late final shelfsdk.BookExpanded book; @override Widget build(BuildContext context, WidgetRef ref) { - final manager = ref.read(simpleDownloadManagerProvider); final downloadHistory = ref.watch(downloadHistoryProvider(group: item.id)); - final isItemDownloaded = ref.watch(downloadStatusProvider(item)); - final isBookPlaying = ref.watch(audiobookPlayerProvider).book != null; final apiSettings = ref.watch(apiSettingsProvider); return Padding( @@ -93,64 +94,9 @@ class LibraryItemActions extends HookConsumerWidget { }, icon: const Icon(Icons.share_rounded), ), - // check if the book is downloaded using manager.isItemDownloaded - isItemDownloaded.when( - data: (isDownloaded) { - if (isDownloaded) { - // already downloaded button - return IconButton( - onPressed: () { - appLogger - .fine('Pressed already downloaded button'); - // manager.openDownloadedFile(item); - // open popup menu to open or delete the file - showModalBottomSheet( - useRootNavigator: false, - context: context, - builder: (context) { - return Padding( - padding: EdgeInsets.only( - top: 8.0, - bottom: (isBookPlaying - ? playerMinHeight - : 0) + - 8, - ), - child: DownloadSheet( - item: item, - ), - ); - }, - ); - }, - icon: const Icon( - Icons.download_done_rounded, - ), - ); - } - // download button - return IconButton( - onPressed: () { - appLogger.fine('Pressed download button'); - manager.queueAudioBookDownload(item); - }, - icon: const Icon( - Icons.download_rounded, - ), - ); - }, - loading: () => const CircularProgressIndicator(), - error: (error, stackTrace) { - return IconButton( - onPressed: () { - appLogger.warning( - 'Error checking download status: $error', - ); - }, - icon: const Icon(Icons.error_rounded), - ); - }, - ), + // download button + LibItemDownloadButton(item: item), + // more button IconButton( onPressed: () { @@ -257,6 +203,130 @@ class LibraryItemActions extends HookConsumerWidget { } } +class LibItemDownloadButton extends HookConsumerWidget { + const LibItemDownloadButton({ + super.key, + required this.item, + }); + + final shelfsdk.LibraryItemExpanded item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isItemDownloaded = ref.watch(isItemDownloadedProvider(item)); + if (isItemDownloaded.valueOrNull ?? false) { + return AlreadyItemDownloadedButton(item: item); + } + final isItemDownloading = ref.watch(isItemDownloadingProvider(item.id)); + + return isItemDownloading + ? ItemCurrentlyInDownloadQueue( + item: item, + ) + : IconButton( + onPressed: () { + appLogger.fine('Pressed download button'); + + ref + .read(downloadManagerProvider.notifier) + .queueAudioBookDownload(item); + }, + icon: const Icon( + Icons.download_rounded, + ), + ); + } +} + +class ItemCurrentlyInDownloadQueue extends HookConsumerWidget { + const ItemCurrentlyInDownloadQueue({ + super.key, + required this.item, + }); + + final shelfsdk.LibraryItemExpanded item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progress = + ref.watch(itemDownloadProgressProvider(item.id)).valueOrNull; + + if (progress == 1) { + return AlreadyItemDownloadedButton(item: item); + } + + const shimmerDuration = Duration(milliseconds: 1000); + return Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: progress, + strokeWidth: 2, + ), + const Icon( + Icons.download, + // color: Theme.of(context).progressIndicatorTheme.color, + ) + .animate( + onPlay: (controller) => controller.repeat(), + ) + .fade( + duration: shimmerDuration, + end: 1, + begin: 0.6, + curve: Curves.linearToEaseOut, + ) + .fade( + duration: shimmerDuration, + end: 0.7, + begin: 1, + curve: Curves.easeInToLinear, + ), + ], + ); + } +} + +class AlreadyItemDownloadedButton extends HookConsumerWidget { + const AlreadyItemDownloadedButton({ + super.key, + required this.item, + }); + + final shelfsdk.LibraryItemExpanded item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isBookPlaying = ref.watch(audiobookPlayerProvider).book != null; + + return IconButton( + onPressed: () { + appLogger.fine('Pressed already downloaded button'); + // manager.openDownloadedFile(item); + // open popup menu to open or delete the file + showModalBottomSheet( + useRootNavigator: false, + context: context, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + top: 8.0, + bottom: (isBookPlaying ? playerMinHeight : 0) + 8, + ), + child: DownloadSheet( + item: item, + ), + ); + }, + ); + }, + icon: const Icon( + Icons.download_done_rounded, + ), + ); + } +} + class DownloadSheet extends HookConsumerWidget { const DownloadSheet({ super.key, @@ -267,7 +337,7 @@ class DownloadSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final manager = ref.read(simpleDownloadManagerProvider); + final manager = ref.watch(downloadManagerProvider); return Column( mainAxisSize: MainAxisSize.min, @@ -296,9 +366,9 @@ class DownloadSheet extends HookConsumerWidget { leading: const Icon( Icons.delete_rounded, ), - onTap: () { + onTap: () async { // show the delete dialog - showDialog( + final wasDeleted = await showDialog( useRootNavigator: false, context: context, builder: (context) { @@ -311,16 +381,18 @@ class DownloadSheet extends HookConsumerWidget { TextButton( onPressed: () { // delete the file - manager.deleteDownloadedItem( - item, - ); - GoRouter.of(context).pop(); + ref + .read(downloadManagerProvider.notifier) + .deleteDownloadedItem( + item, + ); + GoRouter.of(context).pop(true); }, child: const Text('Yes'), ), TextButton( onPressed: () { - GoRouter.of(context).pop(); + GoRouter.of(context).pop(false); }, child: const Text('No'), ), @@ -328,6 +400,18 @@ class DownloadSheet extends HookConsumerWidget { ); }, ); + + if (wasDeleted ?? false) { + appLogger.fine('Deleted ${item.media.metadata.title}'); + GoRouter.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Deleted ${item.media.metadata.title}', + ), + ), + ); + } }, ), ], @@ -444,7 +528,7 @@ Future libraryItemPlayButtonOnPressed({ final downloadManager = ref.watch(simpleDownloadManagerProvider); final libItem = await ref.read(libraryItemProvider(book.libraryItemId).future); - final downloadedUris = await downloadManager.getDownloadedFiles(libItem); + final downloadedUris = await downloadManager.getDownloadedFilesUri(libItem); setSourceFuture = player.setSourceAudiobook( book, initialPosition: userMediaProgress?.currentTime, diff --git a/lib/main.dart b/lib/main.dart index 2630802..381d27f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,6 @@ import 'package:just_audio_media_kit/just_audio_media_kit.dart' import 'package:logging/logging.dart'; import 'package:vaani/api/server_provider.dart'; import 'package:vaani/db/storage.dart'; -import 'package:vaani/features/downloads/core/download_manager.dart'; import 'package:vaani/features/downloads/providers/download_manager.dart'; import 'package:vaani/features/playback_reporting/providers/playback_reporter_provider.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; @@ -49,9 +48,6 @@ void main() async { androidNotificationOngoing: true, ); - // for initializing the download manager - await initDownloadManager(); - // run the app runApp( const ProviderScope( diff --git a/lib/shared/extensions/item_files.dart b/lib/shared/extensions/item_files.dart new file mode 100644 index 0000000..b658f24 --- /dev/null +++ b/lib/shared/extensions/item_files.dart @@ -0,0 +1,10 @@ +import 'package:shelfsdk/audiobookshelf_api.dart'; + +extension TotalSize on LibraryItemExpanded { + int get totalSize { + return libraryFiles.fold( + 0, + (previousValue, element) => previousValue + element.metadata.size, + ); + } +}