added cargo files
This commit is contained in:
517
PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart
Normal file
517
PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
// 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<Feed> _podcastFeed = BehaviorSubject<Feed>(sync: true);
|
||||
|
||||
/// Add to sink to start an Episode download
|
||||
final PublishSubject<Episode?> _downloadEpisode = PublishSubject<Episode?>();
|
||||
|
||||
/// Listen to this subject's stream to obtain list of current subscriptions.
|
||||
late PublishSubject<List<Podcast>> _subscriptions;
|
||||
|
||||
/// Stream containing details of the current podcast.
|
||||
final BehaviorSubject<BlocState<Podcast>> _podcastStream = BehaviorSubject<BlocState<Podcast>>(sync: true);
|
||||
|
||||
/// A separate stream that allows us to listen to changes in the podcast's episodes.
|
||||
final BehaviorSubject<List<Episode?>?> _episodesStream = BehaviorSubject<List<Episode?>?>();
|
||||
|
||||
/// Receives subscription and mark/clear as played events.
|
||||
final PublishSubject<PodcastEvent> _podcastEvent = PublishSubject<PodcastEvent>();
|
||||
|
||||
final BehaviorSubject<String> _podcastSearchEvent = BehaviorSubject<String>();
|
||||
|
||||
final BehaviorSubject<BlocState<void>> _backgroundLoadStream = BehaviorSubject<BlocState<void>>();
|
||||
|
||||
Podcast? _podcast;
|
||||
List<Episode> _episodes = <Episode>[];
|
||||
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<List<Podcast>>(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<Podcast>(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<void>());
|
||||
|
||||
await _loadNewEpisodes(feed);
|
||||
}
|
||||
|
||||
_backgroundLoadStream.sink.add(BlocSuccessfulState<void>());
|
||||
} catch (e) {
|
||||
_backgroundLoadStream.sink.add(BlocDefaultState<void>());
|
||||
|
||||
// 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<Podcast>());
|
||||
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<void> _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<Podcast>(results: _podcast));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
applySearchFilter();
|
||||
}
|
||||
|
||||
Future<void> _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<void>());
|
||||
_podcastStream.sink.add(BlocPopulatedState<Podcast>(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<void>());
|
||||
}
|
||||
|
||||
Future<void> _loadFilteredEpisodes() async {
|
||||
if (_podcast != null) {
|
||||
_podcast = await podcastService.loadPodcast(
|
||||
podcast: _podcast!,
|
||||
highlightNewEpisodes: false,
|
||||
refresh: false,
|
||||
);
|
||||
|
||||
_episodes = _podcast!.episodes;
|
||||
_podcastStream.add(BlocPopulatedState<Podcast>(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>(_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<Podcast>(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<Podcast>(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 = <Episode>[];
|
||||
|
||||
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 = <Episode>[];
|
||||
|
||||
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<BlocState<Podcast>> get details => _podcastStream.stream;
|
||||
|
||||
Stream<BlocState<void>> get backgroundLoading => _backgroundLoadStream.stream;
|
||||
|
||||
/// Stream containing the current list of Podcast episodes.
|
||||
Stream<List<Episode?>?> get episodes => _episodesStream;
|
||||
|
||||
/// Obtain a list of podcast currently subscribed to.
|
||||
Stream<List<Podcast>> get subscriptions => _subscriptions.stream;
|
||||
}
|
||||
Reference in New Issue
Block a user