added cargo files
This commit is contained in:
43
PinePods-0.8.2/mobile/lib/bloc/bloc.dart
Normal file
43
PinePods-0.8.2/mobile/lib/bloc/bloc.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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/podcast/audio_bloc.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Base class for all BLoCs to give each a hook into the mobile
|
||||
/// lifecycle state of paused, resume or detached.
|
||||
abstract class Bloc {
|
||||
/// Handle lifecycle events
|
||||
final PublishSubject<LifecycleState> _lifecycleSubject = PublishSubject<LifecycleState>(sync: true);
|
||||
|
||||
Bloc() {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_lifecycleSubject.listen((state) async {
|
||||
if (state == LifecycleState.resume) {
|
||||
resume();
|
||||
} else if (state == LifecycleState.pause) {
|
||||
pause();
|
||||
} else if (state == LifecycleState.detach) {
|
||||
detach();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_lifecycleSubject.hasListener) {
|
||||
_lifecycleSubject.close();
|
||||
}
|
||||
}
|
||||
|
||||
void resume() {}
|
||||
|
||||
void pause() {}
|
||||
|
||||
void detach() {}
|
||||
|
||||
void Function(LifecycleState) get transitionLifecycleState => _lifecycleSubject.sink.add;
|
||||
}
|
||||
238
PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart
Normal file
238
PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
// 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 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/core/extensions.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/entities/sleep.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/state/transcript_state_event.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
enum TransitionState {
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
fastforward,
|
||||
rewind,
|
||||
}
|
||||
|
||||
enum LifecycleState {
|
||||
pause,
|
||||
resume,
|
||||
detach,
|
||||
}
|
||||
|
||||
/// A BLoC to handle interactions between the audio service and the client.
|
||||
class AudioBloc extends Bloc {
|
||||
final log = Logger('AudioBloc');
|
||||
|
||||
/// Listen for new episode play requests.
|
||||
final BehaviorSubject<Episode?> _play = BehaviorSubject<Episode?>();
|
||||
|
||||
/// Move from one playing state to another such as from paused to play
|
||||
final PublishSubject<TransitionState> _transitionPlayingState = PublishSubject<TransitionState>();
|
||||
|
||||
/// Sink to update our position
|
||||
final PublishSubject<double> _transitionPosition = PublishSubject<double>();
|
||||
|
||||
/// Handles persisting data to storage.
|
||||
final AudioPlayerService audioPlayerService;
|
||||
|
||||
/// Listens for playback speed change requests.
|
||||
final PublishSubject<double> _playbackSpeedSubject = PublishSubject<double>();
|
||||
|
||||
/// Listen for toggling of trim silence requests.
|
||||
final PublishSubject<bool> _trimSilence = PublishSubject<bool>();
|
||||
|
||||
/// Listen for toggling of volume boost silence requests.
|
||||
final PublishSubject<bool> _volumeBoost = PublishSubject<bool>();
|
||||
|
||||
/// Listen for transcript filtering events.
|
||||
final PublishSubject<TranscriptEvent> _transcriptEvent = PublishSubject<TranscriptEvent>();
|
||||
|
||||
final BehaviorSubject<Sleep> _sleepEvent = BehaviorSubject<Sleep>();
|
||||
|
||||
AudioBloc({
|
||||
required this.audioPlayerService,
|
||||
}) {
|
||||
/// Listen for transition events from the client.
|
||||
_handlePlayingStateTransitions();
|
||||
|
||||
/// Listen for events requesting the start of a new episode.
|
||||
_handleEpisodeRequests();
|
||||
|
||||
/// Listen for requests to move the play position within the episode.
|
||||
_handlePositionTransitions();
|
||||
|
||||
/// Listen for playback speed changes
|
||||
_handlePlaybackSpeedTransitions();
|
||||
|
||||
/// Listen to trim silence requests
|
||||
_handleTrimSilenceTransitions();
|
||||
|
||||
/// Listen to volume boost silence requests
|
||||
_handleVolumeBoostTransitions();
|
||||
|
||||
/// Listen to transcript filtering events
|
||||
_handleTranscriptEvents();
|
||||
|
||||
/// Listen to sleep timer events;
|
||||
_handleSleepTimer();
|
||||
}
|
||||
|
||||
/// Listens to events from the UI (or any client) to transition from one
|
||||
/// audio state to another. For example, to pause the current playback
|
||||
/// a [TransitionState.pause] event should be sent. To ensure the underlying
|
||||
/// audio service processes one state request at a time we push events
|
||||
/// on to a queue and execute them sequentially. Each state maps to a call
|
||||
/// to the Audio Service plugin.
|
||||
void _handlePlayingStateTransitions() {
|
||||
_transitionPlayingState.asyncMap((event) => Future.value(event)).listen((state) async {
|
||||
switch (state) {
|
||||
case TransitionState.play:
|
||||
await audioPlayerService.play();
|
||||
break;
|
||||
case TransitionState.pause:
|
||||
await audioPlayerService.pause();
|
||||
break;
|
||||
case TransitionState.fastforward:
|
||||
await audioPlayerService.fastForward();
|
||||
break;
|
||||
case TransitionState.rewind:
|
||||
await audioPlayerService.rewind();
|
||||
break;
|
||||
case TransitionState.stop:
|
||||
await audioPlayerService.stop();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Setup a listener for episode requests and then connect to the
|
||||
/// underlying audio service.
|
||||
void _handleEpisodeRequests() async {
|
||||
_play.listen((episode) {
|
||||
audioPlayerService.playEpisode(episode: episode!, resume: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to change the position of the current episode.
|
||||
void _handlePositionTransitions() async {
|
||||
_transitionPosition.listen((pos) async {
|
||||
await audioPlayerService.seek(position: pos.ceil());
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to adjust the playback speed.
|
||||
void _handlePlaybackSpeedTransitions() {
|
||||
_playbackSpeedSubject.listen((double speed) async {
|
||||
await audioPlayerService.setPlaybackSpeed(speed.toTenth);
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to toggle trim silence mode. This is currently disabled until
|
||||
/// [issue](https://github.com/ryanheise/just_audio/issues/558) is resolved.
|
||||
void _handleTrimSilenceTransitions() {
|
||||
_trimSilence.listen((bool trim) async {
|
||||
await audioPlayerService.trimSilence(trim);
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to toggle the volume boost feature. Android only.
|
||||
void _handleVolumeBoostTransitions() {
|
||||
_volumeBoost.listen((bool boost) async {
|
||||
await audioPlayerService.volumeBoost(boost);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTranscriptEvents() {
|
||||
_transcriptEvent.listen((TranscriptEvent event) {
|
||||
if (event is TranscriptFilterEvent) {
|
||||
audioPlayerService.searchTranscript(event.search);
|
||||
} else if (event is TranscriptClearEvent) {
|
||||
audioPlayerService.clearTranscript();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSleepTimer() {
|
||||
_sleepEvent.listen((Sleep sleep) {
|
||||
audioPlayerService.sleep(sleep);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void pause() async {
|
||||
log.fine('Audio lifecycle pause');
|
||||
await audioPlayerService.suspend();
|
||||
}
|
||||
|
||||
@override
|
||||
void resume() async {
|
||||
log.fine('Audio lifecycle resume');
|
||||
var ep = await audioPlayerService.resume();
|
||||
|
||||
if (ep != null) {
|
||||
log.fine('Resuming with episode ${ep.title} - ${ep.position} - ${ep.played}');
|
||||
} else {
|
||||
log.fine('Resuming without an episode');
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the specified track now
|
||||
void Function(Episode?) get play => _play.add;
|
||||
|
||||
/// Transition the state from connecting, to play, pause, stop etc.
|
||||
void Function(TransitionState) get transitionState => _transitionPlayingState.add;
|
||||
|
||||
/// Move the play position.
|
||||
void Function(double) get transitionPosition => _transitionPosition.sink.add;
|
||||
|
||||
/// Get the current playing state
|
||||
Stream<AudioState>? get playingState => audioPlayerService.playingState;
|
||||
|
||||
/// Listen for any playback errors
|
||||
Stream<int>? get playbackError => audioPlayerService.playbackError;
|
||||
|
||||
/// Get the current playing episode
|
||||
ValueStream<Episode?>? get nowPlaying => audioPlayerService.episodeEvent;
|
||||
|
||||
/// Get the current transcript (if there is one).
|
||||
Stream<TranscriptState>? get nowPlayingTranscript => audioPlayerService.transcriptEvent;
|
||||
|
||||
/// Get position and percentage played of playing episode
|
||||
ValueStream<PositionState>? get playPosition => audioPlayerService.playPosition;
|
||||
|
||||
Stream<Sleep>? get sleepStream => audioPlayerService.sleepStream;
|
||||
|
||||
/// Change playback speed
|
||||
void Function(double) get playbackSpeed => _playbackSpeedSubject.sink.add;
|
||||
|
||||
/// Toggle trim silence
|
||||
void Function(bool) get trimSilence => _trimSilence.sink.add;
|
||||
|
||||
/// Toggle volume boost silence
|
||||
void Function(bool) get volumeBoost => _volumeBoost.sink.add;
|
||||
|
||||
/// Handle filtering & searching of the current transcript.
|
||||
void Function(TranscriptEvent) get filterTranscript => _transcriptEvent.sink.add;
|
||||
|
||||
void Function(Sleep) get sleep => _sleepEvent.sink.add;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_play.close();
|
||||
_transitionPlayingState.close();
|
||||
_transitionPosition.close();
|
||||
_playbackSpeedSubject.close();
|
||||
_trimSilence.close();
|
||||
_volumeBoost.close();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
125
PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart
Normal file
125
PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/state/bloc_state.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// The BLoC provides access to [Episode] details outside the direct scope
|
||||
/// of a [Podcast].
|
||||
class EpisodeBloc extends Bloc {
|
||||
final log = Logger('EpisodeBloc');
|
||||
final PodcastService podcastService;
|
||||
final AudioPlayerService audioPlayerService;
|
||||
|
||||
/// Add to sink to fetch list of current downloaded episodes.
|
||||
final BehaviorSubject<bool> _downloadsInput = BehaviorSubject<bool>();
|
||||
|
||||
/// Add to sink to fetch list of current episodes.
|
||||
final BehaviorSubject<bool> _episodesInput = BehaviorSubject<bool>();
|
||||
|
||||
/// Add to sink to delete the passed [Episode] from storage.
|
||||
final PublishSubject<Episode?> _deleteDownload = PublishSubject<Episode>();
|
||||
|
||||
/// Add to sink to toggle played status of the [Episode].
|
||||
final PublishSubject<Episode?> _togglePlayed = PublishSubject<Episode>();
|
||||
|
||||
/// Stream of currently downloaded episodes
|
||||
Stream<BlocState<List<Episode>>>? _downloadsOutput;
|
||||
|
||||
/// Stream of current episodes
|
||||
Stream<BlocState<List<Episode>>>? _episodesOutput;
|
||||
|
||||
/// Cache of our currently downloaded episodes.
|
||||
List<Episode>? _episodes;
|
||||
|
||||
EpisodeBloc({
|
||||
required this.podcastService,
|
||||
required this.audioPlayerService,
|
||||
}) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_downloadsOutput = _downloadsInput.switchMap<BlocState<List<Episode>>>((bool silent) => _loadDownloads(silent));
|
||||
_episodesOutput = _episodesInput.switchMap<BlocState<List<Episode>>>((bool silent) => _loadEpisodes(silent));
|
||||
|
||||
_handleDeleteDownloads();
|
||||
_handleMarkAsPlayed();
|
||||
_listenEpisodeEvents();
|
||||
}
|
||||
|
||||
void _handleDeleteDownloads() async {
|
||||
_deleteDownload.stream.listen((episode) async {
|
||||
var nowPlaying = audioPlayerService.nowPlaying?.guid == episode?.guid;
|
||||
|
||||
/// If we are attempting to delete the episode we are currently playing, we need to stop the audio.
|
||||
if (nowPlaying) {
|
||||
await audioPlayerService.stop();
|
||||
}
|
||||
|
||||
await podcastService.deleteDownload(episode!);
|
||||
|
||||
fetchDownloads(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleMarkAsPlayed() async {
|
||||
_togglePlayed.stream.listen((episode) async {
|
||||
await podcastService.toggleEpisodePlayed(episode!);
|
||||
|
||||
fetchDownloads(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _listenEpisodeEvents() {
|
||||
// Listen for episode updates. If the episode is downloaded, we need to update.
|
||||
podcastService.episodeListener!.where((event) => event.episode.downloaded || event.episode.played).listen((event) => fetchDownloads(true));
|
||||
}
|
||||
|
||||
Stream<BlocState<List<Episode>>> _loadDownloads(bool silent) async* {
|
||||
if (!silent) {
|
||||
yield BlocLoadingState();
|
||||
}
|
||||
|
||||
_episodes = await podcastService.loadDownloads();
|
||||
|
||||
yield BlocPopulatedState<List<Episode>>(results: _episodes);
|
||||
}
|
||||
|
||||
Stream<BlocState<List<Episode>>> _loadEpisodes(bool silent) async* {
|
||||
if (!silent) {
|
||||
yield BlocLoadingState();
|
||||
}
|
||||
|
||||
_episodes = await podcastService.loadEpisodes();
|
||||
|
||||
yield BlocPopulatedState<List<Episode>>(results: _episodes);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_downloadsInput.close();
|
||||
_deleteDownload.close();
|
||||
_togglePlayed.close();
|
||||
}
|
||||
|
||||
void Function(bool) get fetchDownloads => _downloadsInput.add;
|
||||
|
||||
void Function(bool) get fetchEpisodes => _episodesInput.add;
|
||||
|
||||
Stream<BlocState<List<Episode>>>? get downloads => _downloadsOutput;
|
||||
|
||||
Stream<BlocState<List<Episode>>>? get episodes => _episodesOutput;
|
||||
|
||||
void Function(Episode?) get deleteDownload => _deleteDownload.add;
|
||||
|
||||
void Function(Episode?) get togglePlayed => _togglePlayed.add;
|
||||
}
|
||||
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;
|
||||
}
|
||||
63
PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart
Normal file
63
PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/state/queue_event_state.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Handles interaction with the Queue via an [AudioPlayerService].
|
||||
class QueueBloc extends Bloc {
|
||||
final AudioPlayerService audioPlayerService;
|
||||
final PodcastService podcastService;
|
||||
final PublishSubject<QueueEvent> _queueEvent = PublishSubject<QueueEvent>();
|
||||
|
||||
QueueBloc({
|
||||
required this.audioPlayerService,
|
||||
required this.podcastService,
|
||||
}) {
|
||||
_handleQueueEvents();
|
||||
}
|
||||
|
||||
void _handleQueueEvents() {
|
||||
_queueEvent.listen((QueueEvent event) async {
|
||||
if (event is QueueAddEvent) {
|
||||
var e = event.episode;
|
||||
if (e != null) {
|
||||
await audioPlayerService.addUpNextEpisode(e);
|
||||
}
|
||||
} else if (event is QueueRemoveEvent) {
|
||||
var e = event.episode;
|
||||
if (e != null) {
|
||||
await audioPlayerService.removeUpNextEpisode(e);
|
||||
}
|
||||
} else if (event is QueueMoveEvent) {
|
||||
var e = event.episode;
|
||||
if (e != null) {
|
||||
await audioPlayerService.moveUpNextEpisode(e, event.oldIndex, event.newIndex);
|
||||
}
|
||||
} else if (event is QueueClearEvent) {
|
||||
await audioPlayerService.clearUpNext();
|
||||
}
|
||||
});
|
||||
|
||||
audioPlayerService.queueState!.debounceTime(const Duration(seconds: 2)).listen((event) {
|
||||
podcastService.saveQueue(event.queue).then((value) {
|
||||
/// Queue saved.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Function(QueueEvent) get queueEvent => _queueEvent.sink.add;
|
||||
|
||||
Stream<QueueListState>? get queue => audioPlayerService.queueState;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_queueEvent.close();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
96
PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart
Normal file
96
PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
// 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 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/bloc/search/search_state_event.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/state/bloc_state.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:podcast_search/podcast_search.dart' as pcast;
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// This BLoC interacts with the [PodcastService] to search for podcasts for
|
||||
/// a given term and to fetch the current podcast charts.
|
||||
class SearchBloc extends Bloc {
|
||||
final log = Logger('SearchBloc');
|
||||
final PodcastService podcastService;
|
||||
|
||||
/// Add to the Sink to trigger a search using the [SearchEvent].
|
||||
final BehaviorSubject<SearchEvent> _searchInput = BehaviorSubject<SearchEvent>();
|
||||
|
||||
/// Add to the Sink to fetch the current podcast top x.
|
||||
final BehaviorSubject<int> _chartsInput = BehaviorSubject<int>();
|
||||
|
||||
/// Stream of the current search results, be it from search or charts.
|
||||
Stream<BlocState<pcast.SearchResult>>? _searchResults;
|
||||
|
||||
/// Cache of last results.
|
||||
pcast.SearchResult? _resultsCache;
|
||||
|
||||
SearchBloc({required this.podcastService}) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_searchResults = _searchInput.switchMap<BlocState<pcast.SearchResult>>(
|
||||
(SearchEvent event) => _search(event),
|
||||
);
|
||||
}
|
||||
|
||||
/// Takes the [SearchEvent] to perform either a search, chart fetch or clearing
|
||||
/// of the current results cache.
|
||||
///
|
||||
/// To improve resilience, when performing a search the current network status is
|
||||
/// checked. a [BlocErrorState] is pushed if we have no connectivity.
|
||||
Stream<BlocState<pcast.SearchResult>> _search(SearchEvent event) async* {
|
||||
if (event is SearchClearEvent) {
|
||||
yield BlocDefaultState();
|
||||
} else if (event is SearchChartsEvent) {
|
||||
yield BlocLoadingState();
|
||||
|
||||
_resultsCache ??= await podcastService.charts(size: 10);
|
||||
|
||||
yield BlocPopulatedState<pcast.SearchResult>(results: _resultsCache);
|
||||
} else if (event is SearchTermEvent) {
|
||||
final term = event.term;
|
||||
|
||||
if (term.isEmpty) {
|
||||
yield BlocNoInputState();
|
||||
} else {
|
||||
yield BlocLoadingState();
|
||||
|
||||
// Check we have network
|
||||
var connectivityResult = await Connectivity().checkConnectivity();
|
||||
|
||||
// TODO: Docs do not recommend this approach as a reliable way to
|
||||
// determine if network is available.
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
yield BlocErrorState(error: BlocErrorType.connectivity);
|
||||
} else {
|
||||
final results = await podcastService.search(term: term);
|
||||
|
||||
// Was the search successful?
|
||||
if (results.successful) {
|
||||
yield BlocPopulatedState<pcast.SearchResult>(results: results);
|
||||
} else {
|
||||
yield BlocErrorState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchInput.close();
|
||||
_chartsInput.close();
|
||||
}
|
||||
|
||||
void Function(SearchEvent) get search => _searchInput.add;
|
||||
|
||||
Stream<BlocState>? get results => _searchResults;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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.
|
||||
|
||||
/// Events
|
||||
class SearchEvent {}
|
||||
|
||||
class SearchTermEvent extends SearchEvent {
|
||||
final String term;
|
||||
|
||||
SearchTermEvent(this.term);
|
||||
}
|
||||
|
||||
class SearchChartsEvent extends SearchEvent {}
|
||||
|
||||
class SearchClearEvent extends SearchEvent {}
|
||||
|
||||
/// States
|
||||
class SearchState {}
|
||||
|
||||
class SearchLoadingState extends SearchState {}
|
||||
362
PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart
Normal file
362
PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
// 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/core/environment.dart';
|
||||
import 'package:pinepods_mobile/entities/app_settings.dart';
|
||||
import 'package:pinepods_mobile/entities/search_providers.dart';
|
||||
import 'package:pinepods_mobile/services/settings/settings_service.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
class SettingsBloc extends Bloc {
|
||||
final log = Logger('SettingsBloc');
|
||||
final SettingsService _settingsService;
|
||||
final BehaviorSubject<AppSettings> _settings = BehaviorSubject<AppSettings>.seeded(AppSettings.sensibleDefaults());
|
||||
final BehaviorSubject<bool> _darkMode = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<String> _theme = BehaviorSubject<String>();
|
||||
final BehaviorSubject<bool> _markDeletedAsPlayed = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _deleteDownloadedPlayedEpisodes = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _storeDownloadOnSDCard = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<double> _playbackSpeed = BehaviorSubject<double>();
|
||||
final BehaviorSubject<String> _searchProvider = BehaviorSubject<String>();
|
||||
final BehaviorSubject<bool> _externalLinkConsent = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _autoOpenNowPlaying = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _showFunding = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _trimSilence = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _volumeBoost = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<int> _autoUpdatePeriod = BehaviorSubject<int>();
|
||||
final BehaviorSubject<int> _layoutMode = BehaviorSubject<int>();
|
||||
final BehaviorSubject<String?> _pinepodsServer = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<String?> _pinepodsApiKey = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<int?> _pinepodsUserId = BehaviorSubject<int?>();
|
||||
final BehaviorSubject<String?> _pinepodsUsername = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<String?> _pinepodsEmail = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<List<String>> _bottomBarOrder = BehaviorSubject<List<String>>();
|
||||
var _currentSettings = AppSettings.sensibleDefaults();
|
||||
|
||||
SettingsBloc(this._settingsService) {
|
||||
_init();
|
||||
// Check if we need to fetch user details for existing login
|
||||
_fetchUserDetailsIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> _fetchUserDetailsIfNeeded() async {
|
||||
// Only fetch if we have server/api key but no username
|
||||
if (_currentSettings.pinepodsServer != null &&
|
||||
_currentSettings.pinepodsApiKey != null &&
|
||||
(_currentSettings.pinepodsUsername == null || _currentSettings.pinepodsUsername!.isEmpty)) {
|
||||
|
||||
try {
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(_currentSettings.pinepodsServer!, _currentSettings.pinepodsApiKey!);
|
||||
|
||||
// Use stored user ID if available, otherwise we need to get it somehow
|
||||
final userId = _currentSettings.pinepodsUserId;
|
||||
print('DEBUG: User ID from settings: $userId');
|
||||
if (userId != null) {
|
||||
final userDetails = await pinepodsService.getUserDetails(userId);
|
||||
print('DEBUG: User details response: $userDetails');
|
||||
if (userDetails != null) {
|
||||
// Update settings with user details
|
||||
final username = userDetails['Username'] ?? userDetails['username'] ?? '';
|
||||
final email = userDetails['Email'] ?? userDetails['email'] ?? '';
|
||||
print('DEBUG: Parsed username: "$username", email: "$email"');
|
||||
setPinepodsUsername(username);
|
||||
setPinepodsEmail(email);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - don't break the app if this fails
|
||||
print('Failed to fetch user details on startup: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _init() {
|
||||
/// Load all settings
|
||||
// Add our available search providers.
|
||||
var providers = <SearchProvider>[SearchProvider(key: 'itunes', name: 'iTunes')];
|
||||
|
||||
if (podcastIndexKey.isNotEmpty) {
|
||||
providers.add(SearchProvider(key: 'podcastindex', name: 'PodcastIndex'));
|
||||
}
|
||||
|
||||
_currentSettings = AppSettings(
|
||||
theme: _settingsService.theme,
|
||||
markDeletedEpisodesAsPlayed: _settingsService.markDeletedEpisodesAsPlayed,
|
||||
deleteDownloadedPlayedEpisodes: _settingsService.deleteDownloadedPlayedEpisodes,
|
||||
storeDownloadsSDCard: _settingsService.storeDownloadsSDCard,
|
||||
playbackSpeed: _settingsService.playbackSpeed,
|
||||
searchProvider: _settingsService.searchProvider,
|
||||
searchProviders: providers,
|
||||
externalLinkConsent: _settingsService.externalLinkConsent,
|
||||
autoOpenNowPlaying: _settingsService.autoOpenNowPlaying,
|
||||
showFunding: _settingsService.showFunding,
|
||||
autoUpdateEpisodePeriod: _settingsService.autoUpdateEpisodePeriod,
|
||||
trimSilence: _settingsService.trimSilence,
|
||||
volumeBoost: _settingsService.volumeBoost,
|
||||
layout: _settingsService.layoutMode,
|
||||
pinepodsServer: _settingsService.pinepodsServer,
|
||||
pinepodsApiKey: _settingsService.pinepodsApiKey,
|
||||
pinepodsUserId: _settingsService.pinepodsUserId,
|
||||
pinepodsUsername: _settingsService.pinepodsUsername,
|
||||
pinepodsEmail: _settingsService.pinepodsEmail,
|
||||
bottomBarOrder: _settingsService.bottomBarOrder,
|
||||
);
|
||||
|
||||
_settings.add(_currentSettings);
|
||||
|
||||
_darkMode.listen((bool darkMode) {
|
||||
_currentSettings = _currentSettings.copyWith(theme: darkMode ? 'Dark' : 'Light');
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.themeDarkMode = darkMode;
|
||||
});
|
||||
|
||||
_theme.listen((String theme) {
|
||||
_currentSettings = _currentSettings.copyWith(theme: theme);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.theme = theme;
|
||||
|
||||
// Sync with server if authenticated
|
||||
_syncThemeToServer(theme);
|
||||
});
|
||||
|
||||
_markDeletedAsPlayed.listen((bool mark) {
|
||||
_currentSettings = _currentSettings.copyWith(markDeletedEpisodesAsPlayed: mark);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.markDeletedEpisodesAsPlayed = mark;
|
||||
});
|
||||
|
||||
_deleteDownloadedPlayedEpisodes.listen((bool delete) {
|
||||
_currentSettings = _currentSettings.copyWith(deleteDownloadedPlayedEpisodes: delete);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.deleteDownloadedPlayedEpisodes = delete;
|
||||
});
|
||||
|
||||
_storeDownloadOnSDCard.listen((bool sdcard) {
|
||||
_currentSettings = _currentSettings.copyWith(storeDownloadsSDCard: sdcard);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.storeDownloadsSDCard = sdcard;
|
||||
});
|
||||
|
||||
_playbackSpeed.listen((double speed) {
|
||||
_currentSettings = _currentSettings.copyWith(playbackSpeed: speed);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.playbackSpeed = speed;
|
||||
});
|
||||
|
||||
_autoOpenNowPlaying.listen((bool autoOpen) {
|
||||
_currentSettings = _currentSettings.copyWith(autoOpenNowPlaying: autoOpen);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.autoOpenNowPlaying = autoOpen;
|
||||
});
|
||||
|
||||
_showFunding.listen((show) {
|
||||
// If the setting has not changed, don't bother updating it
|
||||
if (show != _currentSettings.showFunding) {
|
||||
_currentSettings = _currentSettings.copyWith(showFunding: show);
|
||||
_settingsService.showFunding = show;
|
||||
}
|
||||
|
||||
_settings.add(_currentSettings);
|
||||
});
|
||||
|
||||
_searchProvider.listen((search) {
|
||||
_currentSettings = _currentSettings.copyWith(searchProvider: search);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.searchProvider = search;
|
||||
});
|
||||
|
||||
_externalLinkConsent.listen((consent) {
|
||||
// If the setting has not changed, don't bother updating it
|
||||
if (consent != _settingsService.externalLinkConsent) {
|
||||
_currentSettings = _currentSettings.copyWith(externalLinkConsent: consent);
|
||||
_settingsService.externalLinkConsent = consent;
|
||||
}
|
||||
|
||||
_settings.add(_currentSettings);
|
||||
});
|
||||
|
||||
_autoUpdatePeriod.listen((period) {
|
||||
_currentSettings = _currentSettings.copyWith(autoUpdateEpisodePeriod: period);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.autoUpdateEpisodePeriod = period;
|
||||
});
|
||||
|
||||
_trimSilence.listen((trim) {
|
||||
_currentSettings = _currentSettings.copyWith(trimSilence: trim);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.trimSilence = trim;
|
||||
});
|
||||
|
||||
_volumeBoost.listen((boost) {
|
||||
_currentSettings = _currentSettings.copyWith(volumeBoost: boost);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.volumeBoost = boost;
|
||||
});
|
||||
|
||||
_pinepodsServer.listen((server) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsServer: server);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsServer = server;
|
||||
});
|
||||
|
||||
_pinepodsApiKey.listen((apiKey) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsApiKey: apiKey);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsApiKey = apiKey;
|
||||
});
|
||||
|
||||
_pinepodsUserId.listen((userId) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsUserId: userId);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsUserId = userId;
|
||||
});
|
||||
|
||||
_pinepodsUsername.listen((username) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsUsername: username);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsUsername = username;
|
||||
});
|
||||
|
||||
_pinepodsEmail.listen((email) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsEmail: email);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsEmail = email;
|
||||
});
|
||||
|
||||
_layoutMode.listen((mode) {
|
||||
_currentSettings = _currentSettings.copyWith(layout: mode);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.layoutMode = mode;
|
||||
});
|
||||
|
||||
_bottomBarOrder.listen((order) {
|
||||
_currentSettings = _currentSettings.copyWith(bottomBarOrder: order);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.bottomBarOrder = order;
|
||||
});
|
||||
}
|
||||
|
||||
Stream<AppSettings> get settings => _settings.stream;
|
||||
|
||||
void Function(bool) get darkMode => _darkMode.add;
|
||||
|
||||
void Function(bool) get storeDownloadonSDCard => _storeDownloadOnSDCard.add;
|
||||
|
||||
void Function(bool) get markDeletedAsPlayed => _markDeletedAsPlayed.add;
|
||||
|
||||
void Function(bool) get deleteDownloadedPlayedEpisodes => _deleteDownloadedPlayedEpisodes.add;
|
||||
|
||||
void Function(double) get setPlaybackSpeed => _playbackSpeed.add;
|
||||
|
||||
void Function(bool) get setAutoOpenNowPlaying => _autoOpenNowPlaying.add;
|
||||
|
||||
void Function(String) get setSearchProvider => _searchProvider.add;
|
||||
|
||||
void Function(bool) get setExternalLinkConsent => _externalLinkConsent.add;
|
||||
|
||||
void Function(bool) get setShowFunding => _showFunding.add;
|
||||
|
||||
void Function(int) get autoUpdatePeriod => _autoUpdatePeriod.add;
|
||||
|
||||
void Function(bool) get trimSilence => _trimSilence.add;
|
||||
|
||||
void Function(bool) get volumeBoost => _volumeBoost.add;
|
||||
|
||||
void Function(int) get layoutMode => _layoutMode.add;
|
||||
|
||||
void Function(String?) get setPinepodsServer => _pinepodsServer.add;
|
||||
|
||||
void Function(String?) get setPinepodsApiKey => _pinepodsApiKey.add;
|
||||
|
||||
void Function(int?) get setPinepodsUserId => _pinepodsUserId.add;
|
||||
|
||||
void Function(String?) get setPinepodsUsername => _pinepodsUsername.add;
|
||||
|
||||
void Function(String?) get setPinepodsEmail => _pinepodsEmail.add;
|
||||
|
||||
void Function(List<String>) get setBottomBarOrder => _bottomBarOrder.add;
|
||||
|
||||
void Function(String) get setTheme => _theme.add;
|
||||
|
||||
AppSettings get currentSettings => _settings.value;
|
||||
|
||||
Future<void> _syncThemeToServer(String theme) async {
|
||||
try {
|
||||
// Only sync if we have PinePods credentials
|
||||
if (_currentSettings.pinepodsServer != null &&
|
||||
_currentSettings.pinepodsApiKey != null &&
|
||||
_currentSettings.pinepodsUserId != null) {
|
||||
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(
|
||||
_currentSettings.pinepodsServer!,
|
||||
_currentSettings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
await pinepodsService.setUserTheme(_currentSettings.pinepodsUserId!, theme);
|
||||
log.info('Theme synced to server: $theme');
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Failed to sync theme to server: $e');
|
||||
// Don't throw - theme should still work locally
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchThemeFromServer() async {
|
||||
try {
|
||||
// Only fetch if we have PinePods credentials
|
||||
if (_currentSettings.pinepodsServer != null &&
|
||||
_currentSettings.pinepodsApiKey != null &&
|
||||
_currentSettings.pinepodsUserId != null) {
|
||||
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(
|
||||
_currentSettings.pinepodsServer!,
|
||||
_currentSettings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
final serverTheme = await pinepodsService.getUserTheme(_currentSettings.pinepodsUserId!);
|
||||
if (serverTheme != null && serverTheme.isNotEmpty) {
|
||||
// Update local theme without syncing back to server
|
||||
_settingsService.theme = serverTheme;
|
||||
_currentSettings = _currentSettings.copyWith(theme: serverTheme);
|
||||
_settings.add(_currentSettings);
|
||||
log.info('Theme fetched from server: $serverTheme');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Failed to fetch theme from server: $e');
|
||||
// Don't throw - continue with local theme
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_darkMode.close();
|
||||
_theme.close();
|
||||
_markDeletedAsPlayed.close();
|
||||
_deleteDownloadedPlayedEpisodes.close();
|
||||
_storeDownloadOnSDCard.close();
|
||||
_playbackSpeed.close();
|
||||
_searchProvider.close();
|
||||
_externalLinkConsent.close();
|
||||
_autoOpenNowPlaying.close();
|
||||
_showFunding.close();
|
||||
_trimSilence.close();
|
||||
_volumeBoost.close();
|
||||
_autoUpdatePeriod.close();
|
||||
_layoutMode.close();
|
||||
_pinepodsServer.close();
|
||||
_pinepodsApiKey.close();
|
||||
_pinepodsUserId.close();
|
||||
_pinepodsUsername.close();
|
||||
_pinepodsEmail.close();
|
||||
_bottomBarOrder.close();
|
||||
_settings.close();
|
||||
}
|
||||
}
|
||||
19
PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart
Normal file
19
PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// 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:rxdart/rxdart.dart';
|
||||
|
||||
/// This BLoC provides a sink and stream to set and listen for the current
|
||||
/// page/tab on a bottom navigation bar.
|
||||
class PagerBloc {
|
||||
final BehaviorSubject<int> page = BehaviorSubject<int>.seeded(0);
|
||||
|
||||
Function(int) get changePage => page.add;
|
||||
|
||||
Stream<int> get currentPage => page.stream;
|
||||
|
||||
void dispose() {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user