// Copyright 2020 Ben Hills and the project contributors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:pinepods_mobile/bloc/bloc.dart'; import 'package:pinepods_mobile/entities/downloadable.dart'; import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/entities/feed.dart'; import 'package:pinepods_mobile/entities/podcast.dart'; import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; import 'package:pinepods_mobile/services/download/download_service.dart'; import 'package:pinepods_mobile/services/download/mobile_download_service.dart'; import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; import 'package:pinepods_mobile/services/settings/settings_service.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; import 'package:pinepods_mobile/entities/pinepods_search.dart'; import 'package:pinepods_mobile/state/bloc_state.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; enum PodcastEvent { subscribe, unsubscribe, markAllPlayed, clearAllPlayed, reloadSubscriptions, refresh, // Filter episodeFilterNone, episodeFilterStarted, episodeFilterNotFinished, episodeFilterFinished, // Sort episodeSortDefault, episodeSortLatest, episodeSortEarliest, episodeSortAlphabeticalAscending, episodeSortAlphabeticalDescending, } /// This BLoC provides access to the details of a given Podcast. /// /// It takes a feed URL and creates a [Podcast] instance. There are several listeners that /// handle actions on a podcast such as requesting an episode download, following/unfollowing /// a podcast and marking/un-marking all episodes as played. class PodcastBloc extends Bloc { final log = Logger('PodcastBloc'); final PodcastService podcastService; final AudioPlayerService audioPlayerService; final DownloadService downloadService; final SettingsService settingsService; final BehaviorSubject _podcastFeed = BehaviorSubject(sync: true); /// Add to sink to start an Episode download final PublishSubject _downloadEpisode = PublishSubject(); /// Listen to this subject's stream to obtain list of current subscriptions. late PublishSubject> _subscriptions; /// Stream containing details of the current podcast. final BehaviorSubject> _podcastStream = BehaviorSubject>(sync: true); /// A separate stream that allows us to listen to changes in the podcast's episodes. final BehaviorSubject?> _episodesStream = BehaviorSubject?>(); /// Receives subscription and mark/clear as played events. final PublishSubject _podcastEvent = PublishSubject(); final BehaviorSubject _podcastSearchEvent = BehaviorSubject(); final BehaviorSubject> _backgroundLoadStream = BehaviorSubject>(); Podcast? _podcast; List _episodes = []; String _searchTerm = ''; late Feed lastFeed; bool first = true; PodcastBloc({ required this.podcastService, required this.audioPlayerService, required this.downloadService, required this.settingsService, }) { _init(); } void _init() { /// When someone starts listening for subscriptions, load them. _subscriptions = PublishSubject>(onListen: _loadSubscriptions); /// When we receive a load podcast request, send back a BlocState. _listenPodcastLoad(); /// Listen to an Episode download request _listenDownloadRequest(); /// Listen to active downloads _listenDownloads(); /// Listen to episode change events sent by the [Repository] _listenEpisodeRepositoryEvents(); /// Listen to Podcast subscription, mark/cleared played events _listenPodcastStateEvents(); /// Listen for episode search requests _listenPodcastSearchEvents(); } void _loadSubscriptions() async { _subscriptions.add(await podcastService.subscriptions()); } /// Sets up a listener to handle Podcast load requests. We first push a [BlocLoadingState] to /// indicate that the Podcast is being loaded, before calling the [PodcastService] to handle /// the loading. Once loaded, we extract the episodes from the Podcast and push them out via /// the episode stream before pushing a [BlocPopulatedState] containing the Podcast. void _listenPodcastLoad() async { _podcastFeed.listen((feed) async { var silent = false; lastFeed = feed; _episodes = []; _refresh(); _podcastStream.sink.add(BlocLoadingState(feed.podcast)); try { await _loadEpisodes(feed, feed.refresh); /// Do we also need to perform a background refresh? if (feed.podcast.id != null && feed.backgroundFresh && _shouldAutoRefresh()) { silent = feed.silently; log.fine('Performing background refresh of ${feed.podcast.url}'); _backgroundLoadStream.sink.add(BlocLoadingState()); await _loadNewEpisodes(feed); } _backgroundLoadStream.sink.add(BlocSuccessfulState()); } catch (e) { _backgroundLoadStream.sink.add(BlocDefaultState()); // For now we'll assume a network error as this is the most likely. if ((_podcast == null || lastFeed.podcast.url == _podcast!.url) && !silent) { _podcastStream.sink.add(BlocErrorState()); log.fine('Error loading podcast', e); log.fine(e); } } }); } /// Determines if the current feed should be updated in the background. /// /// If the autoUpdatePeriod is -1 this means never; 0 means always and any other /// value is the time in minutes. bool _shouldAutoRefresh() { /// If we are currently following this podcast it will have an id. At /// this point we can compare the last updated time to the update /// after setting time. if (settingsService.autoUpdateEpisodePeriod == -1) { return false; } else if (_podcast == null || settingsService.autoUpdateEpisodePeriod == 0) { return true; } else if (_podcast != null && _podcast!.id != null) { var currentTime = DateTime.now().subtract(Duration(minutes: settingsService.autoUpdateEpisodePeriod)); var lastUpdated = _podcast!.lastUpdated; return currentTime.isAfter(lastUpdated); } return false; } Future _loadEpisodes(Feed feed, bool force) async { _podcast = await podcastService.loadPodcast( podcast: feed.podcast, refresh: force, ); /// Only populate episodes if the ID we started the load with is the /// same as the one we have ended up with. if (_podcast != null && _podcast?.url != null) { if (lastFeed.podcast.url == _podcast!.url) { _episodes = _podcast!.episodes; _refresh(); _podcastStream.sink.add(BlocPopulatedState(results: _podcast)); } } } void _refresh() { applySearchFilter(); } Future _loadNewEpisodes(Feed feed) async { _podcast = await podcastService.loadPodcast( podcast: feed.podcast, highlightNewEpisodes: true, refresh: true, ); /// Only populate episodes if the ID we started the load with is the /// same as the one we have ended up with. if (_podcast != null && lastFeed.podcast.url == _podcast!.url) { _episodes = _podcast!.episodes; if (_podcast!.newEpisodes) { log.fine('We have new episodes to display'); _backgroundLoadStream.sink.add(BlocPopulatedState()); _podcastStream.sink.add(BlocPopulatedState(results: _podcast)); } else if (_podcast!.updatedEpisodes) { log.fine('We have updated episodes to re-display'); _refresh(); } } log.fine('Background loading successful state'); _backgroundLoadStream.sink.add(BlocSuccessfulState()); } Future _loadFilteredEpisodes() async { if (_podcast != null) { _podcast = await podcastService.loadPodcast( podcast: _podcast!, highlightNewEpisodes: false, refresh: false, ); _episodes = _podcast!.episodes; _podcastStream.add(BlocPopulatedState(results: _podcast)); _refresh(); } } /// Sets up a listener to handle requests to download an episode. void _listenDownloadRequest() { _downloadEpisode.listen((Episode? e) async { log.fine('Received download request for ${e!.title}'); // To prevent a pause between the user tapping the download icon and // the UI showing some sort of progress, set it to queued now. 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(); } } }); } /// Sets up a listener to listen for status updates from any currently downloading episode. /// /// If the ID of a current download matches that of an episode currently in /// use, we update the status of the episode and push it back into the episode stream. void _listenDownloads() { // Listen to download progress MobileDownloadService.downloadProgress.listen((downloadProgress) { downloadService.findEpisodeByTaskId(downloadProgress.id).then((downloadable) { if (downloadable != null) { // If the download matches a current episode push the update back into the stream. var episode = _episodes.firstWhereOrNull((e) => e.downloadTaskId == downloadProgress.id); if (episode != null) { // Update the stream. _refresh(); } } else { log.severe('Downloadable not found with id ${downloadProgress.id}'); } }); }); } /// Listen to episode change events sent by the [Repository] void _listenEpisodeRepositoryEvents() { podcastService.episodeListener!.listen((state) { // Do we have this episode? var eidx = _episodes.indexWhere((e) => e.guid == state.episode.guid && e.pguid == state.episode.pguid); if (eidx != -1) { _episodes[eidx] = state.episode; _refresh(); } }); } // TODO: This needs refactoring to simplify the long switch statement. void _listenPodcastStateEvents() async { _podcastEvent.listen((event) async { switch (event) { case PodcastEvent.subscribe: if (_podcast != null) { // Emit loading state for subscription _podcastStream.add(BlocLoadingState(_podcast)); // First, subscribe locally _podcast = await podcastService.subscribe(_podcast!); // Check if we're in a PinePods environment and also add to server if (_podcast != null) { try { final settings = settingsService.settings; if (settings != null && settings.pinepodsServer != null && settings.pinepodsApiKey != null && settings.pinepodsUserId != null) { // Also add to PinePods server final pinepodsService = PinepodsService(); pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); final unifiedPodcast = UnifiedPinepodsPodcast( id: 0, indexId: 0, title: _podcast!.title, url: _podcast!.url ?? '', originalUrl: _podcast!.url ?? '', link: _podcast!.link ?? '', description: _podcast!.description ?? '', author: _podcast!.copyright ?? '', ownerName: _podcast!.copyright ?? '', image: _podcast!.imageUrl ?? '', artwork: _podcast!.imageUrl ?? '', lastUpdateTime: 0, explicit: false, episodeCount: 0, ); await pinepodsService.addPodcast(unifiedPodcast, settings.pinepodsUserId!); log.fine('Added podcast to PinePods server'); } } catch (e) { log.warning('Failed to add podcast to PinePods server: $e'); // Continue with local subscription even if server add fails } _episodes = _podcast!.episodes; _podcastStream.add(BlocPopulatedState(results: _podcast)); _loadSubscriptions(); _refresh(); // Use _refresh to apply filters and update episode stream properly } } break; case PodcastEvent.unsubscribe: if (_podcast != null) { await podcastService.unsubscribe(_podcast!); _podcast!.id = null; _episodes = _podcast!.episodes; _podcastStream.add(BlocPopulatedState(results: _podcast)); _loadSubscriptions(); _refresh(); // Use _refresh to apply filters and update episode stream properly } break; case PodcastEvent.markAllPlayed: if (_podcast != null && _podcast?.episodes != null) { final changedEpisodes = []; for (var e in _podcast!.episodes) { if (!e.played) { e.played = true; e.position = 0; changedEpisodes.add(e); } } await podcastService.saveEpisodes(changedEpisodes); _episodesStream.add(_podcast!.episodes); } break; case PodcastEvent.clearAllPlayed: if (_podcast != null && _podcast?.episodes != null) { final changedEpisodes = []; for (var e in _podcast!.episodes) { if (e.played) { e.played = false; e.position = 0; changedEpisodes.add(e); } } await podcastService.saveEpisodes(changedEpisodes); _episodesStream.add(_podcast!.episodes); } break; case PodcastEvent.reloadSubscriptions: _loadSubscriptions(); break; case PodcastEvent.refresh: _refresh(); break; case PodcastEvent.episodeFilterNone: if (_podcast != null) { _podcast!.filter = PodcastEpisodeFilter.none; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); } break; case PodcastEvent.episodeFilterStarted: _podcast!.filter = PodcastEpisodeFilter.started; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeFilterFinished: _podcast!.filter = PodcastEpisodeFilter.played; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeFilterNotFinished: _podcast!.filter = PodcastEpisodeFilter.notPlayed; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeSortDefault: _podcast!.sort = PodcastEpisodeSort.none; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeSortLatest: _podcast!.sort = PodcastEpisodeSort.latestFirst; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeSortEarliest: _podcast!.sort = PodcastEpisodeSort.earliestFirst; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeSortAlphabeticalAscending: _podcast!.sort = PodcastEpisodeSort.alphabeticalAscending; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; case PodcastEvent.episodeSortAlphabeticalDescending: _podcast!.sort = PodcastEpisodeSort.alphabeticalDescending; _podcast = await podcastService.save(_podcast!, withEpisodes: false); await _loadFilteredEpisodes(); break; } }); } void _listenPodcastSearchEvents() { _podcastSearchEvent.debounceTime(const Duration(milliseconds: 200)).listen((search) { _searchTerm = search; applySearchFilter(); }); } void applySearchFilter() { if (_searchTerm.isEmpty) { _episodesStream.add(_episodes); } else { var searchFilteredEpisodes = _episodes.where((e) => e.title!.toLowerCase().contains(_searchTerm.trim().toLowerCase())).toList(); _episodesStream.add(searchFilteredEpisodes); } } @override void detach() { downloadService.dispose(); } @override void dispose() { _podcastFeed.close(); _downloadEpisode.close(); _subscriptions.close(); _podcastStream.close(); _episodesStream.close(); _podcastEvent.close(); MobileDownloadService.downloadProgress.close(); downloadService.dispose(); super.dispose(); } /// Sink to load a podcast. void Function(Feed) get load => _podcastFeed.add; /// Sink to trigger an episode download. void Function(Episode?) get downloadEpisode => _downloadEpisode.add; void Function(PodcastEvent) get podcastEvent => _podcastEvent.add; void Function(String) get podcastSearchEvent => _podcastSearchEvent.add; /// Stream containing the current state of the podcast load. Stream> get details => _podcastStream.stream; Stream> get backgroundLoading => _backgroundLoadStream.stream; /// Stream containing the current list of Podcast episodes. Stream?> get episodes => _episodesStream; /// Obtain a list of podcast currently subscribed to. Stream> get subscriptions => _subscriptions.stream; }