diff --git a/lib/app/home.dart b/lib/app/home.dart index 388fa8a..32ae7f5 100644 --- a/lib/app/home.dart +++ b/lib/app/home.dart @@ -8,7 +8,6 @@ import '../extensions/build_context_x.dart'; import '../player/player_manager.dart'; import '../player/view/player_full_view.dart'; import '../player/view/player_view.dart'; -import '../podcasts/download_manager.dart'; import '../podcasts/view/recent_downloads_button.dart'; import '../search/view/search_view.dart'; import '../settings/view/settings_dialog.dart'; @@ -18,9 +17,16 @@ class Home extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - registerStreamHandler( - select: (DownloadManager m) => m.messageStream, - handler: downloadMessageStreamHandler, + registerStreamHandler, CommandError>( + target: Command.globalErrors, + handler: (context, snapshot, cancel) { + if (snapshot.hasData) { + final error = snapshot.data!; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text('Download error: ${error.error}')), + ); + } + }, ); final playerFullWindowMode = watchValue( diff --git a/lib/extensions/podcast_item_x.dart b/lib/extensions/podcast_item_x.dart new file mode 100644 index 0000000..7f12fa9 --- /dev/null +++ b/lib/extensions/podcast_item_x.dart @@ -0,0 +1,32 @@ +import 'package:podcast_search/podcast_search.dart'; + +extension PodcastItemX on Item { + Map toJson() => { + 'artistId': artistId, + 'collectionId': collectionId, + 'trackId': trackId, + 'guid': guid, + 'artistName': artistName, + 'collectionName': collectionName, + 'collectionExplicitness': collectionExplicitness, + 'trackExplicitness': trackExplicitness, + 'trackName': trackName, + 'trackCount': trackCount, + 'collectionCensoredName': collectionCensoredName, + 'trackCensoredName': trackCensoredName, + 'artistViewUrl': artistViewUrl, + 'collectionViewUrl': collectionViewUrl, + 'feedUrl': feedUrl, + 'trackViewUrl': trackViewUrl, + 'artworkUrl30': artworkUrl30, + 'artworkUrl60': artworkUrl60, + 'artworkUrl100': artworkUrl100, + 'artworkUrl600': artworkUrl600, + 'releaseDate': releaseDate?.toIso8601String(), + 'country': country, + 'primaryGenreName': primaryGenreName, + 'contentAdvisoryRating': contentAdvisoryRating, + 'genreIds': genre?.map((g) => g.id.toString()).toList(), + 'genres': genre?.map((g) => g.name).toList(), + }; +} diff --git a/lib/extensions/shared_preferences_x.dart b/lib/extensions/shared_preferences_x.dart index 496b03e..4b77a53 100644 --- a/lib/extensions/shared_preferences_x.dart +++ b/lib/extensions/shared_preferences_x.dart @@ -22,10 +22,7 @@ extension SPKeys on SharedPreferences { static const usePlayerColor = 'usePlayerColor'; static const saveWindowSize = 'saveWindowSize'; static const podcastFeedUrls = 'podcastFeedUrls'; - static const podcastImageUrlSuffix = '_imageUrl'; - static const podcastNameSuffix = '_name'; - static const podcastArtistSuffix = '_artist'; - static const podcastGenreListSuffix = '_genreList'; + static const podcastDataSuffix = '_podcastData'; static const podcastEpisodeDownloadedSuffix = '_episodeDownloaded'; static const podcastsWithDownloads = 'podcastsWithDownloads'; static const podcastsWithUpdates = 'podcastsWithUpdates'; diff --git a/lib/player/player_manager.dart b/lib/player/player_manager.dart index eef8ea5..0aedb6d 100644 --- a/lib/player/player_manager.dart +++ b/lib/player/player_manager.dart @@ -7,12 +7,17 @@ import 'package:media_kit_video/media_kit_video.dart'; import '../common/logging.dart'; import '../extensions/color_x.dart'; +import '../podcasts/podcast_library_service.dart'; +import 'data/episode_media.dart'; import 'data/unique_media.dart'; import 'view/player_view_state.dart'; class PlayerManager extends BaseAudioHandler with SeekHandler { - PlayerManager({required VideoController controller}) - : _controller = controller { + PlayerManager({ + required VideoController controller, + required PodcastLibraryService podcastLibraryService, + }) : _controller = controller, + _podcastLibraryService = podcastLibraryService { playbackState.add( PlaybackState( playing: false, @@ -40,6 +45,7 @@ class PlayerManager extends BaseAudioHandler with SeekHandler { } final VideoController _controller; + final PodcastLibraryService _podcastLibraryService; VideoController get videoController => _controller; final playerViewState = ValueNotifier( @@ -234,7 +240,21 @@ class PlayerManager extends BaseAudioHandler with SeekHandler { }) async { if (mediaList.isEmpty) return; updateState(resetRemoteSource: true); - await _player.open(Playlist(mediaList, index: index), play: play); + await _player.open( + Playlist( + mediaList.map((e) { + if (e is EpisodeMedia && + _podcastLibraryService.getDownload(e.url) != null) { + return e.copyWithX( + resource: _podcastLibraryService.getDownload(e.url)!, + ); + } + return e; + }).toList(), + index: index, + ), + play: play, + ); } Future addToPlaylist(UniqueMedia media) async => _player.add(media); diff --git a/lib/player/view/player_podcast_favorite_button.dart b/lib/player/view/player_podcast_favorite_button.dart new file mode 100644 index 0000000..fb7e184 --- /dev/null +++ b/lib/player/view/player_podcast_favorite_button.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../../podcasts/podcast_manager.dart'; +import '../data/episode_media.dart'; + +class PlayerPodcastFavoriteButton extends StatelessWidget with WatchItMixin { + const PlayerPodcastFavoriteButton({super.key, required this.episodeMedia}) + : _floating = false; + const PlayerPodcastFavoriteButton.floating({ + super.key, + required this.episodeMedia, + }) : _floating = true; + + final EpisodeMedia episodeMedia; + final bool _floating; + + @override + Widget build(BuildContext context) { + final isSubscribed = watchValue( + (PodcastManager m) => m.getSubscribedPodcastsCommand.select( + (podcasts) => podcasts.any((p) => p.feedUrl == episodeMedia.feedUrl), + ), + ); + + void onPressed() => isSubscribed + ? di().removePodcast(feedUrl: episodeMedia.feedUrl) + : di().addPodcast( + Item( + feedUrl: episodeMedia.feedUrl, + artworkUrl: episodeMedia.albumArtUrl, + artworkUrl600: episodeMedia.albumArtUrl, + collectionName: episodeMedia.collectionName, + artistName: episodeMedia.artist, + ), + ); + final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); + + if (_floating) { + return FloatingActionButton.small( + heroTag: 'favtag', + onPressed: onPressed, + child: icon, + ); + } + + return IconButton(onPressed: onPressed, icon: icon); + } +} diff --git a/lib/player/view/player_track_info.dart b/lib/player/view/player_track_info.dart index 017e0f6..d3390cd 100644 --- a/lib/player/view/player_track_info.dart +++ b/lib/player/view/player_track_info.dart @@ -6,8 +6,10 @@ import '../../extensions/build_context_x.dart'; import '../../extensions/duration_x.dart'; import '../../radio/view/radio_browser_station_star_button.dart'; import '../../search/copy_to_clipboard_content.dart'; +import '../data/episode_media.dart'; import '../data/station_media.dart'; import '../player_manager.dart'; +import 'player_podcast_favorite_button.dart'; class PlayerTrackInfo extends StatelessWidget with WatchItMixin { const PlayerTrackInfo({ @@ -30,6 +32,7 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { final media = watchStream( (PlayerManager p) => p.currentMediaStream, initialValue: di().currentMedia, + allowStreamChange: true, preserveState: false, ).data; @@ -90,7 +93,10 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { ], ), ), - if (media is StationMedia) const RadioStationStarButton(), + if (media is StationMedia) + RadioStationStarButton(currentMedia: media) + else if (media is EpisodeMedia) + PlayerPodcastFavoriteButton(episodeMedia: media), ], ), ); diff --git a/lib/podcasts/data/podcast_metadata.dart b/lib/podcasts/data/podcast_metadata.dart deleted file mode 100644 index 259dae2..0000000 --- a/lib/podcasts/data/podcast_metadata.dart +++ /dev/null @@ -1,15 +0,0 @@ -class PodcastMetadata { - const PodcastMetadata({ - required this.feedUrl, - this.imageUrl, - this.name, - this.artist, - this.genreList, - }); - - final String feedUrl; - final String? imageUrl; - final String? name; - final String? artist; - final List? genreList; -} diff --git a/lib/podcasts/download_manager.dart b/lib/podcasts/download_manager.dart deleted file mode 100644 index bcd89a3..0000000 --- a/lib/podcasts/download_manager.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; - -import '../player/data/episode_media.dart'; -import 'data/download_capsule.dart'; -import 'podcast_library_service.dart'; - -class DownloadManager extends ChangeNotifier { - DownloadManager({ - required PodcastLibraryService libraryService, - required Dio dio, - }) : _libraryService = libraryService, - _dio = dio { - _propertiesChangedSubscription = _libraryService.propertiesChanged.listen( - (_) => notifyListeners(), - ); - } - - final PodcastLibraryService _libraryService; - final Dio _dio; - StreamSubscription? _propertiesChangedSubscription; - - final _messageStreamController = StreamController.broadcast(); - String _lastMessage = ''; - void _addMessage(String message) { - if (message == _lastMessage) return; - _lastMessage = message; - _messageStreamController.add(message); - } - - Stream get messageStream => _messageStreamController.stream; - - List get feedsWithDownloads => _libraryService.feedsWithDownloads; - String? getDownload(String? url) => _libraryService.getDownload(url); - bool isDownloaded(String? url) => getDownload(url) != null; - final _episodeToProgress = {}; - Map get episodeToProgress => _episodeToProgress; - bool getDownloadsInProgress() => _episodeToProgress.values.any( - (progress) => progress != null && progress < 1.0, - ); - - double? getProgress(EpisodeMedia? episode) => _episodeToProgress[episode]; - void setProgress({ - required int received, - required int total, - required EpisodeMedia episode, - }) { - if (total <= 0) return; - _episodeToProgress[episode] = received / total; - notifyListeners(); - } - - final _episodeToCancelToken = {}; - bool _canCancelDownload(EpisodeMedia episode) => - _episodeToCancelToken[episode] != null; - Future startOrCancelDownload(DownloadCapsule capsule) async { - final url = capsule.media.url; - - if (url == null) { - throw Exception('Invalid media, missing URL to download'); - } - - if (_canCancelDownload(capsule.media)) { - await _cancelDownload(capsule.media); - await deleteDownload(media: capsule.media); - return null; - } - - if (!Directory(capsule.downloadsDir).existsSync()) { - Directory(capsule.downloadsDir).createSync(); - } - - final toDownloadPath = p.join( - capsule.downloadsDir, - capsule.media.audioDownloadId, - ); - final response = await _processDownload( - canceledMessage: capsule.canceledMessage, - episode: capsule.media, - path: toDownloadPath, - ); - - if (response?.statusCode == 200) { - await _libraryService.addDownload( - episodeUrl: url, - path: toDownloadPath, - feedUrl: capsule.media.feedUrl, - ); - _episodeToCancelToken.remove(capsule.media); - _addMessage(capsule.finishedMessage); - notifyListeners(); - } - return _libraryService.getDownload(url); - } - - Future _cancelDownload(EpisodeMedia? episode) async { - if (episode == null) return; - _episodeToCancelToken[episode]?.cancel(); - _episodeToProgress.remove(episode); - _episodeToCancelToken.remove(episode); - notifyListeners(); - } - - Future cancelAllDownloads() async { - final episodes = _episodeToCancelToken.keys.toList(); - for (final episode in episodes) { - _episodeToCancelToken[episode]?.cancel(); - _episodeToProgress.remove(episode); - _episodeToCancelToken.remove(episode); - } - notifyListeners(); - } - - Future?> _processDownload({ - required EpisodeMedia episode, - required String path, - required String canceledMessage, - }) async { - _episodeToCancelToken[episode] = CancelToken(); - try { - return await _dio.download( - episode.url!, - path, - onReceiveProgress: (count, total) => - setProgress(received: count, total: total, episode: episode), - cancelToken: _episodeToCancelToken[episode], - ); - } catch (e) { - _episodeToCancelToken[episode]?.cancel(); - - String? message; - if (e.toString().contains('[request cancelled]')) { - message = canceledMessage; - } - - _addMessage(message ?? e.toString()); - return null; - } - } - - Future deleteDownload({required EpisodeMedia? media}) async { - if (media?.url != null && media?.feedUrl != null) { - await _libraryService.removeDownload( - episodeUrl: media!.url!, - feedUrl: media.feedUrl, - ); - _episodeToProgress.remove(media); - notifyListeners(); - } - } - - Future deleteAllDownloads() async { - if (_episodeToProgress.isNotEmpty) { - throw Exception( - 'Cannot delete all downloads while downloads are in progress', - ); - } - await _libraryService.removeAllDownloads(); - _episodeToProgress.clear(); - notifyListeners(); - } - - @override - Future dispose() async { - await cancelAllDownloads(); - await _messageStreamController.close(); - await _propertiesChangedSubscription?.cancel(); - super.dispose(); - } -} - -void downloadMessageStreamHandler( - BuildContext context, - AsyncSnapshot snapshot, - void Function() cancel, -) { - if (snapshot.hasData) { - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(SnackBar(content: Text(snapshot.data!))); - } -} diff --git a/lib/podcasts/download_service.dart b/lib/podcasts/download_service.dart new file mode 100644 index 0000000..63694ed --- /dev/null +++ b/lib/podcasts/download_service.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; + +import '../player/data/episode_media.dart'; +import '../settings/settings_service.dart'; +import 'podcast_library_service.dart'; + +/// Service for downloading podcast episodes. +/// +/// This is a stateless service - download progress and state are managed +/// by episode download commands. +class DownloadService { + DownloadService({ + required PodcastLibraryService libraryService, + required Dio dio, + required SettingsService settingsService, + }) : _libraryService = libraryService, + _dio = dio, + _settingsService = settingsService; + + final PodcastLibraryService _libraryService; + final SettingsService _settingsService; + final Dio _dio; + + /// Downloads an episode to the local filesystem. + /// + /// Used by episode download commands. Progress updates are sent via the + /// onProgress callback. + Future download({ + required EpisodeMedia episode, + required CancelToken cancelToken, + required void Function(int received, int total) onProgress, + }) async { + final url = episode.url; + if (url == null) { + throw Exception('Invalid media, missing URL to download'); + } + + final downloadsDir = _settingsService.downloadsDir; + if (downloadsDir == null) { + throw Exception('Downloads directory not set'); + } + + if (!Directory(downloadsDir).existsSync()) { + Directory(downloadsDir).createSync(recursive: true); + } + + final path = p.join(downloadsDir, episode.audioDownloadId); + + final response = await _dio.download( + url, + path, + onReceiveProgress: onProgress, + cancelToken: cancelToken, + ); + + if (response.statusCode == 200) { + await _libraryService.addDownload( + episodeUrl: url, + path: path, + feedUrl: episode.feedUrl, + ); + return path; + } + + return null; + } + + /// Deletes a downloaded episode from the filesystem and library. + Future deleteDownload({required EpisodeMedia? media}) async { + if (media?.url != null && media?.feedUrl != null) { + await _libraryService.removeDownload( + episodeUrl: media!.url!, + feedUrl: media.feedUrl, + ); + } + } + + /// Deletes all downloaded episodes. + Future deleteAllDownloads() async => + _libraryService.removeAllDownloads(); +} diff --git a/lib/podcasts/podcast_library_service.dart b/lib/podcasts/podcast_library_service.dart index d9a5664..46b89d8 100644 --- a/lib/podcasts/podcast_library_service.dart +++ b/lib/podcasts/podcast_library_service.dart @@ -1,11 +1,13 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:podcast_search/podcast_search.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../extensions/date_time_x.dart'; +import '../extensions/podcast_item_x.dart'; import '../extensions/shared_preferences_x.dart'; -import 'data/podcast_metadata.dart'; class PodcastLibraryService { PodcastLibraryService({required SharedPreferences sharedPreferences}) @@ -14,8 +16,8 @@ class PodcastLibraryService { final SharedPreferences _sharedPreferences; - // This stream is currently used for downloads - // TODO: replace with download commmand in DownloadManager + // This stream is currently used for notifying whoever is listening to changes + // final _propertiesChangedController = StreamController.broadcast(); Stream get propertiesChanged => _propertiesChangedController.stream; Future notify(bool value) async => @@ -25,67 +27,52 @@ class PodcastLibraryService { /// Podcasts /// - Set get _podcasts => + Set get _subscribedPodcastFeeds => _sharedPreferences.getStringList(SPKeys.podcastFeedUrls)?.toSet() ?? {}; - Set _getFilteredPodcasts(String? filterText) { - return podcasts.where((feedUrl) { - if (filterText == null || filterText.isEmpty) return true; - final name = getSubscribedPodcastName(feedUrl); - final artist = getSubscribedPodcastArtist(feedUrl); - return (name != null && - name.toLowerCase().contains(filterText.toLowerCase())) || - (artist != null && - artist.toLowerCase().contains(filterText.toLowerCase())); - }).toSet(); - } + List getFilteredPodcastsItems(String? filterText) => podcasts + .map((feedUrl) => getPodcastItem(feedUrl)) + .whereType() + .where((item) { + if (filterText == null || filterText.isEmpty) return true; - List getFilteredPodcastsWithMetadata(String? filterText) { - final filteredFeedUrls = _getFilteredPodcasts(filterText); - final result = []; - for (final feedUrl in filteredFeedUrls) { - final metadata = getPodcastMetadata(feedUrl); - result.add(metadata); - } - return result; - } + final term = filterText.toLowerCase(); + final name = item.collectionName?.toLowerCase() ?? ''; + final artist = item.artistName?.toLowerCase() ?? ''; - bool isPodcastSubscribed(String feedUrl) => _podcasts.contains(feedUrl); - List get podcastFeedUrls => _podcasts.toList(); - Set get podcasts => _podcasts; - int get podcastsLength => _podcasts.length; + return name.contains(term) || artist.contains(term); + }) + .toList(); + + bool isPodcastSubscribed(String? feedUrl) => + feedUrl != null && _subscribedPodcastFeeds.contains(feedUrl); + List get podcastFeedUrls => _subscribedPodcastFeeds.toList(); + Set get podcasts => _subscribedPodcastFeeds; + int get podcastsLength => _subscribedPodcastFeeds.length; // Adding and removing Podcasts // ------------------ - Future addPodcast(PodcastMetadata metadata) async { - if (isPodcastSubscribed(metadata.feedUrl)) return; - await _addPodcastMetadata(metadata); - await _sharedPreferences.setStringList(SPKeys.podcastFeedUrls, [ - ...List.from(_podcasts), - metadata.feedUrl, - ]); - } - - Future addPodcasts(List metadata) async { - if (metadata.isEmpty) return; - final newList = List.from(_podcasts); - for (var p in metadata) { - if (!newList.contains(p.feedUrl)) { - await _addPodcastMetadata(p); - newList.add(p.feedUrl); + Future addSubscribedPodcasts(List items) async { + if (items.isEmpty) return; + final newList = List.from(_subscribedPodcastFeeds); + for (var item in items) { + if (item.feedUrl != null && !newList.contains(item.feedUrl)) { + await addPodcastData(item); + newList.add(item.feedUrl!); } } await _sharedPreferences.setStringList(SPKeys.podcastFeedUrls, newList); } - Future removePodcast(String feedUrl, {bool update = true}) async { + Future removeSubscribedPodcast( + String feedUrl, { + bool update = true, + }) async { if (!isPodcastSubscribed(feedUrl)) return; - final newList = List.from(_podcasts)..remove(feedUrl); + final newList = List.from(_subscribedPodcastFeeds)..remove(feedUrl); await _removeFeedWithDownload(feedUrl); - removeSubscribedPodcastImage(feedUrl); - removeSubscribedPodcastName(feedUrl); - removeSubscribedPodcastArtist(feedUrl); + await removeSubscribedPodcastData(feedUrl); _removePodcastLastUpdated(feedUrl); if (update) { @@ -95,90 +82,29 @@ class PodcastLibraryService { // Podcast Metadata // ------------------ - Future _addPodcastMetadata(PodcastMetadata metadata) async { - if (metadata.imageUrl != null) { - addSubscribedPodcastImage( - feedUrl: metadata.feedUrl, - imageUrl: metadata.imageUrl!, - ); - } - if (metadata.name != null) { - addSubscribedPodcastName(feedUrl: metadata.feedUrl, name: metadata.name!); - } - if (metadata.artist != null) { - addSubscribedPodcastArtist( - feedUrl: metadata.feedUrl, - artist: metadata.artist!, - ); - } - if (metadata.genreList != null) { - addSubscribedPodcastGenreList( - feedUrl: metadata.feedUrl, - genreList: metadata.genreList!, - ); - } - await addPodcastLastUpdated( - feedUrl: metadata.feedUrl, - timestamp: DateTime.now().podcastTimeStamp, + + Future addPodcastData(Item item) { + if (item.feedUrl == null) return Future.value(false); + final jsonString = jsonEncode(item.toJson()); + return _sharedPreferences.setString( + item.feedUrl! + SPKeys.podcastDataSuffix, + jsonString, ); } - PodcastMetadata getPodcastMetadata(String feedUrl) => PodcastMetadata( - feedUrl: feedUrl, - imageUrl: getSubscribedPodcastImage(feedUrl), - name: getSubscribedPodcastName(feedUrl), - artist: getSubscribedPodcastArtist(feedUrl), - genreList: getSubScribedPodcastGenreList(feedUrl), - ); - - // Image URL - String? getSubscribedPodcastImage(String feedUrl) => - _sharedPreferences.getString(feedUrl + SPKeys.podcastImageUrlSuffix); - void addSubscribedPodcastImage({ - required String feedUrl, - required String imageUrl, - }) => _sharedPreferences.setString( - feedUrl + SPKeys.podcastImageUrlSuffix, - imageUrl, - ); - void removeSubscribedPodcastImage(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastImageUrlSuffix); - - // Name - String? getSubscribedPodcastName(String feedUrl) => - _sharedPreferences.getString(feedUrl + SPKeys.podcastNameSuffix); - void addSubscribedPodcastName({ - required String feedUrl, - required String name, - }) => _sharedPreferences.setString(feedUrl + SPKeys.podcastNameSuffix, name); - void removeSubscribedPodcastName(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastNameSuffix); - - // Artist - String? getSubscribedPodcastArtist(String feedUrl) => - _sharedPreferences.getString(feedUrl + SPKeys.podcastArtistSuffix); - void addSubscribedPodcastArtist({ - required String feedUrl, - required String artist, - }) => _sharedPreferences.setString( - feedUrl + SPKeys.podcastArtistSuffix, - artist, - ); - void removeSubscribedPodcastArtist(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastArtistSuffix); - - // Genre List - List? getSubScribedPodcastGenreList(String feedUrl) => - _sharedPreferences.getStringList(feedUrl + SPKeys.podcastGenreListSuffix); - void addSubscribedPodcastGenreList({ - required String feedUrl, - required List genreList, - }) => _sharedPreferences.setStringList( - feedUrl + SPKeys.podcastGenreListSuffix, - genreList, - ); - void removeSubscribedPodcastGenreList(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastGenreListSuffix); + Item? getPodcastItem(String feedUrl) { + var string = _sharedPreferences.getString( + feedUrl + SPKeys.podcastDataSuffix, + ); + Map? json; + if (string != null) { + json = jsonDecode(string); + } + return Item.fromJson(json: json); + } + + Future removeSubscribedPodcastData(String feedUrl) => + _sharedPreferences.remove(feedUrl + SPKeys.podcastDataSuffix); // Podcast Downloads // ------------------ @@ -267,8 +193,7 @@ class PodcastLibraryService { _deleteDownload(key); await _sharedPreferences.remove(key); } - await _sharedPreferences.remove(SPKeys.podcastsWithDownloads); - _propertiesChangedController.add(true); + await _sharedPreferences.remove(SPKeys.podcastsWithDownloads).then(notify); } Future _removeFeedWithDownload(String feedUrl) async { @@ -287,10 +212,9 @@ class PodcastLibraryService { Future addPodcastLastUpdated({ required String feedUrl, required String timestamp, - }) async => _sharedPreferences.setString( - feedUrl + SPKeys.podcastLastUpdatedSuffix, - timestamp, - ); + }) async => _sharedPreferences + .setString(feedUrl + SPKeys.podcastLastUpdatedSuffix, timestamp) + .then(notify); void _removePodcastLastUpdated(String feedUrl) => _sharedPreferences.remove(feedUrl + SPKeys.podcastLastUpdatedSuffix); @@ -308,12 +232,11 @@ class PodcastLibraryService { await _sharedPreferences .setStringList(SPKeys.podcastsWithUpdates, updatedFeeds.toList()) .then( - (_) => addPodcastLastUpdated( + (v) => addPodcastLastUpdated( feedUrl: feedUrl, timestamp: lastUpdated.podcastTimeStamp, ), - ) - .then((_) => _propertiesChangedController.add(true)); + ); } Future removePodcastUpdate(String feedUrl) async { @@ -322,7 +245,7 @@ class PodcastLibraryService { final updatedFeeds = Set.from(_podcastUpdates!)..remove(feedUrl); await _sharedPreferences .setStringList(SPKeys.podcastsWithUpdates, updatedFeeds.toList()) - .then((_) => _propertiesChangedController.add(true)); + .then(notify); } // Podcast Episode Ordering @@ -333,13 +256,11 @@ class PodcastLibraryService { Future _addAscendingPodcast(String feedUrl) async { await _sharedPreferences .setBool(SPKeys.ascendingFeeds + feedUrl, true) - .then((_) => _propertiesChangedController.add(true)); + .then(notify); } Future _removeAscendingPodcast(String feedUrl) async => - _sharedPreferences - .remove(SPKeys.ascendingFeeds + feedUrl) - .then((_) => _propertiesChangedController.add(true)); + _sharedPreferences.remove(SPKeys.ascendingFeeds + feedUrl).then(notify); Future reorderPodcast({ required String feedUrl, diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 96a4725..cbd20a7 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -1,12 +1,17 @@ +import 'package:dio/dio.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:podcast_search/podcast_search.dart'; import '../collection/collection_manager.dart'; import '../common/logging.dart'; import '../extensions/country_x.dart'; +import '../extensions/date_time_x.dart'; +import '../extensions/podcast_x.dart'; +import '../extensions/string_x.dart'; +import '../notifications/notifications_service.dart'; import '../player/data/episode_media.dart'; import '../search/search_manager.dart'; -import 'data/podcast_metadata.dart'; +import 'download_service.dart'; import 'podcast_library_service.dart'; import 'podcast_service.dart'; @@ -18,11 +23,16 @@ import 'podcast_service.dart'; class PodcastManager { PodcastManager({ required PodcastService podcastService, + required DownloadService downloadService, required SearchManager searchManager, required CollectionManager collectionManager, required PodcastLibraryService podcastLibraryService, + required NotificationsService notificationsService, }) : _podcastService = podcastService, - _podcastLibraryService = podcastLibraryService { + _downloadService = downloadService, + _podcastLibraryService = podcastLibraryService, + _notificationsService = notificationsService, + _searchManager = searchManager { Command.globalExceptionHandler = (e, s) { printMessageInDebugMode(e.error, s); }; @@ -36,43 +46,189 @@ class PodcastManager { ); // Subscription doesn't need disposal - manager lives for app lifetime - searchManager.textChangedCommand + _searchManager.textChangedCommand .debounce(const Duration(milliseconds: 500)) .listen((filterText, sub) => updateSearchCommand.run(filterText)); - podcastsCommand = Command.createSync( + getSubscribedPodcastsCommand = Command.createSync( (filterText) => - podcastLibraryService.getFilteredPodcastsWithMetadata(filterText), + podcastLibraryService.getFilteredPodcastsItems(filterText), initialValue: [], ); collectionManager.textChangedCommand.listen( - (filterText, sub) => podcastsCommand.run(filterText), + (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - fetchEpisodeMediaCommand = Command.createAsync>( - (podcast) => _podcastService.findEpisodes(item: podcast), - initialValue: [], - ); - - podcastsCommand.run(null); + getSubscribedPodcastsCommand.run(null); updateSearchCommand.run(null); } final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; + final DownloadService _downloadService; + final NotificationsService _notificationsService; + final SearchManager _searchManager; + + // Map of feedUrl to fetch episodes command + final _fetchEpisodeMediaCommands = + >>{}; + + Command> _getFetchEpisodesCommand(Item item) { + if (item.feedUrl == null) { + throw ArgumentError('Item must have a feedUrl to fetch episodes'); + } + return _fetchEpisodeMediaCommands.putIfAbsent( + item.feedUrl!, + () => Command.createAsync>( + (item) async => findEpisodes(item: item), + initialValue: [], + ), + ); + } + + Command> runFetchEpisodesCommand(Item item) { + _getFetchEpisodesCommand(item).run(item); + return _getFetchEpisodesCommand(item); + } + late Command updateSearchCommand; - late Command> fetchEpisodeMediaCommand; - late Command> podcastsCommand; + late Command> getSubscribedPodcastsCommand; + + final _downloadCommands = >{}; + final activeDownloads = ListNotifier(); + final recentDownloads = ListNotifier(); + + Command getDownloadCommand(EpisodeMedia media) => + _downloadCommands.putIfAbsent(media, () => _createDownloadCommand(media)); + + Command _createDownloadCommand(EpisodeMedia media) { + final command = Command.createAsyncNoParamNoResultWithProgress(( + handle, + ) async { + activeDownloads.add(media); + + final cancelToken = CancelToken(); + + handle.isCanceled.listen((canceled, subscription) { + if (canceled) { + activeDownloads.remove(media); + cancelToken.cancel(); + subscription.cancel(); + } + }); + + await _downloadService.download( + episode: media, + cancelToken: cancelToken, + onProgress: (received, total) { + handle.updateProgress(received / total); + }, + ); + + activeDownloads.remove(media); + recentDownloads.add(media); + }, errorFilter: const LocalAndGlobalErrorFilter()); - Future addPodcast(PodcastMetadata metadata) async { - await _podcastLibraryService.addPodcast(metadata); - podcastsCommand.run(); + if (_podcastLibraryService.getDownload(media.url) != null) { + command.resetProgress(progress: 1.0); + } + + return command; + } + + Future addPodcast(Item item) async { + await _podcastLibraryService.addSubscribedPodcasts([item]); + getSubscribedPodcastsCommand.run(_searchManager.textChangedCommand.value); } Future removePodcast({required String feedUrl}) async { - await _podcastLibraryService.removePodcast(feedUrl); - podcastsCommand.run(); + await _podcastLibraryService.removeSubscribedPodcast(feedUrl); + getSubscribedPodcastsCommand.run(_searchManager.textChangedCommand.value); + } + + final Map _podcastCache = {}; + Podcast? getPodcastFromCache(String? feedUrl) => _podcastCache[feedUrl]; + String? getPodcastDescriptionFromCache(String? feedUrl) => + _podcastCache[feedUrl]?.description; + + Future> findEpisodes({ + Item? item, + String? feedUrl, + bool loadFromCache = true, + }) async { + if (item == null && item?.feedUrl == null && feedUrl == null) { + return Future.error( + ArgumentError('Either item or feedUrl must be provided'), + ); + } + + final url = feedUrl ?? item!.feedUrl!; + + Podcast? podcast; + if (loadFromCache && _podcastCache.containsKey(url)) { + podcast = _podcastCache[url]; + } else { + podcast = await _podcastService.fetchPodcast(item: item, feedUrl: url); + if (podcast != null) { + _podcastCache[url] = podcast; + } + } + + return podcast?.toEpisodeMediaList(url, item) ?? []; + } + + Future checkForUpdates({ + Set? feedUrls, + required String updateMessage, + required String Function(int length) multiUpdateMessage, + }) async { + final newUpdateFeedUrls = {}; + + for (final feedUrl in (feedUrls ?? _podcastLibraryService.podcasts)) { + final storedTimeStamp = _podcastLibraryService.getPodcastLastUpdated( + feedUrl, + ); + DateTime? feedLastUpdated; + try { + feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); + } on Exception catch (e) { + printMessageInDebugMode(e); + } + final name = _podcastLibraryService + .getPodcastItem(feedUrl) + ?.collectionName; + + printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); + printMessageInDebugMode( + 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', + ); + printMessageInDebugMode( + 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', + ); + + if (feedLastUpdated == null) continue; + + await _podcastLibraryService.addPodcastLastUpdated( + feedUrl: feedUrl, + timestamp: feedLastUpdated.podcastTimeStamp, + ); + + if (storedTimeStamp != null && + !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { + await findEpisodes(feedUrl: feedUrl, loadFromCache: false); + await _podcastLibraryService.addPodcastUpdate(feedUrl, feedLastUpdated); + + newUpdateFeedUrls.add(feedUrl); + } + } + + if (newUpdateFeedUrls.isNotEmpty) { + final msg = newUpdateFeedUrls.length == 1 + ? '$updateMessage${_podcastCache[newUpdateFeedUrls.first]?.title != null ? ' ${_podcastCache[newUpdateFeedUrls.first]?.title}' : ''}' + : multiUpdateMessage(newUpdateFeedUrls.length); + await _notificationsService.notify(message: msg); + } } } diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 7459097..97bc4a4 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -1,32 +1,18 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:podcast_search/podcast_search.dart'; import '../common/logging.dart'; -import '../extensions/date_time_x.dart'; -import '../extensions/podcast_x.dart'; import '../extensions/shared_preferences_x.dart'; -import '../extensions/string_x.dart'; -import '../notifications/notifications_service.dart'; -import '../player/data/episode_media.dart'; import '../settings/settings_service.dart'; import 'data/podcast_genre.dart'; import 'data/simple_language.dart'; -import 'podcast_library_service.dart'; class PodcastService { - final NotificationsService _notificationsService; final SettingsService _settingsService; - final PodcastLibraryService _libraryService; - PodcastService({ - required NotificationsService notificationsService, - required SettingsService settingsService, - required PodcastLibraryService libraryService, - }) : _notificationsService = notificationsService, - _settingsService = settingsService, - _libraryService = libraryService { + PodcastService({required SettingsService settingsService}) + : _settingsService = settingsService { _search = Search( searchProvider: _settingsService.getBool(SPKeys.usePodcastIndex) == true && @@ -83,106 +69,16 @@ class PodcastService { } } - bool _updateLock = false; - - Future checkForUpdates({ - Set? feedUrls, - required String updateMessage, - required String Function(int length) multiUpdateMessage, - }) async { - if (_updateLock) return; - _updateLock = true; - - final newUpdateFeedUrls = {}; - - for (final feedUrl in (feedUrls ?? _libraryService.podcasts)) { - final storedTimeStamp = _libraryService.getPodcastLastUpdated(feedUrl); - DateTime? feedLastUpdated; - try { - feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); - } on Exception catch (e) { - printMessageInDebugMode(e); - } - final name = _libraryService.getSubscribedPodcastName(feedUrl); - - printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); - printMessageInDebugMode( - 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', - ); - printMessageInDebugMode( - 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', - ); - - if (feedLastUpdated == null) continue; - - await _libraryService.addPodcastLastUpdated( - feedUrl: feedUrl, - timestamp: feedLastUpdated.podcastTimeStamp, - ); - - if (storedTimeStamp != null && - !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { - await findEpisodes(feedUrl: feedUrl, loadFromCache: false); - await _libraryService.addPodcastUpdate(feedUrl, feedLastUpdated); - - newUpdateFeedUrls.add(feedUrl); - } - } - - if (newUpdateFeedUrls.isNotEmpty) { - final msg = newUpdateFeedUrls.length == 1 - ? '$updateMessage${_episodeCache[newUpdateFeedUrls.first]?.firstOrNull?.collectionName != null ? ' ${_episodeCache[newUpdateFeedUrls.first]?.firstOrNull?.collectionName}' : ''}' - : multiUpdateMessage(newUpdateFeedUrls.length); - await _notificationsService.notify(message: msg); - } - - _updateLock = false; - } - - List? getPodcastEpisodesFromCache(String? feedUrl) => - _episodeCache[feedUrl]; - final Map> _episodeCache = {}; - - final Map _podcastDescriptionCache = {}; - String? getPodcastDescriptionFromCache(String? feedUrl) => - _podcastDescriptionCache[feedUrl]; - - Future> findEpisodes({ - Item? item, - String? feedUrl, - bool loadFromCache = true, - }) async { + Future fetchPodcast({Item? item, String? feedUrl}) async { if (item == null && item?.feedUrl == null && feedUrl == null) { - printMessageInDebugMode('findEpisodes called without feedUrl or item'); - return Future.value([]); - } - - final url = feedUrl ?? item!.feedUrl!; - - if (_episodeCache.containsKey(url) && loadFromCache) { - if (item?.bestArtworkUrl != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: item!.bestArtworkUrl!, - ); - } - return _episodeCache[url]!; - } - - final Podcast? podcast = await compute(loadPodcast, url); - if (podcast?.image != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: podcast!.image!, + return Future.error( + ArgumentError('Either item or feedUrl must be provided'), ); } - final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; - - _episodeCache[url] = episodes; - _podcastDescriptionCache[url] = podcast?.description; + final url = feedUrl ?? item!.feedUrl!; - return episodes; + return compute(loadPodcast, url); } } diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index 1e50f15..1287749 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -1,49 +1,43 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import 'package:podcast_search/podcast_search.dart'; import '../../extensions/build_context_x.dart'; import '../../player/data/episode_media.dart'; -import '../../settings/settings_manager.dart'; -import '../data/download_capsule.dart'; -import '../download_manager.dart'; +import '../download_service.dart'; +import '../podcast_manager.dart'; class DownloadButton extends StatelessWidget { - const DownloadButton({ - super.key, - required this.episode, - required this.addPodcast, - }); + const DownloadButton({super.key, required this.episode}); final EpisodeMedia episode; - final void Function()? addPodcast; @override Widget build(BuildContext context) => Stack( alignment: Alignment.center, children: [ _DownloadProgress(episode: episode), - _ProcessDownloadButton(episode: episode, addPodcast: addPodcast), + _ProcessDownloadButton(episode: episode), ], ); } class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { - const _ProcessDownloadButton({required this.episode, this.addPodcast}); + const _ProcessDownloadButton({required this.episode}); final EpisodeMedia episode; - final void Function()? addPodcast; @override Widget build(BuildContext context) { final theme = context.theme; + final downloadCommand = di().getDownloadCommand(episode); - final isDownloaded = watchPropertyValue( - (DownloadManager m) => m.isDownloaded(episode.url), - ); + final progress = watch(downloadCommand.progress).value; + final isDownloaded = progress == 1.0; + + final isRunning = watch(downloadCommand.isRunning).value; - final downloadsDir = watchValue( - (SettingsManager m) => m.downloadsDirCommand, - ); return IconButton( isSelected: isDownloaded, tooltip: isDownloaded @@ -53,27 +47,29 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { isDownloaded ? Icons.download_done : Icons.download_outlined, color: isDownloaded ? theme.colorScheme.primary : null, ), - onPressed: downloadsDir == null - ? null - : () { - if (isDownloaded) { - di().deleteDownload(media: episode); - } else { - addPodcast?.call(); - di().startOrCancelDownload( - DownloadCapsule( - finishedMessage: context.l10n.downloadFinished( - episode.title ?? '', - ), - canceledMessage: context.l10n.downloadCancelled( - episode.title ?? '', - ), - media: episode, - downloadsDir: downloadsDir, - ), - ); - } - }, + onPressed: () { + if (isDownloaded) { + di().deleteDownload(media: episode); + di().recentDownloads.remove(episode); + downloadCommand.resetProgress(); + } else if (isRunning) { + downloadCommand.cancel(); + } else { + // Add podcast to library before downloading + di().addPodcast( + Item( + feedUrl: episode.feedUrl, + artworkUrl: episode.albumArtUrl, + collectionName: episode.collectionName, + artistName: episode.artist, + genre: episode.genres + .mapIndexed((index, genre) => Genre(index, genre)) + .toList(), + ), + ); + downloadCommand.run(); + } + }, color: isDownloaded ? theme.colorScheme.primary : theme.colorScheme.onSurface, @@ -82,20 +78,21 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { } class _DownloadProgress extends StatelessWidget with WatchItMixin { - const _DownloadProgress({this.episode}); + const _DownloadProgress({required this.episode}); - final EpisodeMedia? episode; + final EpisodeMedia episode; @override Widget build(BuildContext context) { - final value = watchPropertyValue( - (DownloadManager m) => m.getProgress(episode), - ); + final progress = watch( + di().getDownloadCommand(episode).progress, + ).value; + return SizedBox.square( dimension: (context.theme.buttonTheme.height / 2 * 2) - 3, child: CircularProgressIndicator( padding: EdgeInsets.zero, - value: value == null || value == 1.0 ? 0 : value, + value: progress == 1.0 ? 0 : progress, backgroundColor: Colors.transparent, ), ); diff --git a/lib/podcasts/view/episode_tile.dart b/lib/podcasts/view/episode_tile.dart index 291f590..d5de30d 100644 --- a/lib/podcasts/view/episode_tile.dart +++ b/lib/podcasts/view/episode_tile.dart @@ -9,8 +9,6 @@ import '../../extensions/duration_x.dart'; import '../../extensions/string_x.dart'; import '../../player/data/episode_media.dart'; import '../../player/player_manager.dart'; -import '../data/podcast_metadata.dart'; -import '../podcast_manager.dart'; import 'download_button.dart'; class EpisodeTile extends StatelessWidget with WatchItMixin { @@ -84,18 +82,7 @@ class EpisodeTile extends StatelessWidget with WatchItMixin { Text( '${episode.creationDateTime!.unixTimeToDateString} ยท ${episode.duration?.formattedTime ?? 'Unknown duration'}', ), - DownloadButton( - episode: episode, - addPodcast: () => di().addPodcast( - PodcastMetadata( - feedUrl: episode.feedUrl, - imageUrl: podcastImage, - artist: episode.artist ?? '', - name: episode.collectionName ?? '', - genreList: episode.genres, - ), - ), - ), + DownloadButton(episode: episode), ], ), titleTextStyle: theme.textTheme.labelSmall, diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index 77be315..471b646 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_it/flutter_it.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:phoenix_theme/phoenix_theme.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -8,9 +6,7 @@ import '../../common/view/safe_network_image.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; -import '../../player/player_manager.dart'; -import '../download_manager.dart'; -import '../podcast_service.dart'; +import 'podcast_card_play_button.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page.dart'; @@ -94,33 +90,8 @@ class _PodcastCardState extends State { spacing: kBigPadding, mainAxisSize: MainAxisSize.min, children: [ - FloatingActionButton.small( - heroTag: 'podcastcardfap', - onPressed: () async { - final res = await showFutureLoadingDialog( - context: context, - future: () async => di() - .findEpisodes(item: widget.podcastItem), - ); - if (res.isValue) { - final episodes = res.asValue!.value; - final withDownloads = episodes.map((e) { - final download = di() - .getDownload(e.id); - if (download != null) { - return e.copyWithX(resource: download); - } - return e; - }).toList(); - if (withDownloads.isNotEmpty) { - await di().setPlaylist( - withDownloads, - index: 0, - ); - } - } - }, - child: const Icon(Icons.play_arrow), + PodcastCardPlayButton( + podcastItem: widget.podcastItem, ), PodcastFavoriteButton.floating( podcastItem: widget.podcastItem, diff --git a/lib/podcasts/view/podcast_card_play_button.dart b/lib/podcasts/view/podcast_card_play_button.dart new file mode 100644 index 0000000..0e06f8a --- /dev/null +++ b/lib/podcasts/view/podcast_card_play_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../../extensions/build_context_x.dart'; +import '../../player/player_manager.dart'; +import '../podcast_manager.dart'; + +class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { + const PodcastCardPlayButton({super.key, required this.podcastItem}); + + final Item podcastItem; + + @override + Widget build(BuildContext context) => FloatingActionButton.small( + heroTag: 'podcastcardfap', + onPressed: () => + showFutureLoadingDialog( + context: context, + title: context.l10n.loadingPodcastFeed, + future: () => di().findEpisodes(item: podcastItem), + ).then((result) { + if (result.isValue) { + di().setPlaylist(result.asValue!.value); + } + }), + child: const Icon(Icons.play_arrow), + ); +} diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 9fc8e13..7d26a51 100644 --- a/lib/podcasts/view/podcast_collection_view.dart +++ b/lib/podcasts/view/podcast_collection_view.dart @@ -1,11 +1,9 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; -import 'package:podcast_search/podcast_search.dart'; import '../../collection/collection_manager.dart'; import '../../common/view/ui_constants.dart'; -import '../download_manager.dart'; +import '../podcast_library_service.dart'; import '../podcast_manager.dart'; import 'podcast_card.dart'; @@ -14,14 +12,23 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - final feedsWithDownloads = watchPropertyValue( - (DownloadManager m) => m.feedsWithDownloads, - ); + final feedsWithDownloads = + watchStream( + (PodcastLibraryService m) => + m.propertiesChanged.map((_) => m.feedsWithDownloads), + initialValue: di().feedsWithDownloads, + allowStreamChange: true, + preserveState: false, + ).data ?? + {}; + final showOnlyDownloads = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); - return watchValue((PodcastManager m) => m.podcastsCommand.results).toWidget( + return watchValue( + (PodcastManager m) => m.getSubscribedPodcastsCommand.results, + ).toWidget( onData: (pees, _) { final podcasts = showOnlyDownloads ? pees.where((p) => feedsWithDownloads.contains(p.feedUrl)) @@ -32,20 +39,7 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { itemCount: podcasts.length, itemBuilder: (context, index) { final item = podcasts.elementAt(index); - return PodcastCard( - key: ValueKey(item), - podcastItem: Item( - feedUrl: item.feedUrl, - artistName: item.artist, - collectionName: item.name, - artworkUrl: item.imageUrl, - genre: - item.genreList - ?.mapIndexed((i, e) => Genre(i, e)) - .toList() ?? - [], - ), - ); + return PodcastCard(key: ValueKey(item), podcastItem: item); }, ); }, diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index a2e0bdf..d6eea6b 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:podcast_search/podcast_search.dart'; -import '../data/podcast_metadata.dart'; import '../podcast_manager.dart'; class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { @@ -17,23 +16,14 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final isSubscribed = watchValue( - (PodcastManager m) => m.podcastsCommand.select( + (PodcastManager m) => m.getSubscribedPodcastsCommand.select( (podcasts) => podcasts.any((p) => p.feedUrl == podcastItem.feedUrl), ), ); void onPressed() => isSubscribed ? di().removePodcast(feedUrl: podcastItem.feedUrl!) - : di().addPodcast( - PodcastMetadata( - feedUrl: podcastItem.feedUrl!, - name: podcastItem.collectionName!, - artist: podcastItem.artistName!, - imageUrl: podcastItem.bestArtworkUrl!, - genreList: - podcastItem.genre?.map((e) => e.name).toList() ?? [], - ), - ); + : di().addPodcast(podcastItem); final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); if (_floating) { diff --git a/lib/podcasts/view/podcast_page.dart b/lib/podcasts/view/podcast_page.dart index e72abaf..1169236 100644 --- a/lib/podcasts/view/podcast_page.dart +++ b/lib/podcasts/view/podcast_page.dart @@ -14,7 +14,7 @@ import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; import '../../player/view/player_view.dart'; import '../data/podcast_genre.dart'; -import '../podcast_service.dart'; +import '../podcast_manager.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page_episode_list.dart'; import 'recent_downloads_button.dart'; @@ -95,7 +95,7 @@ class _PodcastPageState extends State { wrapInFakeScroll: false, color: Colors.white, text: - di() + di() .getPodcastDescriptionFromCache( widget.podcastItem.feedUrl, ) ?? diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index c988c4d..5f72627 100644 --- a/lib/podcasts/view/podcast_page_episode_list.dart +++ b/lib/podcasts/view/podcast_page_episode_list.dart @@ -4,7 +4,6 @@ import 'package:podcast_search/podcast_search.dart'; import '../../collection/collection_manager.dart'; import '../../player/player_manager.dart'; -import '../download_manager.dart'; import '../podcast_manager.dart'; import 'episode_tile.dart'; @@ -15,22 +14,23 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - callOnce( - (context) => di().fetchEpisodeMediaCommand(podcastItem), - ); - final downloadsOnly = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); return watchValue( - (PodcastManager m) => m.fetchEpisodeMediaCommand.results, + (PodcastManager m) => m.runFetchEpisodesCommand(podcastItem).results, ).toWidget( onData: (episodesX, param) { final episodes = downloadsOnly ? episodesX .where( - (e) => di().getDownload(e.url) != null, + (e) => + di() + .getDownloadCommand(e) + .progress + .value == + 1.0, ) .toList() : episodesX; diff --git a/lib/podcasts/view/recent_downloads_button.dart b/lib/podcasts/view/recent_downloads_button.dart index 445d820..982e60f 100644 --- a/lib/podcasts/view/recent_downloads_button.dart +++ b/lib/podcasts/view/recent_downloads_button.dart @@ -4,7 +4,7 @@ import 'package:yaru/yaru.dart'; import '../../extensions/build_context_x.dart'; import '../../player/player_manager.dart'; -import '../download_manager.dart'; +import '../podcast_manager.dart'; import 'download_button.dart'; class RecentDownloadsButton extends StatefulWidget @@ -42,18 +42,13 @@ class _RecentDownloadsButtonState extends State @override Widget build(BuildContext context) { final theme = context.theme; - final episodeToProgress = watchPropertyValue( - (DownloadManager m) => m.episodeToProgress, - ); - final episodeToProgressLength = watchPropertyValue( - (DownloadManager m) => m.episodeToProgress.length, - ); + final activeDownloads = watchValue((PodcastManager m) => m.activeDownloads); + final hasActiveDownloads = activeDownloads.isNotEmpty; - final downloadsInProgress = watchPropertyValue( - (DownloadManager m) => m.getDownloadsInProgress(), - ); + final recentDownloads = watchValue((PodcastManager m) => m.recentDownloads); + final hasRecentDownloads = recentDownloads.isNotEmpty; - if (downloadsInProgress) { + if (hasActiveDownloads) { if (!_controller.isAnimating) { _controller.repeat(reverse: true); } @@ -65,23 +60,19 @@ class _RecentDownloadsButtonState extends State return AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: episodeToProgressLength > 0 ? 1.0 : 0.0, + opacity: hasActiveDownloads || hasRecentDownloads ? 1.0 : 0.0, child: IconButton( - icon: downloadsInProgress + icon: hasActiveDownloads ? FadeTransition( opacity: _animation, child: Icon( Icons.download_for_offline, - color: downloadsInProgress - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), ) : Icon( Icons.download_for_offline, - color: episodeToProgress.isNotEmpty - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, + color: theme.colorScheme.onSurface, ), onPressed: () => showDialog( context: context, @@ -98,32 +89,38 @@ class _RecentDownloadsButtonState extends State child: CustomScrollView( slivers: [ SliverList.builder( - itemCount: episodeToProgress.keys.length, - itemBuilder: (context, index) => ListTile( - onTap: () { - final download = di().getDownload( - episodeToProgress.keys.elementAt(index).url, - ); - di().setPlaylist([ - if (download != null) - episodeToProgress.keys - .elementAt(index) - .copyWithX(resource: download), - ]); - }, - title: Text( - episodeToProgress.keys.elementAt(index).title ?? - context.l10n.unknown, - ), - subtitle: Text( - episodeToProgress.keys.elementAt(index).artist ?? - context.l10n.unknown, - ), - trailing: DownloadButton( - episode: episodeToProgress.keys.elementAt(index), - addPodcast: () {}, - ), - ), + itemCount: activeDownloads.length, + itemBuilder: (context, index) { + final episode = activeDownloads.elementAt(index); + return ListTile( + onTap: () { + if (di() + .getDownloadCommand(episode) + .progress + .value == + 1.0) { + di().setPlaylist([episode]); + } + }, + title: Text(episode.title ?? context.l10n.unknown), + subtitle: Text(episode.artist ?? context.l10n.unknown), + trailing: DownloadButton(episode: episode), + ); + }, + ), + SliverList.builder( + itemBuilder: (context, index) { + final episode = recentDownloads.elementAt(index); + return ListTile( + onTap: () { + di().setPlaylist([episode]); + }, + title: Text(episode.title ?? context.l10n.unknown), + subtitle: Text(episode.artist ?? context.l10n.unknown), + trailing: DownloadButton(episode: episode), + ); + }, + itemCount: recentDownloads.length, ), ], ), diff --git a/lib/radio/view/radio_browser_station_star_button.dart b/lib/radio/view/radio_browser_station_star_button.dart index 576a5df..b2459ff 100644 --- a/lib/radio/view/radio_browser_station_star_button.dart +++ b/lib/radio/view/radio_browser_station_star_button.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:yaru/yaru.dart'; +import '../../player/data/station_media.dart'; import '../../player/data/unique_media.dart'; -import '../../player/player_manager.dart'; import '../radio_manager.dart'; class RadioBrowserStationStarButton extends StatelessWidget with WatchItMixin { @@ -28,26 +28,21 @@ class RadioBrowserStationStarButton extends StatelessWidget with WatchItMixin { } class RadioStationStarButton extends StatelessWidget with WatchItMixin { - const RadioStationStarButton({super.key}); + const RadioStationStarButton({super.key, required this.currentMedia}); + + final StationMedia currentMedia; @override Widget build(BuildContext context) { - final currentMedia = watchStream( - (PlayerManager p) => p.currentMediaStream, - initialValue: di().currentMedia, - preserveState: false, - ).data; final isFavorite = watchValue( (RadioManager s) => s.favoriteStationsCommand.select( - (favorites) => favorites.any((m) => m.id == currentMedia?.id), + (favs) => favs.contains(currentMedia), ), ); return IconButton( - onPressed: currentMedia == null - ? null - : () => isFavorite - ? di().removeFavoriteStation(currentMedia.id) - : di().addFavoriteStation(currentMedia.id), + onPressed: () => isFavorite + ? di().removeFavoriteStation(currentMedia.id) + : di().addFavoriteStation(currentMedia.id), icon: Icon(isFavorite ? YaruIcons.star_filled : YaruIcons.star), ); } diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index 880dc36..965e1f4 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -16,7 +16,7 @@ import 'common/platforms.dart'; import 'notifications/notifications_service.dart'; import 'online_art/online_art_service.dart'; import 'player/player_manager.dart'; -import 'podcasts/download_manager.dart'; +import 'podcasts/download_service.dart'; import 'podcasts/podcast_library_service.dart'; import 'podcasts/podcast_manager.dart'; import 'podcasts/podcast_service.dart'; @@ -50,6 +50,13 @@ void registerDependencies() { return wm; }) ..registerSingletonAsync(SharedPreferences.getInstance) + ..registerSingletonAsync(() async { + final service = SettingsService( + sharedPreferences: di(), + ); + await service.init(); + return service; + }, dependsOn: [SharedPreferences]) ..registerLazySingleton(() { MediaKit.ensureInitialized(); return VideoController( @@ -63,6 +70,14 @@ void registerDependencies() { dio.options.headers = {HttpHeaders.acceptEncodingHeader: '*'}; return dio; }, dispose: (s) => s.close()) + ..registerSingletonWithDependencies( + () => DownloadService( + libraryService: di(), + settingsService: di(), + dio: di(), + ), + dependsOn: [SettingsService], + ) ..registerSingletonAsync( () async => AudioService.init( config: AudioServiceConfig( @@ -75,29 +90,21 @@ void registerDependencies() { : null, androidNotificationChannelDescription: 'MusicPod Media Controls', ), - builder: () => PlayerManager(controller: di()), + builder: () => PlayerManager( + controller: di(), + podcastLibraryService: di(), + ), ), // dependsOn: [VideoController], dispose: (s) async => s.dispose(), ) - ..registerSingletonAsync(() async { - final service = SettingsService( - sharedPreferences: di(), - ); - await service.init(); - return service; - }, dependsOn: [SharedPreferences]) ..registerLazySingleton(() => NotificationsService()) ..registerSingletonWithDependencies( () => PodcastLibraryService(sharedPreferences: di()), dependsOn: [SharedPreferences], ) ..registerSingletonWithDependencies( - () => PodcastService( - libraryService: di(), - notificationsService: di(), - settingsService: di(), - ), + () => PodcastService(settingsService: di()), dependsOn: [PodcastLibraryService, SettingsService], ) ..registerSingleton(SearchManager()) @@ -107,6 +114,8 @@ void registerDependencies() { searchManager: di(), collectionManager: di(), podcastLibraryService: di(), + downloadService: di(), + notificationsService: di(), ), dependsOn: [PodcastService], ) @@ -120,12 +129,6 @@ void registerDependencies() { ), dependsOn: [SettingsService], ) - ..registerLazySingleton( - () => DownloadManager( - libraryService: di(), - dio: di(), - ), - ) ..registerSingletonWithDependencies( () => RadioLibraryService(sharedPreferences: di()), dependsOn: [SharedPreferences], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a0194bd..20fc55f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,7 +13,6 @@ import local_notifier import media_kit_libs_macos_video import media_kit_video import package_info_plus -import path_provider_foundation import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin @@ -32,7 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index dce2123..59dae1a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -15,10 +15,9 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - FlutterMacOS - - package_info_plus (0.0.1): + - objective_c (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter + - package_info_plus (0.0.1): - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS @@ -48,8 +47,8 @@ DEPENDENCIES: - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) @@ -76,10 +75,10 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos media_kit_video: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + objective_c: + :path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos shared_preferences_foundation: @@ -106,8 +105,8 @@ SPEC CHECKSUMS: local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + objective_c: ec13431e45ba099cb734eb2829a5c1cd37986cba package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/pubspec.lock b/pubspec.lock index 90954a8..90a5c8a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,10 +198,10 @@ packages: dependency: transitive description: name: command_it - sha256: "6198e223b5a4d0e23281fd9514a67d264fb9239eeb3c1372176f49367aa2b198" + sha256: "4f1cd9131565e0d03569213b40180d7ea12830cc31fcb563a8081cd9fdddec3b" url: "https://pub.dev" source: hosted - version: "9.0.2" + version: "9.4.2" convert: dependency: transitive description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+1" crypto: dependency: transitive description: @@ -302,58 +302,58 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f + sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200" url: "https://pub.dev" source: hosted - version: "10.3.3" + version: "10.3.7" file_selector: dependency: "direct main" description: name: file_selector - sha256: "5f1d15a7f17115038f433d1b0ea57513cc9e29a9d5338d166cb0bef3fa90a7a0" + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" file_selector_android: dependency: transitive description: name: file_selector_android - sha256: "2db9a2d05f66b49a3b45c4a7c2f040dd5fcd457ca30f39df7cdcf80b8cd7f2d4" + sha256: "51e8fd0446de75e4b62c065b76db2210c704562d072339d333bd89c57a7f8a7c" url: "https://pub.dev" source: hosted - version: "0.5.2+1" + version: "0.5.2+4" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: fc3c3fc567cd9bcae784dfeb98d37c46a8ded9e8757d37ea67e975c399bc14e0 + sha256: "628ec99afd8bb40620b4c8707d5fd5fc9e89d83e9b0b327d471fe5f7bc5fc33f" url: "https://pub.dev" source: hosted - version: "0.5.3+3" + version: "0.5.3+4" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" url: "https://pub.dev" source: hosted - version: "0.9.3+2" + version: "0.9.4" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "88707a3bec4b988aaed3b4df5d7441ee4e987f20b286cddca5d6a8270cab23f2" + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" url: "https://pub.dev" source: hosted - version: "0.9.4+5" + version: "0.9.5" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" file_selector_web: dependency: transitive description: @@ -366,10 +366,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" url: "https://pub.dev" source: hosted - version: "0.9.3+4" + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -395,10 +395,10 @@ packages: dependency: "direct main" description: name: flutter_it - sha256: "81fe23d007abd87f26a3dec0b52ad2772ecd3b6d3c8f7e6e1daa24c04c9b1a2d" + sha256: "12ad0696a2aaabbe9e86eee9b296f8906e043d31e0283b09c3a12c309d6affac" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -424,10 +424,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.33" flutter_tabler_icons: dependency: "direct main" description: @@ -466,10 +466,10 @@ packages: dependency: transitive description: name: get_it - sha256: "84792561b731b6463d053e9761a5236da967c369da10b134b8585a5e18429956" + sha256: "2c120112e34bbd5f4ce2ca1ad3076a5b2068eb1e48aae8dd7b717b26cdd38543" url: "https://pub.dev" source: hosted - version: "9.0.5" + version: "9.1.1" gsettings: dependency: transitive description: @@ -506,10 +506,10 @@ packages: dependency: transitive description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" http_parser: dependency: transitive description: @@ -586,10 +586,10 @@ packages: dependency: transitive description: name: listen_it - sha256: "516bfd486ca6fcb5d73c5640ed59e3c281a2871ee11863ffd4713e581b11c8d0" + sha256: "461f46cc86e9c3be0d679f1ee4d94c8893c0a9ce42d0aad4474fe7e154e62c4a" url: "https://pub.dev" source: hosted - version: "5.3.3" + version: "5.3.4" local_notifier: dependency: "direct main" description: @@ -721,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64" + url: "https://pub.dev" + source: hosted + version: "9.1.0" octo_image: dependency: transitive description: @@ -781,18 +789,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.20" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 + sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.5.0" path_provider_linux: dependency: transitive description: @@ -930,6 +938,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" quiver: dependency: transitive description: @@ -1038,18 +1054,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.17" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1235,34 +1251,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.24" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad url: "https://pub.dev" source: hosted - version: "6.3.5" + version: "6.3.6" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1283,10 +1299,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: @@ -1339,10 +1355,10 @@ packages: dependency: transitive description: name: watch_it - sha256: "98e091d39aab70c57c6d38883ad83b9a22a0e12683e0222391d20d61cda4d250" + sha256: "561910e534af41dda7c9bce2eca2b3d65c48aa88b6570964b3dcb29fb1cc6dc3" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.1" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 26a788c..8e90884 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: flutter: sdk: flutter flutter_cache_manager: ^3.4.1 - flutter_it: ^2.0.0 + flutter_it: ^2.0.1 flutter_localizations: sdk: flutter flutter_tabler_icons: ^1.43.0