diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..62f61e58 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +flutter 3.27.1-stable diff --git a/lib/bloc/podcast/episode_bloc.dart b/lib/bloc/podcast/episode_bloc.dart index 37a3d629..4f07e8ff 100644 --- a/lib/bloc/podcast/episode_bloc.dart +++ b/lib/bloc/podcast/episode_bloc.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:anytime/bloc/bloc.dart'; import 'package:anytime/entities/episode.dart'; import 'package:anytime/services/audio/audio_player_service.dart'; +import 'package:anytime/services/download/download_service.dart'; import 'package:anytime/services/podcast/podcast_service.dart'; import 'package:anytime/state/bloc_state.dart'; import 'package:logging/logging.dart'; @@ -17,6 +18,7 @@ import 'package:rxdart/rxdart.dart'; class EpisodeBloc extends Bloc { final log = Logger('EpisodeBloc'); final PodcastService podcastService; + final DownloadService downloadService; final AudioPlayerService audioPlayerService; /// Add to sink to fetch list of current downloaded episodes. @@ -43,6 +45,7 @@ class EpisodeBloc extends Bloc { EpisodeBloc({ required this.podcastService, required this.audioPlayerService, + required this.downloadService, }) { _init(); } @@ -65,7 +68,7 @@ class EpisodeBloc extends Bloc { await audioPlayerService.stop(); } - await podcastService.deleteDownload(episode!); + await downloadService.deleteDownload(episode!); fetchDownloads(true); }); diff --git a/lib/bloc/podcast/podcast_bloc.dart b/lib/bloc/podcast/podcast_bloc.dart index a4b6ebc9..91b4de15 100644 --- a/lib/bloc/podcast/podcast_bloc.dart +++ b/lib/bloc/podcast/podcast_bloc.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:anytime/bloc/bloc.dart'; -import 'package:anytime/entities/downloadable.dart'; import 'package:anytime/entities/episode.dart'; import 'package:anytime/entities/feed.dart'; import 'package:anytime/entities/podcast.dart'; @@ -245,20 +244,9 @@ class PodcastBloc extends Bloc { var episode = _episodes.firstWhereOrNull((ep) => ep.guid == e.guid); if (episode != null) { - episode.downloadState = e.downloadState = DownloadState.queued; - _refresh(); - var result = await downloadService.downloadEpisode(e); - - // If there was an error downloading the episode, push an error state - // and then restore to none. - if (!result) { - episode.downloadState = e.downloadState = DownloadState.failed; - _refresh(); - episode.downloadState = e.downloadState = DownloadState.none; - _refresh(); - } + await downloadService.downloadEpisode(e); } }); } diff --git a/lib/services/audio/default_audio_player_service.dart b/lib/services/audio/default_audio_player_service.dart index b1f36e14..7d3f0cdb 100644 --- a/lib/services/audio/default_audio_player_service.dart +++ b/lib/services/audio/default_audio_player_service.dart @@ -15,6 +15,7 @@ import 'package:anytime/entities/sleep.dart'; import 'package:anytime/entities/transcript.dart'; import 'package:anytime/repository/repository.dart'; import 'package:anytime/services/audio/audio_player_service.dart'; +import 'package:anytime/services/download/download_service.dart'; import 'package:anytime/services/podcast/podcast_service.dart'; import 'package:anytime/services/settings/settings_service.dart'; import 'package:anytime/state/episode_state.dart'; @@ -37,6 +38,7 @@ class DefaultAudioPlayerService extends AudioPlayerService { final log = Logger('DefaultAudioPlayerService'); final Repository repository; final SettingsService settingsService; + final DownloadService downloadService; final PodcastService podcastService; late AudioHandler _audioHandler; @@ -95,6 +97,7 @@ class DefaultAudioPlayerService extends AudioPlayerService { required this.repository, required this.settingsService, required this.podcastService, + required this.downloadService, }) { AudioService.init( builder: () => _DefaultAudioPlayerHandler( @@ -535,7 +538,7 @@ class DefaultAudioPlayerService extends AudioPlayerService { settingsService.deleteDownloadedPlayedEpisodes && _currentEpisode?.downloadState == DownloadState.downloaded && !sleepy ) { - await podcastService.deleteDownload(_currentEpisode!); + await downloadService.deleteDownload(_currentEpisode!); } _stopPositionTicker(); diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart index c14268a8..c39f7a16 100644 --- a/lib/services/download/download_service.dart +++ b/lib/services/download/download_service.dart @@ -5,7 +5,8 @@ import 'package:anytime/entities/episode.dart'; abstract class DownloadService { - Future downloadEpisode(Episode episode); + Future downloadEpisode(Episode episode); + Future deleteDownload(Episode episode); Future findEpisodeByTaskId(String taskId); diff --git a/lib/services/download/mobile_download_service.dart b/lib/services/download/mobile_download_service.dart index da20ba22..5ad7b9c3 100644 --- a/lib/services/download/mobile_download_service.dart +++ b/lib/services/download/mobile_download_service.dart @@ -13,10 +13,14 @@ import 'package:anytime/repository/repository.dart'; import 'package:anytime/services/download/download_manager.dart'; import 'package:anytime/services/download/download_service.dart'; import 'package:anytime/services/podcast/podcast_service.dart'; +import 'package:anytime/services/settings/settings_service.dart'; import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:logging/logging.dart'; import 'package:mp3_info/mp3_info.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:uuid/uuid.dart'; /// An implementation of a [DownloadService] that handles downloading /// of episodes on mobile. @@ -25,23 +29,40 @@ class MobileDownloadService extends DownloadService { final log = Logger('MobileDownloadService'); final Repository repository; + final SettingsService settingsService; final DownloadManager downloadManager; final PodcastService podcastService; - MobileDownloadService({required this.repository, required this.downloadManager, required this.podcastService}) { - downloadManager.downloadProgress.pipe(downloadProgress); - downloadProgress.listen((progress) { - _updateDownloadProgress(progress); - }); + late final StreamSubscription _downloadProgressSubscription; + + /// Lock ensures we wait for task creation and local save + /// before handling subsequent [Download update events]. + final _downloadLock = Lock(); + + MobileDownloadService({ + required this.repository, + required this.downloadManager, + required this.settingsService, + required this.podcastService, + }) { + _downloadProgressSubscription = downloadManager.downloadProgress.listen( + (progress) async => await _downloadLock.synchronized( + () { + downloadProgress.add(progress); + _updateDownloadProgress(progress); + }, + ), + ); } @override void dispose() { downloadManager.dispose(); + _downloadProgressSubscription.cancel(); } @override - Future downloadEpisode(Episode episode) async { + Future downloadEpisode(Episode episode) async { try { final season = episode.season > 0 ? episode.season.toString() : ''; final epno = episode.episode > 0 ? episode.episode.toString() : ''; @@ -121,28 +142,90 @@ class MobileDownloadService extends DownloadService { /// the URL before calling download and ensure it is https. var url = await resolveUrl(episode.contentUrl!, forceHttps: true); - final taskId = await downloadManager.enqueueTask(url, downloadPath, filename); + await _downloadLock.synchronized(() async { + final taskId = await downloadManager.enqueueTask(url, downloadPath, filename!); - // Update the episode with download data - episode.filepath = episodePath; - episode.filename = filename; - episode.downloadTaskId = taskId; - episode.downloadState = DownloadState.downloading; - episode.downloadPercentage = 0; + // Update the episode with download data + episode.filepath = episodePath; + episode.filename = filename; + episode.downloadTaskId = taskId; + episode.downloadState = DownloadState.downloading; + episode.downloadPercentage = 0; - await repository.saveEpisode(episode); - - return true; + await repository.saveEpisode(episode); + }); } } - - return false; } catch (e, stack) { log.warning('Episode download failed (${episode.title})', e, stack); - return false; + episode.filename = null; + episode.filepath = null; + episode.downloadTaskId = null; + episode.downloadPercentage = 0; + episode.downloadState = DownloadState.none; + + await repository.saveEpisode(episode); + + /// If there was an error downloading the episode, push an error state + /// and then restore to none. + /// + /// If failure happens before download actual start, its [id] will be [null]. + final downloadId = episode.downloadTaskId ?? const Uuid().v4(); + downloadProgress + ..add(DownloadProgress( + downloadId, + 0, + DownloadState.failed, + )) + ..add(DownloadProgress( + downloadId, + 0, + DownloadState.none, + )); } } + @override + Future deleteDownload(Episode episode) async => _downloadLock.synchronized(() async { + // If this episode is currently downloading, cancel the download first. + if (episode.downloadState == DownloadState.downloaded) { + if (settingsService.markDeletedEpisodesAsPlayed) { + episode.played = true; + } + } else if (episode.downloadState == DownloadState.downloading && episode.downloadPercentage! < 100) { + await FlutterDownloader.cancel(taskId: episode.downloadTaskId!); + } + + episode.downloadTaskId = null; + episode.downloadPercentage = 0; + episode.position = 0; + episode.downloadState = DownloadState.none; + + if (episode.transcriptId != null && episode.transcriptId! > 0) { + await repository.deleteTranscriptById(episode.transcriptId!); + } + + await repository.saveEpisode(episode); + + if (await hasStoragePermission()) { + final f = File.fromUri(Uri.file(await resolvePath(episode))); + + log.fine('Deleting file ${f.path}'); + + if (await f.exists()) { + f.delete(); + } + } + + // downloadProgress.add(DownloadProgress( + // episode.downloadTaskId!, + // 0, + // DownloadState.none, + // )); + + return; + }); + @override Future findEpisodeByTaskId(String taskId) { return repository.findEpisodeByTaskId(taskId); diff --git a/lib/services/podcast/mobile_podcast_service.dart b/lib/services/podcast/mobile_podcast_service.dart index 332e3b49..302a8b65 100644 --- a/lib/services/podcast/mobile_podcast_service.dart +++ b/lib/services/podcast/mobile_podcast_service.dart @@ -8,7 +8,6 @@ import 'dart:io'; import 'package:anytime/api/podcast/podcast_api.dart'; import 'package:anytime/core/utils.dart'; import 'package:anytime/entities/chapter.dart'; -import 'package:anytime/entities/downloadable.dart'; import 'package:anytime/entities/episode.dart'; import 'package:anytime/entities/funding.dart'; import 'package:anytime/entities/person.dart'; @@ -20,7 +19,6 @@ import 'package:anytime/state/episode_state.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:intl/intl.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; @@ -507,41 +505,6 @@ class MobilePodcastService extends PodcastService { return repository.findAllEpisodes(); } - @override - Future deleteDownload(Episode episode) async { - // If this episode is currently downloading, cancel the download first. - if (episode.downloadState == DownloadState.downloaded) { - if (settingsService.markDeletedEpisodesAsPlayed) { - episode.played = true; - } - } else if (episode.downloadState == DownloadState.downloading && episode.downloadPercentage! < 100) { - await FlutterDownloader.cancel(taskId: episode.downloadTaskId!); - } - - episode.downloadTaskId = null; - episode.downloadPercentage = 0; - episode.position = 0; - episode.downloadState = DownloadState.none; - - if (episode.transcriptId != null && episode.transcriptId! > 0) { - await repository.deleteTranscriptById(episode.transcriptId!); - } - - await repository.saveEpisode(episode); - - if (await hasStoragePermission()) { - final f = File.fromUri(Uri.file(await resolvePath(episode))); - - log.fine('Deleting file ${f.path}'); - - if (await f.exists()) { - f.delete(); - } - } - - return; - } - @override Future toggleEpisodePlayed(Episode episode) async { episode.played = !episode.played; diff --git a/lib/services/podcast/podcast_service.dart b/lib/services/podcast/podcast_service.dart index 64ad1f5e..9faaab79 100644 --- a/lib/services/podcast/podcast_service.dart +++ b/lib/services/podcast/podcast_service.dart @@ -202,8 +202,6 @@ abstract class PodcastService { Future loadTranscriptByUrl({required TranscriptUrl transcriptUrl}); - Future deleteDownload(Episode episode); - Future toggleEpisodePlayed(Episode episode); Future> subscriptions(); diff --git a/lib/ui/anytime_podcast_app.dart b/lib/ui/anytime_podcast_app.dart index 690abe79..03eb0573 100644 --- a/lib/ui/anytime_podcast_app.dart +++ b/lib/ui/anytime_podcast_app.dart @@ -64,7 +64,7 @@ class AnytimePodcastApp extends StatefulWidget { late DownloadService downloadService; late AudioPlayerService audioPlayerService; late OPMLService opmlService; - PodcastService? podcastService; + late PodcastService podcastService; SettingsBloc? settingsBloc; MobileSettingsService mobileSettingsService; List certificateAuthorityBytes; @@ -82,24 +82,24 @@ class AnytimePodcastApp extends StatefulWidget { settingsService: mobileSettingsService, ); - assert(podcastService != null); - downloadService = MobileDownloadService( repository: repository, downloadManager: MobileDownloaderManager(), - podcastService: podcastService!, + settingsService: mobileSettingsService, + podcastService: podcastService, ); audioPlayerService = DefaultAudioPlayerService( repository: repository, settingsService: mobileSettingsService, - podcastService: podcastService!, + podcastService: podcastService, + downloadService: downloadService, ); settingsBloc = SettingsBloc(mobileSettingsService); opmlService = MobileOPMLService( - podcastService: podcastService!, + podcastService: podcastService, repository: repository, ); @@ -142,24 +142,27 @@ class AnytimePodcastAppState extends State { providers: [ Provider( create: (_) => SearchBloc( - podcastService: widget.podcastService!, + podcastService: widget.podcastService, ), dispose: (_, value) => value.dispose(), ), Provider( create: (_) => DiscoveryBloc( - podcastService: widget.podcastService!, + podcastService: widget.podcastService, ), dispose: (_, value) => value.dispose(), ), Provider( - create: (_) => - EpisodeBloc(podcastService: widget.podcastService!, audioPlayerService: widget.audioPlayerService), + create: (_) => EpisodeBloc( + podcastService: widget.podcastService, + audioPlayerService: widget.audioPlayerService, + downloadService: widget.downloadService, + ), dispose: (_, value) => value.dispose(), ), Provider( create: (_) => PodcastBloc( - podcastService: widget.podcastService!, + podcastService: widget.podcastService, audioPlayerService: widget.audioPlayerService, downloadService: widget.downloadService, settingsService: widget.mobileSettingsService), @@ -184,7 +187,7 @@ class AnytimePodcastAppState extends State { Provider( create: (_) => QueueBloc( audioPlayerService: widget.audioPlayerService, - podcastService: widget.podcastService!, + podcastService: widget.podcastService, ), dispose: (_, value) => value.dispose(), ) @@ -264,8 +267,7 @@ class _AnytimeHomePageState extends State with WidgetsBindingOb /// This method handles the actual link supplied from [uni_links], either /// at app startup or during running. void _handleLinkEvent(Uri uri) async { - if ((uri.scheme == 'anytime-subscribe' || uri.scheme == 'https') && - (uri.query.startsWith('uri=') || uri.query.startsWith('url='))) { + if ((uri.scheme == 'anytime-subscribe' || uri.scheme == 'https') && (uri.query.startsWith('uri=') || uri.query.startsWith('url='))) { var path = uri.query.substring(4); var loadPodcastBloc = Provider.of(context, listen: false); var routeName = NavigationRouteObserver().top!.settings.name; @@ -360,9 +362,7 @@ class _AnytimeHomePageState extends State with WidgetsBindingOb context, defaultTargetPlatform == TargetPlatform.iOS ? MaterialPageRoute( - fullscreenDialog: false, - settings: const RouteSettings(name: 'search'), - builder: (context) => const Search()) + fullscreenDialog: false, settings: const RouteSettings(name: 'search'), builder: (context) => const Search()) : SlideRightRoute( widget: const Search(), settings: const RouteSettings(name: 'search'), @@ -476,8 +476,7 @@ class _AnytimeHomePageState extends State with WidgetsBindingOb selectedItemColor: Theme.of(context).iconTheme.color, selectedFontSize: 11.0, unselectedFontSize: 11.0, - unselectedItemColor: - HSLColor.fromColor(Theme.of(context).bottomAppBarTheme.color!).withLightness(0.8).toColor(), + unselectedItemColor: HSLColor.fromColor(Theme.of(context).bottomAppBarTheme.color!).withLightness(0.8).toColor(), currentIndex: index, onTap: pager.changePage, items: [ diff --git a/pubspec.lock b/pubspec.lock index 75e1c0bd..6d8839fc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1088,7 +1088,7 @@ packages: source: hosted version: "1.3.0" synchronized: - dependency: transitive + dependency: "direct main" description: name: synchronized sha256: "51b08572b9f091f8c3eb4d9d4be253f196ff0075d5ec9b10a884026d5b55d7bc" @@ -1184,13 +1184,13 @@ packages: source: hosted version: "3.1.2" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid - sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.5.1" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 14fb862e..977855eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: Anytime Podcast Player version: 1.3.10+160 environment: - sdk: '>=3.2.0 <4.0.0' + sdk: ">=3.2.0 <4.0.0" flutter: 3.27.0 dependencies: @@ -47,7 +47,9 @@ dependencies: share_plus: ^10.0.2 shared_preferences: ^2.3.2 sliver_tools: ^0.2.12 + synchronized: ^3.1.0 url_launcher: ^6.1.12 + uuid: ^4.5.1 xml: 6.5.0 flutter: @@ -94,4 +96,3 @@ flutter: - family: MontserratBold fonts: - asset: assets/fonts/Montserrat-Bold.otf -