added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,247 @@
// 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:io';
import 'package:pinepods_mobile/api/podcast/podcast_api.dart';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:flutter/foundation.dart';
import 'package:podcast_search/podcast_search.dart' as podcast_search;
import 'package:http/http.dart' as http;
import 'package:html/parser.dart' as html;
/// An implementation of the [PodcastApi].
///
/// A simple wrapper class that interacts with the iTunes/PodcastIndex search API
/// via the podcast_search package.
class MobilePodcastApi extends PodcastApi {
/// Set when using a custom certificate authority.
SecurityContext? _defaultSecurityContext;
/// Bytes containing a custom certificate authority.
List<int> _certificateAuthorityBytes = [];
@override
Future<podcast_search.SearchResult> search(
String term, {
String? country,
String? attribute,
int? limit,
String? language,
int version = 0,
bool explicit = false,
String? searchProvider,
}) async {
var searchParams = {
'term': term,
'searchProvider': searchProvider,
};
return compute(_search, searchParams);
}
@override
Future<podcast_search.SearchResult> charts({
int? size = 20,
String? genre,
String? searchProvider,
String? countryCode = '',
String? languageCode = '',
}) async {
var searchParams = {
'size': size.toString(),
'genre': genre,
'searchProvider': searchProvider,
'countryCode': countryCode,
'languageCode': languageCode,
};
return compute(_charts, searchParams);
}
@override
List<String> genres(String searchProvider) {
var provider = searchProvider == 'itunes'
? const podcast_search.ITunesProvider()
: podcast_search.PodcastIndexProvider(
key: podcastIndexKey,
secret: podcastIndexSecret,
);
return podcast_search.Search(
userAgent: Environment.userAgent(),
searchProvider: provider,
).genres();
}
@override
Future<podcast_search.Podcast> loadFeed(String url) async {
return _loadFeed(url);
}
@override
Future<podcast_search.Chapters> loadChapters(String url) async {
// In podcast_search 0.7.11, load chapters using Feed.loadChaptersByUrl
try {
return await podcast_search.Feed.loadChaptersByUrl(url: url);
} catch (e) {
// Fallback: create empty chapters if loading fails
return podcast_search.Chapters(url: url);
}
}
@override
Future<podcast_search.Transcript> loadTranscript(TranscriptUrl transcriptUrl) async {
// Handle HTML transcripts with custom parser
if (transcriptUrl.type == TranscriptFormat.html) {
return await _loadHtmlTranscript(transcriptUrl);
}
late podcast_search.TranscriptFormat format;
switch (transcriptUrl.type) {
case TranscriptFormat.subrip:
format = podcast_search.TranscriptFormat.subrip;
break;
case TranscriptFormat.json:
format = podcast_search.TranscriptFormat.json;
break;
case TranscriptFormat.html:
// This case is now handled above
format = podcast_search.TranscriptFormat.unsupported;
break;
case TranscriptFormat.unsupported:
format = podcast_search.TranscriptFormat.unsupported;
break;
}
// In podcast_search 0.7.11, load transcript using Feed.loadTranscriptByUrl
try {
// Create a podcast_search.TranscriptUrl from our local TranscriptUrl
final searchTranscriptUrl = podcast_search.TranscriptUrl(
url: transcriptUrl.url,
type: format,
language: transcriptUrl.language ?? '',
rel: transcriptUrl.rel ?? '',
);
return await podcast_search.Feed.loadTranscriptByUrl(
transcriptUrl: searchTranscriptUrl
);
} catch (e) {
// Fallback: create empty transcript if loading fails
return podcast_search.Transcript();
}
}
/// Parse HTML transcript content into a transcript object
Future<podcast_search.Transcript> _loadHtmlTranscript(TranscriptUrl transcriptUrl) async {
try {
final response = await http.get(Uri.parse(transcriptUrl.url));
if (response.statusCode != 200) {
return podcast_search.Transcript();
}
final document = html.parse(response.body);
final subtitles = <podcast_search.Subtitle>[];
// For HTML transcripts, find the main content area and render as a single block
String transcriptContent = '';
// Try to find the main transcript content area
final transcriptContainer = document.querySelector('.transcript, .content, main, article') ??
document.querySelector('body');
if (transcriptContainer != null) {
transcriptContent = transcriptContainer.innerHtml;
// Clean up common unwanted elements
final cleanDoc = html.parse(transcriptContent);
// Remove navigation, headers, footers, ads, etc.
for (final selector in ['nav', 'header', 'footer', '.nav', '.navigation', '.ads', '.advertisement', '.sidebar']) {
cleanDoc.querySelectorAll(selector).forEach((el) => el.remove());
}
transcriptContent = cleanDoc.body?.innerHtml ?? transcriptContent;
// Process markdown-style links [text](url) -> <a href="url">text</a>
transcriptContent = transcriptContent.replaceAllMapped(
RegExp(r'\[([^\]]+)\]\(([^)]+)\)'),
(match) => '<a href="${match.group(2)}">${match.group(1)}</a>',
);
// Create a single subtitle entry for the entire HTML transcript
subtitles.add(podcast_search.Subtitle(
index: 0,
start: const Duration(seconds: 0),
end: const Duration(seconds: 1), // Minimal duration since timing doesn't matter
data: '{{HTMLFULL}}$transcriptContent',
speaker: '',
));
}
return podcast_search.Transcript(subtitles: subtitles);
} catch (e) {
debugPrint('Error parsing HTML transcript: $e');
return podcast_search.Transcript();
}
}
static Future<podcast_search.SearchResult> _search(Map<String, String?> searchParams) {
var term = searchParams['term']!;
var provider = searchParams['searchProvider'] == 'itunes'
? const podcast_search.ITunesProvider()
: podcast_search.PodcastIndexProvider(
key: podcastIndexKey,
secret: podcastIndexSecret,
);
return podcast_search.Search(
userAgent: Environment.userAgent(),
searchProvider: provider,
).search(term).timeout(const Duration(seconds: 30));
}
static Future<podcast_search.SearchResult> _charts(Map<String, String?> searchParams) {
var provider = searchParams['searchProvider'] == 'itunes'
? const podcast_search.ITunesProvider()
: podcast_search.PodcastIndexProvider(
key: podcastIndexKey,
secret: podcastIndexSecret,
);
var countryCode = searchParams['countryCode'];
var languageCode = searchParams['languageCode'] ?? '';
var country = podcast_search.Country.none;
if (countryCode != null && countryCode.isNotEmpty) {
country = podcast_search.Country.values.where((element) => element.code == countryCode).first;
}
return podcast_search.Search(userAgent: Environment.userAgent(), searchProvider: provider)
.charts(genre: searchParams['genre']!, country: country, language: languageCode, limit: 50)
.timeout(const Duration(seconds: 30));
}
Future<podcast_search.Podcast> _loadFeed(String url) {
_setupSecurityContext();
// In podcast_search 0.7.11, use Feed.loadFeed or create a Feed instance
return podcast_search.Feed.loadFeed(url: url, userAgent: Environment.userAgent());
}
void _setupSecurityContext() {
if (_certificateAuthorityBytes.isNotEmpty && _defaultSecurityContext == null) {
SecurityContext.defaultContext.setTrustedCertificatesBytes(_certificateAuthorityBytes);
_defaultSecurityContext = SecurityContext.defaultContext;
}
}
@override
void addClientAuthorityBytes(List<int> certificateAuthorityBytes) {
_certificateAuthorityBytes = certificateAuthorityBytes;
}
}

View File

@@ -0,0 +1,51 @@
// 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/entities/transcript.dart';
import 'package:podcast_search/podcast_search.dart' as pslib;
/// A simple wrapper class that interacts with the search API via
/// the podcast_search package.
///
/// TODO: Make this more generic so it's not tied to podcast_search
abstract class PodcastApi {
/// Search for podcasts matching the search criteria. Returns a
/// [SearchResult] instance.
Future<pslib.SearchResult> search(
String term, {
String? country,
String? attribute,
int? limit,
String? language,
int version = 0,
bool explicit = false,
String? searchProvider,
});
/// Request the top podcast charts from iTunes, and at most [size] records.
Future<pslib.SearchResult> charts({
int? size,
String? searchProvider,
String? genre,
String? countryCode,
String? languageCode,
});
List<String> genres(
String searchProvider,
);
/// URL representing the RSS feed for a podcast.
Future<pslib.Podcast> loadFeed(String url);
/// Load episode chapters via JSON file.
Future<pslib.Chapters> loadChapters(String url);
/// Load episode transcript via SRT or JSON file.
Future<pslib.Transcript> loadTranscript(TranscriptUrl transcriptUrl);
/// Allow adding of custom certificates. Required as default context
/// does not apply when running in separate Isolate.
void addClientAuthorityBytes(List<int> certificateAuthorityBytes);
}

View 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;
}

View 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();
}
}

View 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;
}

View 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;
}

View 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();
}
}

View 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;
}

View File

@@ -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 {}

View 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();
}
}

View 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();
}
}

View File

@@ -0,0 +1,8 @@
// 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.
/// Simple marker to indicate a field is transient and is not intended to be persisted
class Transient {
const Transient();
}

View File

@@ -0,0 +1,54 @@
// 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:io';
/// The key required when searching via PodcastIndex.org.
const podcastIndexKey = String.fromEnvironment('PINDEX_KEY', defaultValue: '');
/// The secret required when searching via PodcastIndex.org.
const podcastIndexSecret = String.fromEnvironment(
'PINDEX_SECRET',
defaultValue: '',
);
/// Allows a user to override the default user agent string.
const userAgentAppString = String.fromEnvironment(
'USER_AGENT',
defaultValue: '',
);
/// Link to a feedback form. This will be shown in the main overflow menu if set
const feedbackUrl = String.fromEnvironment('FEEDBACK_URL', defaultValue: '');
/// This class stores version information for PinePods, including project version and
/// build number. This is then used for user agent strings when interacting with
/// APIs and RSS feeds.
///
/// The user agent string can be overridden by passing in the USER_AGENT variable
/// using dart-define.
class Environment {
static const _applicationName = 'Pinepods';
static const _applicationUrl =
'https://github.com/madeofpendletonwool/pinepods';
static const _projectVersion = '0.8.1';
static const _build = '20252203';
static var _agentString = userAgentAppString;
static String userAgent() {
if (_agentString.isEmpty) {
var platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}'
.trim();
_agentString =
'$_applicationName/$_projectVersion b$_build (phone;$platform) $_applicationUrl';
}
return _agentString;
}
static String get projectVersion => '$_projectVersion b$_build';
}

View File

@@ -0,0 +1,60 @@
// 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:math';
extension IterableExtensions<E> on Iterable<E> {
Iterable<List<E>> chunk(int size) sync* {
if (length <= 0) {
yield [];
return;
}
var skip = 0;
while (skip < length) {
final chunk = this.skip(skip).take(size);
yield chunk.toList(growable: false);
skip += size;
if (chunk.length < size) {
return;
}
}
}
}
extension ExtString on String? {
String get forceHttps {
if (this != null) {
final url = Uri.tryParse(this!);
if (url == null || !url.isScheme('http')) return this!;
// Don't force HTTPS for localhost or local IP addresses to support self-hosted development
final host = url.host.toLowerCase();
if (host == 'localhost' ||
host == '127.0.0.1' ||
host.startsWith('10.') ||
host.startsWith('192.168.') ||
host.startsWith('172.') ||
host.endsWith('.local')) {
return this!;
}
return url.replace(scheme: 'https').toString();
}
return this ?? '';
}
}
extension ExtDouble on double {
double get toTenth {
var mod = pow(10.0, 1).toDouble();
return ((this * mod).round().toDouble() / mod);
}
}

View File

@@ -0,0 +1,140 @@
// 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:io';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart';
import 'package:pinepods_mobile/services/settings/settings_service.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
/// Returns the storage directory for the current platform.
///
/// On iOS, the directory that the app has available to it for storing episodes may
/// change between updates, whereas on Android we are able to save the full path. To
/// ensure we can handle the directory name change on iOS without breaking existing
/// Android installations we have created the following three functions to help with
/// resolving the various paths correctly depending upon platform.
Future<String> resolvePath(Episode episode) async {
if (Platform.isIOS) {
return Future.value(join(await getStorageDirectory(), episode.filepath, episode.filename));
}
return Future.value(join(episode.filepath!, episode.filename));
}
Future<String> resolveDirectory({required Episode episode, bool full = false}) async {
if (full || Platform.isAndroid) {
return Future.value(join(await getStorageDirectory(), safePath(episode.podcast!)));
}
return Future.value(safePath(episode.podcast!));
}
Future<void> createDownloadDirectory(Episode episode) async {
var path = join(await getStorageDirectory(), safePath(episode.podcast!));
Directory(path).createSync(recursive: true);
}
Future<bool> hasStoragePermission() async {
SettingsService? settings = await MobileSettingsService.instance();
if (Platform.isIOS || !settings!.storeDownloadsSDCard) {
return Future.value(true);
} else {
final permissionStatus = await Permission.storage.request();
return Future.value(permissionStatus.isGranted);
}
}
Future<String> getStorageDirectory() async {
SettingsService? settings = await MobileSettingsService.instance();
Directory directory;
if (Platform.isIOS) {
directory = await getApplicationDocumentsDirectory();
} else if (settings!.storeDownloadsSDCard) {
directory = await _getSDCard();
} else {
directory = await getApplicationSupportDirectory();
}
return join(directory.path, 'PinePods');
}
Future<bool> hasExternalStorage() async {
try {
await _getSDCard();
return Future.value(true);
} catch (e) {
return Future.value(false);
}
}
Future<Directory> _getSDCard() async {
final appDocumentDir = (await getExternalStorageDirectories(type: StorageDirectory.podcasts))!;
Directory? path;
// If the directory contains the word 'emulated' we are
// probably looking at a mapped user partition rather than
// an actual SD card - so skip those and find the first
// non-emulated directory.
if (appDocumentDir.isNotEmpty) {
// See if we can find the last card without emulated
for (var d in appDocumentDir) {
if (!d.path.contains('emulated')) {
path = d.absolute;
}
}
}
if (path == null) {
throw ('No SD card found');
}
return path;
}
/// Strips characters that are invalid for file and directory names.
String? safePath(String? s) {
return s?.replaceAll(RegExp(r'[^\w\s]+'), '').trim();
}
String? safeFile(String? s) {
return s?.replaceAll(RegExp(r'[^\w\s\.]+'), '').trim();
}
Future<String> resolveUrl(String url, {bool forceHttps = false}) async {
final client = HttpClient();
var uri = Uri.parse(url);
var request = await client.getUrl(uri);
request.followRedirects = false;
var response = await request.close();
while (response.isRedirect) {
response.drain(0);
final location = response.headers.value(HttpHeaders.locationHeader);
if (location != null) {
uri = uri.resolve(location);
request = await client.getUrl(uri);
// Set the body or headers as desired.
request.followRedirects = false;
response = await request.close();
}
}
if (uri.scheme == 'http') {
uri = uri.replace(scheme: 'https');
}
return uri.toString();
}

View File

@@ -0,0 +1,152 @@
// 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/entities/search_providers.dart';
class AppSettings {
/// The current theme name.
final String theme;
/// True if episodes are marked as played when deleted.
final bool markDeletedEpisodesAsPlayed;
/// True if downloaded played episodes must be deleted automatically.
final bool deleteDownloadedPlayedEpisodes;
/// True if downloads should be saved to the SD card.
final bool storeDownloadsSDCard;
/// The default playback speed.
final double playbackSpeed;
/// The search provider: itunes or podcastindex.
final String? searchProvider;
/// List of search providers: currently itunes or podcastindex.
final List<SearchProvider> searchProviders;
/// True if the user has confirmed dialog accepting funding links.
final bool externalLinkConsent;
/// If true the main player window will open as soon as an episode starts.
final bool autoOpenNowPlaying;
/// If true the funding link icon will appear (if the podcast supports it).
final bool showFunding;
/// If -1 never; 0 always; otherwise time in minutes.
final int autoUpdateEpisodePeriod;
/// If true, silence in audio playback is trimmed. Currently Android only.
final bool trimSilence;
/// If true, volume is boosted. Currently Android only.
final bool volumeBoost;
/// If 0, list view; else grid view
final int layout;
final String? pinepodsServer;
final String? pinepodsApiKey;
final int? pinepodsUserId;
final String? pinepodsUsername;
final String? pinepodsEmail;
/// Custom order for bottom navigation bar items
final List<String> bottomBarOrder;
AppSettings({
required this.theme,
required this.markDeletedEpisodesAsPlayed,
required this.deleteDownloadedPlayedEpisodes,
required this.storeDownloadsSDCard,
required this.playbackSpeed,
required this.searchProvider,
required this.searchProviders,
required this.externalLinkConsent,
required this.autoOpenNowPlaying,
required this.showFunding,
required this.autoUpdateEpisodePeriod,
required this.trimSilence,
required this.volumeBoost,
required this.layout,
this.pinepodsServer,
this.pinepodsApiKey,
this.pinepodsUserId,
this.pinepodsUsername,
this.pinepodsEmail,
required this.bottomBarOrder,
});
AppSettings.sensibleDefaults()
: theme = 'Dark',
markDeletedEpisodesAsPlayed = false,
deleteDownloadedPlayedEpisodes = false,
storeDownloadsSDCard = false,
playbackSpeed = 1.0,
searchProvider = 'itunes',
searchProviders = <SearchProvider>[],
externalLinkConsent = false,
autoOpenNowPlaying = false,
showFunding = true,
autoUpdateEpisodePeriod = -1,
trimSilence = false,
volumeBoost = false,
layout = 0,
pinepodsServer = null,
pinepodsApiKey = null,
pinepodsUserId = null,
pinepodsUsername = null,
pinepodsEmail = null,
bottomBarOrder = const ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search'];
AppSettings copyWith({
String? theme,
bool? markDeletedEpisodesAsPlayed,
bool? deleteDownloadedPlayedEpisodes,
bool? storeDownloadsSDCard,
double? playbackSpeed,
String? searchProvider,
List<SearchProvider>? searchProviders,
bool? externalLinkConsent,
bool? autoOpenNowPlaying,
bool? showFunding,
int? autoUpdateEpisodePeriod,
bool? trimSilence,
bool? volumeBoost,
int? layout,
String? pinepodsServer,
String? pinepodsApiKey,
int? pinepodsUserId,
String? pinepodsUsername,
String? pinepodsEmail,
List<String>? bottomBarOrder,
}) =>
AppSettings(
theme: theme ?? this.theme,
markDeletedEpisodesAsPlayed: markDeletedEpisodesAsPlayed ?? this.markDeletedEpisodesAsPlayed,
deleteDownloadedPlayedEpisodes: deleteDownloadedPlayedEpisodes ?? this.deleteDownloadedPlayedEpisodes,
storeDownloadsSDCard: storeDownloadsSDCard ?? this.storeDownloadsSDCard,
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
searchProvider: searchProvider ?? this.searchProvider,
searchProviders: searchProviders ?? this.searchProviders,
externalLinkConsent: externalLinkConsent ?? this.externalLinkConsent,
autoOpenNowPlaying: autoOpenNowPlaying ?? this.autoOpenNowPlaying,
showFunding: showFunding ?? this.showFunding,
autoUpdateEpisodePeriod: autoUpdateEpisodePeriod ?? this.autoUpdateEpisodePeriod,
trimSilence: trimSilence ?? this.trimSilence,
volumeBoost: volumeBoost ?? this.volumeBoost,
layout: layout ?? this.layout,
pinepodsServer: pinepodsServer ?? this.pinepodsServer,
pinepodsApiKey: pinepodsApiKey ?? this.pinepodsApiKey,
pinepodsUserId: pinepodsUserId ?? this.pinepodsUserId,
pinepodsUsername: pinepodsUsername ?? this.pinepodsUsername,
pinepodsEmail: pinepodsEmail ?? this.pinepodsEmail,
bottomBarOrder: bottomBarOrder ?? this.bottomBarOrder,
);
}

View File

@@ -0,0 +1,71 @@
// 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/core/extensions.dart';
/// A class that represents an individual chapter within an [Episode].
///
/// Chapters may, or may not, exist for an episode.
///
/// Part of the [podcast namespace](https://github.com/Podcastindex-org/podcast-namespace)
class Chapter {
/// Title of this chapter.
final String title;
/// URL for the chapter image if one is available.
final String? imageUrl;
/// URL of an external link for this chapter if available.
final String? url;
/// Table of contents flag. If this is false the chapter should be treated as
/// meta data only and not be displayed.
final bool toc;
/// The start time of the chapter in seconds.
final double startTime;
/// The optional end time of the chapter in seconds.
final double? endTime;
Chapter({
required this.title,
required String? imageUrl,
required this.startTime,
String? url,
this.toc = true,
this.endTime,
}) : imageUrl = imageUrl?.forceHttps,
url = url?.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'title': title,
'imageUrl': imageUrl,
'url': url,
'toc': toc ? 'true' : 'false',
'startTime': startTime.toString(),
'endTime': endTime.toString(),
};
}
static Chapter fromMap(Map<String, dynamic> chapter) {
return Chapter(
title: chapter['title'] as String,
imageUrl: chapter['imageUrl'] as String?,
url: chapter['url'] as String?,
toc: chapter['toc'] == 'false' ? false : true,
startTime: double.tryParse(chapter['startTime'] as String? ?? '0') ?? 0.0,
endTime: double.tryParse(chapter['endTime'] as String? ?? '0') ?? 0.0,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Chapter && runtimeType == other.runtimeType && title == other.title && startTime == other.startTime;
@override
int get hashCode => title.hashCode ^ startTime.hashCode;
}

View File

@@ -0,0 +1,98 @@
// 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.
enum DownloadState { none, queued, downloading, failed, cancelled, paused, downloaded }
/// A Downloadble is an object that holds information about a podcast episode
/// and its download status.
///
/// Downloadables can be used to determine if a download has been successful and
/// if an episode can be played from the filesystem.
class Downloadable {
/// Database ID
int? id;
/// Unique identifier for the download
final String guid;
/// URL of the file to download
final String url;
/// Destination directory
String directory;
/// Name of file
String filename;
/// Current task ID for the download
String taskId;
/// Current state of the download
DownloadState state;
/// Percentage of MP3 downloaded
int? percentage;
Downloadable({
required this.guid,
required this.url,
required this.directory,
required this.filename,
required this.taskId,
required this.state,
this.percentage,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'url': url,
'filename': filename,
'directory': directory,
'taskId': taskId,
'state': state.index,
'percentage': percentage.toString(),
};
}
static Downloadable fromMap(Map<String, dynamic> downloadable) {
return Downloadable(
guid: downloadable['guid'] as String,
url: downloadable['url'] as String,
directory: downloadable['directory'] as String,
filename: downloadable['filename'] as String,
taskId: downloadable['taskId'] as String,
state: _determineState(downloadable['state'] as int?),
percentage: int.parse(downloadable['percentage'] as String),
);
}
static DownloadState _determineState(int? index) {
switch (index) {
case 0:
return DownloadState.none;
case 1:
return DownloadState.queued;
case 2:
return DownloadState.downloading;
case 3:
return DownloadState.failed;
case 4:
return DownloadState.cancelled;
case 5:
return DownloadState.paused;
case 6:
return DownloadState.downloaded;
}
return DownloadState.none;
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is Downloadable && runtimeType == other.runtimeType && guid == other.guid;
@override
int get hashCode => guid.hashCode;
}

View File

@@ -0,0 +1,420 @@
// 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/core/annotations.dart';
import 'package:pinepods_mobile/core/extensions.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:flutter/foundation.dart';
import 'package:html/parser.dart' show parseFragment;
import 'package:logging/logging.dart';
/// An object that represents an individual episode of a Podcast.
///
/// An Episode can be used in conjunction with a [Downloadable] to
/// determine if the Episode is available on the local filesystem.
class Episode {
final log = Logger('Episode');
/// Database ID
int? id;
/// A String GUID for the episode.
final String guid;
/// The GUID for an associated podcast. If an episode has been downloaded
/// without subscribing to a podcast this may be null.
String? pguid;
/// If the episode is currently being downloaded, this contains the unique
/// ID supplied by the download manager for the episode.
String? downloadTaskId;
/// The path to the directory containing the download for this episode; or null.
String? filepath;
/// The filename of the downloaded episode; or null.
String? filename;
/// The current downloading state of the episode.
DownloadState downloadState = DownloadState.none;
/// The name of the podcast the episode is part of.
String? podcast;
/// The episode title.
String? title;
/// The episode description. This could be plain text or HTML.
String? description;
/// More detailed description - optional.
String? content;
/// External link
String? link;
/// URL to the episode artwork image.
String? imageUrl;
/// URL to a thumbnail version of the episode artwork image.
String? thumbImageUrl;
/// The date the episode was published (if known).
DateTime? publicationDate;
/// The URL for the episode location.
String? contentUrl;
/// Author of the episode if known.
String? author;
/// The season the episode is part of if available.
int season;
/// The episode number within a season if available.
int episode;
/// The duration of the episode in milliseconds. This can be populated either from
/// the RSS if available, or determined from the MP3 file at stream/download time.
int duration;
/// Stores the current position within the episode in milliseconds. Used for resuming.
int position;
/// Stores the progress of the current download progress if available.
int? downloadPercentage;
/// True if this episode is 'marked as played'.
bool played;
/// URL pointing to a JSON file containing chapter information if available.
String? chaptersUrl;
/// List of chapters for the episode if available.
List<Chapter> chapters;
/// List of transcript URLs for the episode if available.
List<TranscriptUrl> transcriptUrls;
List<Person> persons;
/// Currently downloaded or in use transcript for the episode.To minimise memory
/// use, this is cleared when an episode download is deleted, or a streamed episode stopped.
Transcript? transcript;
/// Link to a currently stored transcript for this episode.
int? transcriptId;
/// Date and time episode was last updated and persisted.
DateTime? lastUpdated;
/// Processed version of episode description.
String? _descriptionText;
/// Index of the currently playing chapter it available. Transient.
int? chapterIndex;
/// Current chapter we are listening to if this episode has chapters. Transient.
Chapter? currentChapter;
/// Set to true if chapter data is currently being loaded.
@Transient()
bool chaptersLoading = false;
@Transient()
bool highlight = false;
@Transient()
bool queued = false;
@Transient()
bool streaming = true;
Episode({
required this.guid,
this.pguid,
required this.podcast,
this.id,
this.downloadTaskId,
this.filepath,
this.filename,
this.downloadState = DownloadState.none,
this.title,
this.description,
this.content,
this.link,
String? imageUrl,
String? thumbImageUrl,
this.publicationDate,
String? contentUrl,
this.author,
this.season = 0,
this.episode = 0,
this.duration = 0,
this.position = 0,
this.downloadPercentage = 0,
this.played = false,
this.highlight = false,
String? chaptersUrl,
this.chapters = const <Chapter>[],
this.transcriptUrls = const <TranscriptUrl>[],
this.persons = const <Person>[],
this.transcriptId = 0,
this.lastUpdated,
}) : imageUrl = imageUrl?.forceHttps,
thumbImageUrl = thumbImageUrl?.forceHttps,
contentUrl = contentUrl?.forceHttps,
chaptersUrl = chaptersUrl?.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'pguid': pguid,
'downloadTaskId': downloadTaskId,
'filepath': filepath,
'filename': filename,
'downloadState': downloadState.index,
'podcast': podcast,
'title': title,
'description': description,
'content': content,
'link': link,
'imageUrl': imageUrl,
'thumbImageUrl': thumbImageUrl,
'publicationDate': publicationDate?.millisecondsSinceEpoch.toString(),
'contentUrl': contentUrl,
'author': author,
'season': season.toString(),
'episode': episode.toString(),
'duration': duration.toString(),
'position': position.toString(),
'downloadPercentage': downloadPercentage.toString(),
'played': played ? 'true' : 'false',
'chaptersUrl': chaptersUrl,
'chapters': (chapters).map((chapter) => chapter.toMap()).toList(growable: false),
'tid': transcriptId ?? 0,
'transcriptUrls': (transcriptUrls).map((tu) => tu.toMap()).toList(growable: false),
'persons': (persons).map((person) => person.toMap()).toList(growable: false),
'lastUpdated': lastUpdated?.millisecondsSinceEpoch.toString() ?? '',
};
}
static Episode fromMap(int? key, Map<String, dynamic> episode) {
var chapters = <Chapter>[];
var transcriptUrls = <TranscriptUrl>[];
var persons = <Person>[];
// We need to perform an 'is' on each loop to prevent Dart
// from complaining that we have not set the type for chapter.
if (episode['chapters'] != null) {
for (var chapter in (episode['chapters'] as List)) {
if (chapter is Map<String, dynamic>) {
chapters.add(Chapter.fromMap(chapter));
}
}
}
if (episode['transcriptUrls'] != null) {
for (var transcriptUrl in (episode['transcriptUrls'] as List)) {
if (transcriptUrl is Map<String, dynamic>) {
transcriptUrls.add(TranscriptUrl.fromMap(transcriptUrl));
}
}
}
if (episode['persons'] != null) {
for (var person in (episode['persons'] as List)) {
if (person is Map<String, dynamic>) {
persons.add(Person.fromMap(person));
}
}
}
return Episode(
id: key,
guid: episode['guid'] as String,
pguid: episode['pguid'] as String?,
downloadTaskId: episode['downloadTaskId'] as String?,
filepath: episode['filepath'] as String?,
filename: episode['filename'] as String?,
downloadState: _determineState(episode['downloadState'] as int?),
podcast: episode['podcast'] as String?,
title: episode['title'] as String?,
description: episode['description'] as String?,
content: episode['content'] as String?,
link: episode['link'] as String?,
imageUrl: episode['imageUrl'] as String?,
thumbImageUrl: episode['thumbImageUrl'] as String?,
publicationDate: episode['publicationDate'] == null || episode['publicationDate'] == 'null'
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(int.parse(episode['publicationDate'] as String)),
contentUrl: episode['contentUrl'] as String?,
author: episode['author'] as String?,
season: int.parse(episode['season'] as String? ?? '0'),
episode: int.parse(episode['episode'] as String? ?? '0'),
duration: int.parse(episode['duration'] as String? ?? '0'),
position: int.parse(episode['position'] as String? ?? '0'),
downloadPercentage: int.parse(episode['downloadPercentage'] as String? ?? '0'),
played: episode['played'] == 'true' ? true : false,
chaptersUrl: episode['chaptersUrl'] as String?,
chapters: chapters,
transcriptUrls: transcriptUrls,
persons: persons,
transcriptId: episode['tid'] == null ? 0 : episode['tid'] as int?,
lastUpdated: episode['lastUpdated'] == null || episode['lastUpdated'] == 'null'
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(int.parse(episode['lastUpdated'] as String)),
);
}
static DownloadState _determineState(int? index) {
switch (index) {
case 0:
return DownloadState.none;
case 1:
return DownloadState.queued;
case 2:
return DownloadState.downloading;
case 3:
return DownloadState.failed;
case 4:
return DownloadState.cancelled;
case 5:
return DownloadState.paused;
case 6:
return DownloadState.downloaded;
}
return DownloadState.none;
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is Episode &&
runtimeType == other.runtimeType &&
guid == other.guid &&
pguid == other.pguid &&
downloadTaskId == other.downloadTaskId &&
filepath == other.filepath &&
filename == other.filename &&
downloadState == other.downloadState &&
podcast == other.podcast &&
title == other.title &&
description == other.description &&
content == other.content &&
link == other.link &&
imageUrl == other.imageUrl &&
thumbImageUrl == other.thumbImageUrl &&
publicationDate?.millisecondsSinceEpoch == other.publicationDate?.millisecondsSinceEpoch &&
contentUrl == other.contentUrl &&
author == other.author &&
season == other.season &&
episode == other.episode &&
duration == other.duration &&
position == other.position &&
downloadPercentage == other.downloadPercentage &&
played == other.played &&
chaptersUrl == other.chaptersUrl &&
transcriptId == other.transcriptId &&
listEquals(persons, other.persons) &&
listEquals(chapters, other.chapters);
}
@override
int get hashCode =>
id.hashCode ^
guid.hashCode ^
pguid.hashCode ^
downloadTaskId.hashCode ^
filepath.hashCode ^
filename.hashCode ^
downloadState.hashCode ^
podcast.hashCode ^
title.hashCode ^
description.hashCode ^
content.hashCode ^
link.hashCode ^
imageUrl.hashCode ^
thumbImageUrl.hashCode ^
publicationDate.hashCode ^
contentUrl.hashCode ^
author.hashCode ^
season.hashCode ^
episode.hashCode ^
duration.hashCode ^
position.hashCode ^
downloadPercentage.hashCode ^
played.hashCode ^
chaptersUrl.hashCode ^
chapters.hashCode ^
transcriptId.hashCode ^
lastUpdated.hashCode;
@override
String toString() {
return 'Episode{id: $id, guid: $guid, pguid: $pguid, filepath: $filepath, title: $title, contentUrl: $contentUrl, episode: $episode, duration: $duration, position: $position, downloadPercentage: $downloadPercentage, played: $played, queued: $queued}';
}
bool get downloaded => downloadPercentage == 100;
Duration get timeRemaining {
if (position > 0 && duration > 0) {
var currentPosition = Duration(milliseconds: position);
var tr = duration - currentPosition.inSeconds;
return Duration(seconds: tr);
}
return const Duration(seconds: 0);
}
double get percentagePlayed {
if (position > 0 && duration > 0) {
var pc = (position / (duration * 1000)) * 100;
if (pc > 100.0) {
pc = 100.0;
}
return pc;
}
return 0.0;
}
String? get descriptionText {
if (_descriptionText == null || _descriptionText!.isEmpty) {
if (description == null || description!.isEmpty) {
_descriptionText = '';
} else {
// Replace break tags with space character for readability
var formattedDescription = description!.replaceAll(RegExp(r'(<br/?>)+'), ' ');
_descriptionText = parseFragment(formattedDescription).text;
}
}
return _descriptionText;
}
bool get hasChapters => (chaptersUrl != null && chaptersUrl!.isNotEmpty) || chapters.isNotEmpty;
bool get hasTranscripts => transcriptUrls.isNotEmpty;
bool get chaptersAreLoaded => chaptersLoading == false && chapters.isNotEmpty;
bool get chaptersAreNotLoaded => chaptersLoading == true && chapters.isEmpty;
String? get positionalImageUrl {
if (currentChapter != null && currentChapter!.imageUrl != null && currentChapter!.imageUrl!.isNotEmpty) {
return currentChapter!.imageUrl;
}
return imageUrl;
}
}

View File

@@ -0,0 +1,39 @@
// 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/entities/podcast.dart';
/// This class is used when loading a [Podcast] feed.
///
/// The key information is contained within the [Podcast] instance, but as the
/// iTunes API also returns large and thumbnail artwork within its search results
/// this class also contains properties to represent those.
class Feed {
/// The podcast to load
final Podcast podcast;
/// The full-size artwork for the podcast.
String? imageUrl;
/// The thumbnail artwork for the podcast,
String? thumbImageUrl;
/// If true the podcast is loaded regardless of if it's currently cached.
bool refresh;
/// If true, will also perform an additional background refresh.
bool backgroundFresh;
/// If true any error can be ignored.
bool silently;
Feed({
required this.podcast,
this.imageUrl,
this.thumbImageUrl,
this.refresh = false,
this.backgroundFresh = false,
this.silently = false,
});
}

View File

@@ -0,0 +1,35 @@
// 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/core/extensions.dart';
/// part of a [Podcast].
///
/// Part of the [podcast namespace](https://github.com/Podcastindex-org/podcast-namespace)
class Funding {
/// The URL to the funding/donation/information page.
final String url;
/// The label for the link which will be presented to the user.
final String value;
Funding({
required String url,
required this.value,
}) : url = url.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'url': url,
'value': value,
};
}
static Funding fromMap(Map<String, dynamic> chapter) {
return Funding(
url: chapter['url'] as String,
value: chapter['value'] as String,
);
}
}

View File

@@ -0,0 +1,238 @@
// lib/entities/home_data.dart
class HomePodcast {
final int podcastId;
final String podcastName;
final int? podcastIndexId;
final String? artworkUrl;
final String? author;
final String? categories;
final String? description;
final int? episodeCount;
final String? feedUrl;
final String? websiteUrl;
final bool? explicit;
final bool isYoutube;
final int playCount;
final int? totalListenTime;
HomePodcast({
required this.podcastId,
required this.podcastName,
this.podcastIndexId,
this.artworkUrl,
this.author,
this.categories,
this.description,
this.episodeCount,
this.feedUrl,
this.websiteUrl,
this.explicit,
required this.isYoutube,
required this.playCount,
this.totalListenTime,
});
factory HomePodcast.fromJson(Map<String, dynamic> json) {
return HomePodcast(
podcastId: json['podcastid'] ?? 0,
podcastName: json['podcastname'] ?? '',
podcastIndexId: json['podcastindexid'],
artworkUrl: json['artworkurl'],
author: json['author'],
categories: _parseCategories(json['categories']),
description: json['description'],
episodeCount: json['episodecount'],
feedUrl: json['feedurl'],
websiteUrl: json['websiteurl'],
explicit: json['explicit'],
isYoutube: json['is_youtube'] ?? false,
playCount: json['play_count'] ?? 0,
totalListenTime: json['total_listen_time'],
);
}
/// Parse categories from either string or Map format
static String? _parseCategories(dynamic categories) {
if (categories == null) return null;
if (categories is String) {
// Old format - return as is
return categories;
} else if (categories is Map<String, dynamic>) {
// New format - convert map values to comma-separated string
if (categories.isEmpty) return null;
return categories.values.join(', ');
}
return null;
}
}
class HomeEpisode {
final int episodeId;
final int podcastId;
final String episodeTitle;
final String episodeDescription;
final String episodeUrl;
final String episodeArtwork;
final String episodePubDate;
final int episodeDuration;
final bool completed;
final String podcastName;
final bool isYoutube;
final int? listenDuration;
final bool saved;
final bool queued;
final bool downloaded;
HomeEpisode({
required this.episodeId,
required this.podcastId,
required this.episodeTitle,
required this.episodeDescription,
required this.episodeUrl,
required this.episodeArtwork,
required this.episodePubDate,
required this.episodeDuration,
required this.completed,
required this.podcastName,
required this.isYoutube,
this.listenDuration,
this.saved = false,
this.queued = false,
this.downloaded = false,
});
factory HomeEpisode.fromJson(Map<String, dynamic> json) {
return HomeEpisode(
episodeId: json['episodeid'] ?? 0,
podcastId: json['podcastid'] ?? 0,
episodeTitle: json['episodetitle'] ?? '',
episodeDescription: json['episodedescription'] ?? '',
episodeUrl: json['episodeurl'] ?? '',
episodeArtwork: json['episodeartwork'] ?? '',
episodePubDate: json['episodepubdate'] ?? '',
episodeDuration: json['episodeduration'] ?? 0,
completed: json['completed'] ?? false,
podcastName: json['podcastname'] ?? '',
isYoutube: json['is_youtube'] ?? false,
listenDuration: json['listenduration'],
saved: json['saved'] ?? false,
queued: json['queued'] ?? false,
downloaded: json['downloaded'] ?? false,
);
}
/// Format duration in seconds to MM:SS or HH:MM:SS format
String get formattedDuration {
if (episodeDuration <= 0) return '--:--';
final hours = episodeDuration ~/ 3600;
final minutes = (episodeDuration % 3600) ~/ 60;
final seconds = episodeDuration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Format listen duration if available
String? get formattedListenDuration {
if (listenDuration == null || listenDuration! <= 0) return null;
final duration = listenDuration!;
final hours = duration ~/ 3600;
final minutes = (duration % 3600) ~/ 60;
final seconds = duration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Calculate progress percentage for progress bar
double get progressPercentage {
if (episodeDuration <= 0 || listenDuration == null) return 0.0;
return (listenDuration! / episodeDuration) * 100.0;
}
}
class HomeOverview {
final List<HomeEpisode> recentEpisodes;
final List<HomeEpisode> inProgressEpisodes;
final List<HomePodcast> topPodcasts;
final int savedCount;
final int downloadedCount;
final int queueCount;
HomeOverview({
required this.recentEpisodes,
required this.inProgressEpisodes,
required this.topPodcasts,
required this.savedCount,
required this.downloadedCount,
required this.queueCount,
});
factory HomeOverview.fromJson(Map<String, dynamic> json) {
return HomeOverview(
recentEpisodes: (json['recent_episodes'] as List<dynamic>? ?? [])
.map((e) => HomeEpisode.fromJson(e))
.toList(),
inProgressEpisodes: (json['in_progress_episodes'] as List<dynamic>? ?? [])
.map((e) => HomeEpisode.fromJson(e))
.toList(),
topPodcasts: (json['top_podcasts'] as List<dynamic>? ?? [])
.map((p) => HomePodcast.fromJson(p))
.toList(),
savedCount: json['saved_count'] ?? 0,
downloadedCount: json['downloaded_count'] ?? 0,
queueCount: json['queue_count'] ?? 0,
);
}
}
class Playlist {
final int playlistId;
final String name;
final String? description;
final String iconName;
final int? episodeCount;
Playlist({
required this.playlistId,
required this.name,
this.description,
required this.iconName,
this.episodeCount,
});
factory Playlist.fromJson(Map<String, dynamic> json) {
return Playlist(
playlistId: json['playlist_id'] ?? 0,
name: json['name'] ?? '',
description: json['description'],
iconName: json['icon_name'] ?? 'ph-music-notes',
episodeCount: json['episode_count'],
);
}
}
class PlaylistResponse {
final List<Playlist> playlists;
PlaylistResponse({required this.playlists});
factory PlaylistResponse.fromJson(Map<String, dynamic> json) {
return PlaylistResponse(
playlists: (json['playlists'] as List<dynamic>? ?? [])
.map((p) => Playlist.fromJson(p))
.toList(),
);
}
}

View File

@@ -0,0 +1,81 @@
// Copyright 2020 Ben Hills. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
enum LastState { none, completed, stopped, paused }
/// This class is used to persist information about the currently playing episode to disk.
///
/// This allows the background audio service to persist state (whilst the UI is not visible)
/// and for the episode play and position details to be restored when the UI becomes visible
/// again - either when bringing it to the foreground or upon next start.
class Persistable {
/// The Podcast GUID.
String pguid;
/// The episode ID (provided by the DB layer).
int episodeId;
/// The current position in seconds;
int position;
/// The current playback state.
LastState state;
/// Date & time episode was last updated.
DateTime? lastUpdated;
Persistable({
required this.pguid,
required this.episodeId,
required this.position,
required this.state,
this.lastUpdated,
});
Persistable.empty()
: pguid = '',
episodeId = 0,
position = 0,
state = LastState.none,
lastUpdated = DateTime.now();
Map<String, dynamic> toMap() {
return <String, dynamic>{
'pguid': pguid,
'episodeId': episodeId,
'position': position,
'state': state.toString(),
'lastUpdated': lastUpdated == null ? DateTime.now().millisecondsSinceEpoch : lastUpdated!.millisecondsSinceEpoch,
};
}
static Persistable fromMap(Map<String, dynamic> persistable) {
var stateString = persistable['state'] as String?;
var state = LastState.none;
if (stateString != null) {
switch (stateString) {
case 'LastState.completed':
state = LastState.completed;
break;
case 'LastState.stopped':
state = LastState.stopped;
break;
case 'LastState.paused':
state = LastState.paused;
break;
}
}
var lastUpdated = persistable['lastUpdated'] as int?;
return Persistable(
pguid: persistable['pguid'] as String,
episodeId: persistable['episodeId'] as int,
position: persistable['position'] as int,
state: state,
lastUpdated: lastUpdated == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(lastUpdated),
);
}
}

View File

@@ -0,0 +1,64 @@
// 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/core/extensions.dart';
/// This class represents a person of interest to the podcast.
///
/// It is primarily intended to identify people like hosts, co-hosts and guests.
class Person {
final String name;
final String role;
final String group;
final String? image;
final String? link;
Person({
required this.name,
this.role = '',
this.group = '',
String? image = '',
String? link = '',
}) : image = image?.forceHttps,
link = link?.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'name': name,
'role': role,
'group': group,
'image': image,
'link': link,
};
}
static Person fromMap(Map<String, dynamic> chapter) {
return Person(
name: chapter['name'] as String? ?? '',
role: chapter['role'] as String? ?? '',
group: chapter['group'] as String? ?? '',
image: chapter['image'] as String? ?? '',
link: chapter['link'] as String? ?? '',
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Person &&
runtimeType == other.runtimeType &&
name == other.name &&
role == other.role &&
group == other.group &&
image == other.image &&
link == other.link;
@override
int get hashCode => name.hashCode ^ role.hashCode ^ group.hashCode ^ image.hashCode ^ link.hashCode;
@override
String toString() {
return 'Person{name: $name, role: $role, group: $group, image: $image, link: $link}';
}
}

View File

@@ -0,0 +1,142 @@
class PinepodsEpisode {
final String podcastName;
final String episodeTitle;
final String episodePubDate;
final String episodeDescription;
final String episodeArtwork;
final String episodeUrl;
final int episodeDuration;
final int? listenDuration;
final int episodeId;
final bool completed;
final bool saved;
final bool queued;
final bool downloaded;
final bool isYoutube;
final int? podcastId;
PinepodsEpisode({
required this.podcastName,
required this.episodeTitle,
required this.episodePubDate,
required this.episodeDescription,
required this.episodeArtwork,
required this.episodeUrl,
required this.episodeDuration,
this.listenDuration,
required this.episodeId,
required this.completed,
required this.saved,
required this.queued,
required this.downloaded,
required this.isYoutube,
this.podcastId,
});
factory PinepodsEpisode.fromJson(Map<String, dynamic> json) {
return PinepodsEpisode(
podcastName: json['Podcastname'] ?? json['podcastname'] ?? '',
episodeTitle: json['Episodetitle'] ?? json['episodetitle'] ?? '',
episodePubDate: json['Episodepubdate'] ?? json['episodepubdate'] ?? '',
episodeDescription: json['Episodedescription'] ?? json['episodedescription'] ?? '',
episodeArtwork: json['Episodeartwork'] ?? json['episodeartwork'] ?? '',
episodeUrl: json['Episodeurl'] ?? json['episodeurl'] ?? '',
episodeDuration: json['Episodeduration'] ?? json['episodeduration'] ?? 0,
listenDuration: json['Listenduration'] ?? json['listenduration'],
episodeId: json['Episodeid'] ?? json['episodeid'] ?? 0,
completed: json['Completed'] ?? json['completed'] ?? false,
saved: json['Saved'] ?? json['saved'] ?? false,
queued: json['Queued'] ?? json['queued'] ?? false,
downloaded: json['Downloaded'] ?? json['downloaded'] ?? false,
isYoutube: json['Is_youtube'] ?? json['is_youtube'] ?? false,
podcastId: json['Podcastid'] ?? json['podcastid'],
);
}
Map<String, dynamic> toJson() {
return {
'podcastname': podcastName,
'episodetitle': episodeTitle,
'episodepubdate': episodePubDate,
'episodedescription': episodeDescription,
'episodeartwork': episodeArtwork,
'episodeurl': episodeUrl,
'episodeduration': episodeDuration,
'listenduration': listenDuration,
'episodeid': episodeId,
'completed': completed,
'saved': saved,
'queued': queued,
'downloaded': downloaded,
'is_youtube': isYoutube,
'podcastid': podcastId,
};
}
/// Format duration from seconds to MM:SS or HH:MM:SS
String get formattedDuration {
if (episodeDuration <= 0) return '0:00';
final hours = episodeDuration ~/ 3600;
final minutes = (episodeDuration % 3600) ~/ 60;
final seconds = episodeDuration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Get progress percentage (0-100)
double get progressPercentage {
if (episodeDuration <= 0 || listenDuration == null) return 0.0;
return (listenDuration! / episodeDuration * 100).clamp(0.0, 100.0);
}
/// Check if episode has been started (has some listen duration)
bool get isStarted {
return listenDuration != null && listenDuration! > 0;
}
/// Format listen duration from seconds to MM:SS or HH:MM:SS
String get formattedListenDuration {
if (listenDuration == null || listenDuration! <= 0) return '0:00';
final duration = listenDuration!;
final hours = duration ~/ 3600;
final minutes = (duration % 3600) ~/ 60;
final seconds = duration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Format the publish date to a more readable format
String get formattedPubDate {
try {
final date = DateTime.parse(episodePubDate);
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else if (difference.inDays < 7) {
return '${difference.inDays} days ago';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return weeks == 1 ? '1 week ago' : '$weeks weeks ago';
} else {
final months = (difference.inDays / 30).floor();
return months == 1 ? '1 month ago' : '$months months ago';
}
} catch (e) {
return episodePubDate;
}
}
}

View File

@@ -0,0 +1,359 @@
// lib/entities/pinepods_search.dart
class PinepodsSearchResult {
final String? status;
final int? resultCount;
final List<PinepodsPodcast>? feeds;
final List<PinepodsITunesPodcast>? results;
PinepodsSearchResult({
this.status,
this.resultCount,
this.feeds,
this.results,
});
factory PinepodsSearchResult.fromJson(Map<String, dynamic> json) {
return PinepodsSearchResult(
status: json['status'] as String?,
resultCount: json['resultCount'] as int?,
feeds: json['feeds'] != null
? (json['feeds'] as List)
.map((item) => PinepodsPodcast.fromJson(item as Map<String, dynamic>))
.toList()
: null,
results: json['results'] != null
? (json['results'] as List)
.map((item) => PinepodsITunesPodcast.fromJson(item as Map<String, dynamic>))
.toList()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'status': status,
'resultCount': resultCount,
'feeds': feeds?.map((item) => item.toJson()).toList(),
'results': results?.map((item) => item.toJson()).toList(),
};
}
List<UnifiedPinepodsPodcast> getUnifiedPodcasts() {
final List<UnifiedPinepodsPodcast> unified = [];
// Add PodcastIndex results
if (feeds != null) {
unified.addAll(feeds!.map((podcast) => UnifiedPinepodsPodcast.fromPodcast(podcast)));
}
// Add iTunes results
if (results != null) {
unified.addAll(results!.map((podcast) => UnifiedPinepodsPodcast.fromITunesPodcast(podcast)));
}
return unified;
}
}
class PinepodsPodcast {
final int id;
final String title;
final String url;
final String originalUrl;
final String link;
final String description;
final String author;
final String ownerName;
final String image;
final String artwork;
final int lastUpdateTime;
final Map<String, String>? categories;
final bool explicit;
final int episodeCount;
PinepodsPodcast({
required this.id,
required this.title,
required this.url,
required this.originalUrl,
required this.link,
required this.description,
required this.author,
required this.ownerName,
required this.image,
required this.artwork,
required this.lastUpdateTime,
this.categories,
required this.explicit,
required this.episodeCount,
});
factory PinepodsPodcast.fromJson(Map<String, dynamic> json) {
return PinepodsPodcast(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
originalUrl: json['originalUrl'] as String? ?? '',
link: json['link'] as String? ?? '',
description: json['description'] as String? ?? '',
author: json['author'] as String? ?? '',
ownerName: json['ownerName'] as String? ?? '',
image: json['image'] as String? ?? '',
artwork: json['artwork'] as String? ?? '',
lastUpdateTime: json['lastUpdateTime'] as int? ?? 0,
categories: json['categories'] != null
? Map<String, String>.from(json['categories'] as Map)
: null,
explicit: json['explicit'] as bool? ?? false,
episodeCount: json['episodeCount'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'url': url,
'originalUrl': originalUrl,
'link': link,
'description': description,
'author': author,
'ownerName': ownerName,
'image': image,
'artwork': artwork,
'lastUpdateTime': lastUpdateTime,
'categories': categories,
'explicit': explicit,
'episodeCount': episodeCount,
};
}
}
class PinepodsITunesPodcast {
final String wrapperType;
final String kind;
final int collectionId;
final int trackId;
final String artistName;
final String trackName;
final String collectionViewUrl;
final String feedUrl;
final String artworkUrl100;
final String releaseDate;
final List<String> genres;
final String collectionExplicitness;
final int? trackCount;
PinepodsITunesPodcast({
required this.wrapperType,
required this.kind,
required this.collectionId,
required this.trackId,
required this.artistName,
required this.trackName,
required this.collectionViewUrl,
required this.feedUrl,
required this.artworkUrl100,
required this.releaseDate,
required this.genres,
required this.collectionExplicitness,
this.trackCount,
});
factory PinepodsITunesPodcast.fromJson(Map<String, dynamic> json) {
return PinepodsITunesPodcast(
wrapperType: json['wrapperType'] as String? ?? '',
kind: json['kind'] as String? ?? '',
collectionId: json['collectionId'] as int? ?? 0,
trackId: json['trackId'] as int? ?? 0,
artistName: json['artistName'] as String? ?? '',
trackName: json['trackName'] as String? ?? '',
collectionViewUrl: json['collectionViewUrl'] as String? ?? '',
feedUrl: json['feedUrl'] as String? ?? '',
artworkUrl100: json['artworkUrl100'] as String? ?? '',
releaseDate: json['releaseDate'] as String? ?? '',
genres: json['genres'] != null
? List<String>.from(json['genres'] as List)
: [],
collectionExplicitness: json['collectionExplicitness'] as String? ?? '',
trackCount: json['trackCount'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'wrapperType': wrapperType,
'kind': kind,
'collectionId': collectionId,
'trackId': trackId,
'artistName': artistName,
'trackName': trackName,
'collectionViewUrl': collectionViewUrl,
'feedUrl': feedUrl,
'artworkUrl100': artworkUrl100,
'releaseDate': releaseDate,
'genres': genres,
'collectionExplicitness': collectionExplicitness,
'trackCount': trackCount,
};
}
}
class UnifiedPinepodsPodcast {
final int id;
final int indexId;
final String title;
final String url;
final String originalUrl;
final String link;
final String description;
final String author;
final String ownerName;
final String image;
final String artwork;
final int lastUpdateTime;
final Map<String, String>? categories;
final bool explicit;
final int episodeCount;
UnifiedPinepodsPodcast({
required this.id,
required this.indexId,
required this.title,
required this.url,
required this.originalUrl,
required this.link,
required this.description,
required this.author,
required this.ownerName,
required this.image,
required this.artwork,
required this.lastUpdateTime,
this.categories,
required this.explicit,
required this.episodeCount,
});
factory UnifiedPinepodsPodcast.fromJson(Map<String, dynamic> json) {
return UnifiedPinepodsPodcast(
id: json['id'] as int? ?? 0,
indexId: json['indexId'] as int? ?? 0,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
originalUrl: json['originalUrl'] as String? ?? '',
link: json['link'] as String? ?? '',
description: json['description'] as String? ?? '',
author: json['author'] as String? ?? '',
ownerName: json['ownerName'] as String? ?? '',
image: json['image'] as String? ?? '',
artwork: json['artwork'] as String? ?? '',
lastUpdateTime: json['lastUpdateTime'] as int? ?? 0,
categories: json['categories'] != null
? Map<String, String>.from(json['categories'] as Map)
: null,
explicit: json['explicit'] as bool? ?? false,
episodeCount: json['episodeCount'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'indexId': indexId,
'title': title,
'url': url,
'originalUrl': originalUrl,
'link': link,
'description': description,
'author': author,
'ownerName': ownerName,
'image': image,
'artwork': artwork,
'lastUpdateTime': lastUpdateTime,
'categories': categories,
'explicit': explicit,
'episodeCount': episodeCount,
};
}
factory UnifiedPinepodsPodcast.fromPodcast(PinepodsPodcast podcast) {
return UnifiedPinepodsPodcast(
id: 0, // Internal database ID - will be fetched when needed
indexId: podcast.id, // Podcast index ID
title: podcast.title,
url: podcast.url,
originalUrl: podcast.originalUrl,
author: podcast.author,
ownerName: podcast.ownerName,
description: podcast.description,
image: podcast.image,
link: podcast.link,
artwork: podcast.artwork,
lastUpdateTime: podcast.lastUpdateTime,
categories: podcast.categories,
explicit: podcast.explicit,
episodeCount: podcast.episodeCount,
);
}
factory UnifiedPinepodsPodcast.fromITunesPodcast(PinepodsITunesPodcast podcast) {
// Convert genres list to map
final Map<String, String> genreMap = {};
for (int i = 0; i < podcast.genres.length; i++) {
genreMap[i.toString()] = podcast.genres[i];
}
// Parse release date to timestamp
int timestamp = 0;
try {
final dateTime = DateTime.parse(podcast.releaseDate);
timestamp = dateTime.millisecondsSinceEpoch ~/ 1000;
} catch (e) {
// Default to 0 if parsing fails
}
return UnifiedPinepodsPodcast(
id: podcast.trackId,
indexId: 0,
title: podcast.trackName,
url: podcast.feedUrl,
originalUrl: podcast.feedUrl,
author: podcast.artistName,
ownerName: podcast.artistName,
description: 'Descriptions not provided by iTunes',
image: podcast.artworkUrl100,
link: podcast.collectionViewUrl,
artwork: podcast.artworkUrl100,
lastUpdateTime: timestamp,
categories: genreMap,
explicit: podcast.collectionExplicitness == 'explicit',
episodeCount: podcast.trackCount ?? 0,
);
}
}
enum SearchProvider {
podcastIndex,
itunes,
}
extension SearchProviderExtension on SearchProvider {
String get name {
switch (this) {
case SearchProvider.podcastIndex:
return 'Podcast Index';
case SearchProvider.itunes:
return 'iTunes';
}
}
String get value {
switch (this) {
case SearchProvider.podcastIndex:
return 'podcast_index';
case SearchProvider.itunes:
return 'itunes';
}
}
}

View File

@@ -0,0 +1,248 @@
// 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/core/extensions.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:podcast_search/podcast_search.dart' as search;
import 'episode.dart';
enum PodcastEpisodeFilter {
none(id: 0),
started(id: 1),
played(id: 2),
notPlayed(id: 3);
const PodcastEpisodeFilter({required this.id});
final int id;
}
enum PodcastEpisodeSort {
none(id: 0),
latestFirst(id: 1),
earliestFirst(id: 2),
alphabeticalAscending(id: 3),
alphabeticalDescending(id: 4);
const PodcastEpisodeSort({required this.id});
final int id;
}
/// A class that represents an instance of a podcast.
///
/// When persisted to disk this represents a podcast that is being followed.
class Podcast {
/// Database ID
int? id;
/// Unique identifier for podcast.
final String? guid;
/// The link to the podcast RSS feed.
final String url;
/// RSS link URL.
final String? link;
/// Podcast title.
final String title;
/// Podcast description. Can be either plain text or HTML.
final String? description;
/// URL to the full size artwork image.
final String? imageUrl;
/// URL for thumbnail version of artwork image. Not contained within
/// the RSS but may be calculated or provided within search results.
final String? thumbImageUrl;
/// Copyright owner of the podcast.
final String? copyright;
/// Zero or more funding links.
final List<Funding>? funding;
PodcastEpisodeFilter filter;
PodcastEpisodeSort sort;
/// Date and time user subscribed to the podcast.
DateTime? subscribedDate;
/// Date and time podcast was last updated/refreshed.
DateTime? _lastUpdated;
/// One or more episodes for this podcast.
List<Episode> episodes;
final List<Person>? persons;
bool newEpisodes;
bool updatedEpisodes = false;
Podcast({
required this.guid,
required String url,
required this.link,
required this.title,
this.id,
this.description,
String? imageUrl,
String? thumbImageUrl,
this.copyright,
this.subscribedDate,
this.funding,
this.filter = PodcastEpisodeFilter.none,
this.sort = PodcastEpisodeSort.none,
this.episodes = const <Episode>[],
this.newEpisodes = false,
this.persons,
DateTime? lastUpdated,
}) : url = url.forceHttps,
imageUrl = imageUrl?.forceHttps,
thumbImageUrl = thumbImageUrl?.forceHttps {
_lastUpdated = lastUpdated;
}
factory Podcast.fromUrl({required String url}) => Podcast(
url: url,
guid: '',
link: '',
title: '',
description: '',
thumbImageUrl: null,
imageUrl: null,
copyright: '',
funding: <Funding>[],
persons: <Person>[],
);
factory Podcast.fromSearchResultItem(search.Item item) => Podcast(
guid: item.guid ?? '',
url: item.feedUrl ?? '',
link: item.feedUrl,
title: item.trackName!,
description: '',
imageUrl: item.bestArtworkUrl ?? item.artworkUrl,
thumbImageUrl: item.thumbnailArtworkUrl,
funding: const <Funding>[],
copyright: item.artistName,
);
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'title': title,
'copyright': copyright ?? '',
'description': description ?? '',
'url': url,
'link': link ?? '',
'imageUrl': imageUrl ?? '',
'thumbImageUrl': thumbImageUrl ?? '',
'subscribedDate': subscribedDate?.millisecondsSinceEpoch.toString() ?? '',
'filter': filter.id,
'sort': sort.id,
'funding': (funding ?? <Funding>[]).map((funding) => funding.toMap()).toList(growable: false),
'person': (persons ?? <Person>[]).map((persons) => persons.toMap()).toList(growable: false),
'lastUpdated': _lastUpdated?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch,
};
}
static Podcast fromMap(int key, Map<String, dynamic> podcast) {
final sds = podcast['subscribedDate'] as String?;
final lus = podcast['lastUpdated'] as int?;
final funding = <Funding>[];
final persons = <Person>[];
var filter = PodcastEpisodeFilter.none;
var sort = PodcastEpisodeSort.none;
var sd = DateTime.now();
var lastUpdated = DateTime(1971, 1, 1);
if (sds != null && sds.isNotEmpty && int.tryParse(sds) != null) {
sd = DateTime.fromMillisecondsSinceEpoch(int.parse(sds));
}
if (lus != null) {
lastUpdated = DateTime.fromMillisecondsSinceEpoch(lus);
}
if (podcast['funding'] != null) {
for (var chapter in (podcast['funding'] as List)) {
if (chapter is Map<String, dynamic>) {
funding.add(Funding.fromMap(chapter));
}
}
}
if (podcast['persons'] != null) {
for (var person in (podcast['persons'] as List)) {
if (person is Map<String, dynamic>) {
persons.add(Person.fromMap(person));
}
}
}
if (podcast['filter'] != null) {
var filterValue = (podcast['filter'] as int);
filter = switch (filterValue) {
1 => PodcastEpisodeFilter.started,
2 => PodcastEpisodeFilter.played,
3 => PodcastEpisodeFilter.notPlayed,
_ => PodcastEpisodeFilter.none,
};
}
if (podcast['sort'] != null) {
var sortValue = (podcast['sort'] as int);
sort = switch (sortValue) {
1 => PodcastEpisodeSort.latestFirst,
2 => PodcastEpisodeSort.earliestFirst,
3 => PodcastEpisodeSort.alphabeticalAscending,
4 => PodcastEpisodeSort.alphabeticalDescending,
_ => PodcastEpisodeSort.none,
};
}
return Podcast(
id: key,
guid: podcast['guid'] as String,
link: podcast['link'] as String?,
title: podcast['title'] as String,
copyright: podcast['copyright'] as String?,
description: podcast['description'] as String?,
url: podcast['url'] as String,
imageUrl: podcast['imageUrl'] as String?,
thumbImageUrl: podcast['thumbImageUrl'] as String?,
filter: filter,
sort: sort,
funding: funding,
persons: persons,
subscribedDate: sd,
lastUpdated: lastUpdated,
);
}
bool get subscribed => id != null;
DateTime get lastUpdated => _lastUpdated ?? DateTime(1970, 1, 1);
set lastUpdated(DateTime value) {
_lastUpdated = value;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Podcast && runtimeType == other.runtimeType && guid == other.guid && url == other.url;
@override
int get hashCode => guid.hashCode ^ url.hashCode;
}

View File

@@ -0,0 +1,31 @@
// 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.
/// The current persistable queue.
class Queue {
List<String> guids = <String>[];
Queue({
required this.guids,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'q': guids,
};
}
static Queue fromMap(int key, Map<String, dynamic> guids) {
var g = guids['q'] as List<dynamic>?;
var result = <String>[];
if (g != null) {
result = g.map((dynamic e) => e.toString()).toList();
}
return Queue(
guids: result,
);
}
}

View File

@@ -0,0 +1,16 @@
// 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.
/// PinePods can support multiple search providers.
///
/// This class represents a provider.
class SearchProvider {
final String key;
final String name;
SearchProvider({
required this.key,
required this.name,
});
}

View File

@@ -0,0 +1,32 @@
// 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.
enum SleepType {
none,
time,
episode,
}
final class Sleep {
final SleepType type;
final Duration duration;
late DateTime endTime;
Sleep({
required this.type,
this.duration = const Duration(milliseconds: 0),
}) {
endTime = DateTime.now().add(duration);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Sleep && runtimeType == other.runtimeType && type == other.type && duration == other.duration;
@override
int get hashCode => type.hashCode ^ duration.hashCode;
Duration get timeRemaining => endTime.difference(DateTime.now());
}

View File

@@ -0,0 +1,213 @@
// 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/core/extensions.dart';
import 'package:flutter/foundation.dart';
enum TranscriptFormat {
json,
subrip,
html,
unsupported,
}
/// This class represents a Podcasting 2.0 transcript URL.
///
/// [docs](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript)
class TranscriptUrl {
final String url;
final TranscriptFormat type;
final String? language;
final String? rel;
final DateTime? lastUpdated;
TranscriptUrl({
required String url,
required this.type,
this.language = '',
this.rel = '',
this.lastUpdated,
}) : url = url.forceHttps;
Map<String, dynamic> toMap() {
var t = 0;
switch (type) {
case TranscriptFormat.subrip:
t = 0;
break;
case TranscriptFormat.json:
t = 1;
break;
case TranscriptFormat.html:
t = 2;
break;
case TranscriptFormat.unsupported:
t = 3;
break;
}
return <String, dynamic>{
'url': url,
'type': t,
'lang': language,
'rel': rel,
'lastUpdated': DateTime.now().millisecondsSinceEpoch,
};
}
static TranscriptUrl fromMap(Map<String, dynamic> transcript) {
var ts = transcript['type'] as int? ?? 2;
var t = TranscriptFormat.unsupported;
switch (ts) {
case 0:
t = TranscriptFormat.subrip;
break;
case 1:
t = TranscriptFormat.json;
break;
case 2:
t = TranscriptFormat.html;
break;
case 3:
t = TranscriptFormat.unsupported;
break;
}
return TranscriptUrl(
url: transcript['url'] as String,
language: transcript['lang'] as String?,
rel: transcript['rel'] as String?,
type: t,
lastUpdated: transcript['lastUpdated'] == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(transcript['lastUpdated'] as int),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TranscriptUrl &&
runtimeType == other.runtimeType &&
url == other.url &&
type == other.type &&
language == other.language &&
rel == other.rel;
@override
int get hashCode => url.hashCode ^ type.hashCode ^ language.hashCode ^ rel.hashCode;
}
/// This class represents a Podcasting 2.0 transcript container.
/// [docs](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript)
class Transcript {
int? id;
String? guid;
final List<Subtitle> subtitles;
DateTime? lastUpdated;
bool filtered;
Transcript({
this.id,
this.guid,
this.subtitles = const <Subtitle>[],
this.filtered = false,
this.lastUpdated,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'subtitles': (subtitles).map((subtitle) => subtitle.toMap()).toList(growable: false),
'lastUpdated': DateTime.now().millisecondsSinceEpoch,
};
}
static Transcript fromMap(int? key, Map<String, dynamic> transcript) {
var subtitles = <Subtitle>[];
if (transcript['subtitles'] != null) {
for (var subtitle in (transcript['subtitles'] as List)) {
if (subtitle is Map<String, dynamic>) {
subtitles.add(Subtitle.fromMap(subtitle));
}
}
}
return Transcript(
id: key,
guid: transcript['guid'] as String? ?? '',
subtitles: subtitles,
lastUpdated: transcript['lastUpdated'] == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(transcript['lastUpdated'] as int),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Transcript &&
runtimeType == other.runtimeType &&
guid == other.guid &&
listEquals(subtitles, other.subtitles);
@override
int get hashCode => guid.hashCode ^ subtitles.hashCode;
bool get transcriptAvailable => (subtitles.isNotEmpty || filtered);
}
/// Represents an individual line within a transcript.
class Subtitle {
final int index;
final Duration start;
Duration? end;
String? data;
String speaker;
Subtitle({
required this.index,
required this.start,
this.end,
this.data,
this.speaker = '',
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'i': index,
'start': start.inMilliseconds,
'end': end!.inMilliseconds,
'speaker': speaker,
'data': data,
};
}
static Subtitle fromMap(Map<String, dynamic> subtitle) {
return Subtitle(
index: subtitle['i'] as int? ?? 0,
start: Duration(milliseconds: subtitle['start'] as int? ?? 0),
end: Duration(milliseconds: subtitle['end'] as int? ?? 0),
speaker: subtitle['speaker'] as String? ?? '',
data: subtitle['data'] as String? ?? '',
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Subtitle &&
runtimeType == other.runtimeType &&
index == other.index &&
start == other.start &&
end == other.end &&
data == other.data &&
speaker == other.speaker;
@override
int get hashCode => index.hashCode ^ start.hashCode ^ end.hashCode ^ data.hashCode ^ speaker.hashCode;
}

View File

@@ -0,0 +1,91 @@
class UserStats {
final String userCreated;
final int podcastsPlayed;
final int timeListened;
final int podcastsAdded;
final int episodesSaved;
final int episodesDownloaded;
final String gpodderUrl;
final String podSyncType;
UserStats({
required this.userCreated,
required this.podcastsPlayed,
required this.timeListened,
required this.podcastsAdded,
required this.episodesSaved,
required this.episodesDownloaded,
required this.gpodderUrl,
required this.podSyncType,
});
factory UserStats.fromJson(Map<String, dynamic> json) {
return UserStats(
userCreated: json['UserCreated'] ?? '',
podcastsPlayed: json['PodcastsPlayed'] ?? 0,
timeListened: json['TimeListened'] ?? 0,
podcastsAdded: json['PodcastsAdded'] ?? 0,
episodesSaved: json['EpisodesSaved'] ?? 0,
episodesDownloaded: json['EpisodesDownloaded'] ?? 0,
gpodderUrl: json['GpodderUrl'] ?? '',
podSyncType: json['Pod_Sync_Type'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'UserCreated': userCreated,
'PodcastsPlayed': podcastsPlayed,
'TimeListened': timeListened,
'PodcastsAdded': podcastsAdded,
'EpisodesSaved': episodesSaved,
'EpisodesDownloaded': episodesDownloaded,
'GpodderUrl': gpodderUrl,
'Pod_Sync_Type': podSyncType,
};
}
// Format time listened from minutes to human readable
String get formattedTimeListened {
if (timeListened <= 0) return '0 minutes';
final hours = timeListened ~/ 60;
final minutes = timeListened % 60;
if (hours == 0) {
return '$minutes minute${minutes != 1 ? 's' : ''}';
} else if (minutes == 0) {
return '$hours hour${hours != 1 ? 's' : ''}';
} else {
return '$hours hour${hours != 1 ? 's' : ''} $minutes minute${minutes != 1 ? 's' : ''}';
}
}
// Format user created date
String get formattedUserCreated {
try {
final date = DateTime.parse(userCreated);
return '${date.day}/${date.month}/${date.year}';
} catch (e) {
return userCreated;
}
}
// Get sync status description
String get syncStatusDescription {
switch (podSyncType.toLowerCase()) {
case 'none':
return 'Not Syncing';
case 'gpodder':
if (gpodderUrl == 'http://localhost:8042') {
return 'Internal gpodder';
} else {
return 'External gpodder';
}
case 'nextcloud':
return 'Nextcloud';
default:
return 'Unknown sync type';
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that looks up messages for specific locales by
// delegating to the appropriate library.
// @dart=2.12
export 'messages_all_locales.dart'
show initializeMessages;

View File

@@ -0,0 +1,73 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that looks up messages for specific locales by
// delegating to the appropriate library.
// @dart=2.12
// Ignore issues from commonly used lints in this file.
// ignore_for_file:implementation_imports, file_names
// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering
// ignore_for_file:argument_type_not_assignable, invalid_assignment
// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases
// ignore_for_file:comment_references
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
import 'package:intl/src/intl_helpers.dart';
import 'messages_de.dart' as messages_de;
import 'messages_en.dart' as messages_en;
import 'messages_it.dart' as messages_it;
import 'messages_messages.dart' as messages_messages;
typedef Future<dynamic> LibraryLoader();
Map<String, LibraryLoader> _deferredLibraries = {
'de': () => Future.value(null),
'en': () => Future.value(null),
'it': () => Future.value(null),
'messages': () => Future.value(null),
};
MessageLookupByLibrary? _findExact(String localeName) {
switch (localeName) {
case 'de':
return messages_de.messages;
case 'en':
return messages_en.messages;
case 'it':
return messages_it.messages;
case 'messages':
return messages_messages.messages;
default:
return null;
}
}
/// User programs should call this before using [localeName] for messages.
Future<bool> initializeMessages(String? localeName) async {
var availableLocale = Intl.verifiedLocale(
localeName,
(locale) => _deferredLibraries[locale] != null,
onFailure: (_) => null);
if (availableLocale == null) {
return Future.value(false);
}
var lib = _deferredLibraries[availableLocale];
await (lib == null ? Future.value(false) : lib());
initializeInternalMessageLookup(() => CompositeMessageLookup());
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
return Future.value(true);
}
bool _messagesExistFor(String locale) {
try {
return _findExact(locale) != null;
} catch (e) {
return false;
}
}
MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) {
var actualLocale = Intl.verifiedLocale(locale, _messagesExistFor,
onFailure: (_) => null);
if (actualLocale == null) return null;
return _findExact(actualLocale);
}

View File

@@ -0,0 +1,364 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a de locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// @dart=2.12
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = MessageLookup();
typedef String? MessageIfAbsent(String? messageStr, List<Object>? args);
class MessageLookup extends MessageLookupByLibrary {
@override
String get localeName => 'de';
static m0(minutes) => "${minutes} Minuten";
@override
final Map<String, dynamic> messages =
_notInlinedMessages(_notInlinedMessages);
static Map<String, dynamic> _notInlinedMessages(_) => {
'about_label': MessageLookupByLibrary.simpleMessage('Über'),
'add_rss_feed_option':
MessageLookupByLibrary.simpleMessage('RSS-Feed hinzufügen'),
'app_title':
MessageLookupByLibrary.simpleMessage('Pinepods Podcast Client'),
'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'),
'audio_effect_trim_silence_label':
MessageLookupByLibrary.simpleMessage('Stille Trimmen'),
'audio_effect_volume_boost_label':
MessageLookupByLibrary.simpleMessage('Lautstärke-Boost'),
'audio_settings_playback_speed_label':
MessageLookupByLibrary.simpleMessage('Wiedergabe Schnelligkeit'),
'auto_scroll_transcript_label':
MessageLookupByLibrary.simpleMessage('Follow the transcript'),
'cancel_button_label':
MessageLookupByLibrary.simpleMessage('Stornieren'),
'cancel_download_button_label':
MessageLookupByLibrary.simpleMessage('Download abbrechen'),
'cancel_option_label':
MessageLookupByLibrary.simpleMessage('Stirbuereb'),
'chapters_label': MessageLookupByLibrary.simpleMessage('Kapitel'),
'clear_queue_button_label':
MessageLookupByLibrary.simpleMessage('WARTESCHLANGE LÖSCHEN'),
'clear_search_button_label':
MessageLookupByLibrary.simpleMessage('Suchtext löschen'),
'close_button_label': MessageLookupByLibrary.simpleMessage('Schließen'),
'consent_message': MessageLookupByLibrary.simpleMessage(
'Über diesen Finanzierungslink gelangen Sie zu einer externen Website, auf der Sie die Show direkt unterstützen können. Links werden von den Podcast-Autoren bereitgestellt und nicht von Pinepods kontrolliert.'),
'continue_button_label':
MessageLookupByLibrary.simpleMessage('Fortsetzen'),
'delete_button_label': MessageLookupByLibrary.simpleMessage('Löschen'),
'delete_episode_button_label':
MessageLookupByLibrary.simpleMessage('Download -Episode löschen'),
'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage(
'Sind Sie sicher, dass Sie diese Episode löschen möchten?'),
'delete_episode_title':
MessageLookupByLibrary.simpleMessage('Folge löschen'),
'delete_label': MessageLookupByLibrary.simpleMessage('Löschen'),
'discover': MessageLookupByLibrary.simpleMessage('Entdecken'),
'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage(
'<Alle>,Künste,Geschäft,Komödie,Ausbildung,Fiktion,Regierung,Gesundheit & Fitness,Geschichte,Kinder & Familie,Freizeit,Musik,Die Nachrichten,Religion & Spiritualität,Wissenschaft,Gesellschaft & Kultur,Sport,Fernsehen & Film,Technologie,Echte Kriminalität'),
'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage(
'<Alle>,After-Shows,Alternative,Tiere,Animation,Kunst,Astronomie,Automobil,Luftfahrt,Baseball,Basketball,Schönheit,Bücher,Buddhismus,Geschäft,Karriere,Chemie,Christentum,Klima,Komödie,Kommentar,Kurse,Kunsthandwerk,Kricket,Kryptowährung,Kultur,Täglich,Design,Dokumentarfilm,Theater,Erde,Ausbildung,Unterhaltung,Unternehmerschaft,Familie,Fantasie,Mode,Fiktion,Film,Fitness,Essen,Fußball,Spiele,Garten,Golf,Regierung,Gesundheit,Hinduismus,Geschichte,Hobbys,Eishockey,Heim,Wieman,Improvisieren,Vorstellungsgespräche,Investieren,Islam,Zeitschriften,Judentum,Kinder,Sprache,Lernen,Freizeit,Leben,Management,Manga,Marketing,Mathematik,Medizin,geistig,Musik,Natürlich,Natur,Nachricht,Gemeinnützig,Ernährung,Erziehung,Aufführung,Persönlich,Haustiere,Philosophie,Physik,Setzt,Politik,Beziehungen,Religion,Bewertungen,Rollenspiel,Rugby,Betrieb,Wissenschaft,Selbstverbesserung,Sexualität,Fußball,Sozial,Gesellschaft,Spiritualität,Sport,Aufstehen,Geschichten,Baden,FERNSEHER,Tischplatte,Technologie,Tennis,Reisen,EchteKriminalität,Videospiele,Visuell,Volleyball,Wetter,Wildnis,Ringen'),
'download_episode_button_label':
MessageLookupByLibrary.simpleMessage('Folge herunterladen'),
'downloads': MessageLookupByLibrary.simpleMessage('Herunterladen'),
'empty_queue_message':
MessageLookupByLibrary.simpleMessage('Ihre Warteschlange ist leer'),
'episode_details_button_label': MessageLookupByLibrary.simpleMessage(
'Episodeninformationen anzeigen'),
'episode_filter_clear_filters_button_label':
MessageLookupByLibrary.simpleMessage('Filter zurücksetzen'),
'episode_filter_no_episodes_title_description':
MessageLookupByLibrary.simpleMessage(
'Dieser Podcast hat keine Episoden, die Ihren Suchkriterien und Filtern entsprechen'),
'episode_filter_no_episodes_title_label':
MessageLookupByLibrary.simpleMessage('Keine Episoden Gefunden'),
'episode_filter_none_label':
MessageLookupByLibrary.simpleMessage('Keiner'),
'episode_filter_played_label':
MessageLookupByLibrary.simpleMessage('Gespielt'),
'episode_filter_semantic_label':
MessageLookupByLibrary.simpleMessage('Episoden filtern'),
'episode_filter_started_label':
MessageLookupByLibrary.simpleMessage('Gestartet'),
'episode_filter_unplayed_label':
MessageLookupByLibrary.simpleMessage('Nicht gespielt'),
'episode_label': MessageLookupByLibrary.simpleMessage('Episode'),
'episode_sort_alphabetical_ascending_label':
MessageLookupByLibrary.simpleMessage('Alphabetisch von A bis Z'),
'episode_sort_alphabetical_descending_label':
MessageLookupByLibrary.simpleMessage('Alphabetisch von Z bis A'),
'episode_sort_earliest_first_label':
MessageLookupByLibrary.simpleMessage('Das Älteste zuerst'),
'episode_sort_latest_first_label':
MessageLookupByLibrary.simpleMessage('Das Neueste zuerst'),
'episode_sort_none_label':
MessageLookupByLibrary.simpleMessage('Standard'),
'episode_sort_semantic_label':
MessageLookupByLibrary.simpleMessage('Episoden sortieren'),
'error_no_connection': MessageLookupByLibrary.simpleMessage(
'Episode kann nicht abgespielt werden. Überprüfen Sie bitte Ihre Verbindung und versuchen Sie es erneut.'),
'error_playback_fail': MessageLookupByLibrary.simpleMessage(
'Während der Wiedergabe ist ein unerwarteter Fehler aufgetreten. Überprüfen Sie bitte Ihre Verbindung und versuchen Sie es erneut.'),
'fast_forward_button_label': MessageLookupByLibrary.simpleMessage(
'30 Sekunden schneller Vorlauf'),
'feedback_menu_item_label':
MessageLookupByLibrary.simpleMessage('Rückmeldung'),
'go_back_button_label':
MessageLookupByLibrary.simpleMessage('Geh Zurück'),
'label_opml_importing':
MessageLookupByLibrary.simpleMessage('Importieren'),
'layout_label': MessageLookupByLibrary.simpleMessage('Layout'),
'library': MessageLookupByLibrary.simpleMessage('Bibliothek'),
'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage(
'Markieren Sie alle Folgen als nicht abgespielt'),
'mark_episodes_played_label': MessageLookupByLibrary.simpleMessage(
'Markieren Sie alle Episoden als abgespielt'),
'mark_played_label':
MessageLookupByLibrary.simpleMessage('Markieren gespielt'),
'mark_unplayed_label':
MessageLookupByLibrary.simpleMessage('Markieren nicht abgespielt'),
'minimise_player_window_button_label':
MessageLookupByLibrary.simpleMessage(
'Wiedergabebildschirm minimieren'),
'more_label': MessageLookupByLibrary.simpleMessage('Mehr'),
'new_episodes_label':
MessageLookupByLibrary.simpleMessage('Neue Folgen sind verfügbar'),
'new_episodes_view_now_label':
MessageLookupByLibrary.simpleMessage('JETZT ANZEIGEN'),
'no_downloads_message': MessageLookupByLibrary.simpleMessage(
'Sie haben keine Episoden heruntergeladen'),
'no_podcast_details_message': MessageLookupByLibrary.simpleMessage(
'Podcast-Episoden konnten nicht geladen werden. Bitte überprüfen Sie Ihre Verbindung.'),
'no_search_results_message':
MessageLookupByLibrary.simpleMessage('Keine Podcasts gefunden'),
'no_subscriptions_message': MessageLookupByLibrary.simpleMessage(
'Tippen Sie unten auf die Schaltfläche „Entdecken“ oder verwenden Sie die Suchleiste oben, um Ihren ersten Podcast zu finden'),
'no_transcript_available_label': MessageLookupByLibrary.simpleMessage(
'Für diesen Podcast ist kein Transkript verfügbar'),
'notes_label': MessageLookupByLibrary.simpleMessage('Notizen'),
'now_playing_episode_position':
MessageLookupByLibrary.simpleMessage('Episodenposition'),
'now_playing_episode_time_remaining':
MessageLookupByLibrary.simpleMessage('Verbleibende Zeit'),
'now_playing_queue_label':
MessageLookupByLibrary.simpleMessage('Jetzt Spielen'),
'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'),
'open_show_website_label':
MessageLookupByLibrary.simpleMessage('Show-Website öffnen'),
'opml_export_button_label':
MessageLookupByLibrary.simpleMessage('Export'),
'opml_import_button_label':
MessageLookupByLibrary.simpleMessage('Importieren'),
'opml_import_export_label':
MessageLookupByLibrary.simpleMessage('OPML Importieren/Export'),
'pause_button_label':
MessageLookupByLibrary.simpleMessage('Folge pausieren'),
'play_button_label':
MessageLookupByLibrary.simpleMessage('Folge abspielen'),
'play_download_button_label': MessageLookupByLibrary.simpleMessage(
'Heruntergeladene Episode abspielen'),
'playback_speed_label': MessageLookupByLibrary.simpleMessage(
'Stellen Sie die Wiedergabegeschwindigkeit ein'),
'podcast_funding_dialog_header':
MessageLookupByLibrary.simpleMessage('Podcast-Finanzierung'),
'podcast_options_overflow_menu_semantic_label':
MessageLookupByLibrary.simpleMessage('Optionsmenü'),
'queue_add_label': MessageLookupByLibrary.simpleMessage('Addieren'),
'queue_clear_button_label':
MessageLookupByLibrary.simpleMessage('Klar'),
'queue_clear_label': MessageLookupByLibrary.simpleMessage(
'Möchten Sie die Warteschlange wirklich löschen?'),
'queue_clear_label_title':
MessageLookupByLibrary.simpleMessage('Warteschlange löschen'),
'queue_remove_label': MessageLookupByLibrary.simpleMessage('Entfernen'),
'refresh_feed_label': MessageLookupByLibrary.simpleMessage(
'Holen Sie sich neue Episoden'),
'resume_button_label':
MessageLookupByLibrary.simpleMessage('Folge fortsetzen'),
'rewind_button_label':
MessageLookupByLibrary.simpleMessage('10 Sekunden zurückspulen'),
'scrim_episode_details_selector':
MessageLookupByLibrary.simpleMessage('Episodendetails schließen'),
'scrim_episode_filter_selector':
MessageLookupByLibrary.simpleMessage('Episodenfilter schließen'),
'scrim_episode_sort_selector': MessageLookupByLibrary.simpleMessage(
'Episodensortierung schließen'),
'scrim_layout_selector':
MessageLookupByLibrary.simpleMessage('Layout-Auswahl schließen'),
'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage(
'Auswahl des Sleep-Timers schließen'),
'scrim_speed_selector': MessageLookupByLibrary.simpleMessage(
'Auswahl der Wiedergabegeschwindigkeit schließen'),
'search_back_button_label':
MessageLookupByLibrary.simpleMessage('Zurück'),
'search_button_label': MessageLookupByLibrary.simpleMessage('Suche'),
'search_episodes_label':
MessageLookupByLibrary.simpleMessage('Folgen suchen'),
'search_for_podcasts_hint':
MessageLookupByLibrary.simpleMessage('Suche nach Podcasts'),
'search_provider_label':
MessageLookupByLibrary.simpleMessage('Suchmaschine'),
'search_transcript_label':
MessageLookupByLibrary.simpleMessage('Transkript suchen'),
'semantic_announce_searching':
MessageLookupByLibrary.simpleMessage('Suchen, bitte warten.'),
'semantic_chapter_link_label':
MessageLookupByLibrary.simpleMessage('Weblink zum Kapitel'),
'semantic_current_chapter_label':
MessageLookupByLibrary.simpleMessage('Aktuelles Kapitel'),
'semantic_current_value_label':
MessageLookupByLibrary.simpleMessage('Aktueller Wert'),
'semantic_playing_options_collapse_label':
MessageLookupByLibrary.simpleMessage(
'Schließen Sie den Schieberegler für die Wiedergabeoptionen'),
'semantic_playing_options_expand_label':
MessageLookupByLibrary.simpleMessage(
'Öffnen Sie den Schieberegler für die Wiedergabeoptionen'),
'semantic_podcast_artwork_label':
MessageLookupByLibrary.simpleMessage('Podcast-Artwork'),
'semantics_add_to_queue': MessageLookupByLibrary.simpleMessage(
'Fügen Sie die Episode zur Warteschlange hinzu'),
'semantics_collapse_podcast_description':
MessageLookupByLibrary.simpleMessage(
'Collapse Podcast Beschreibung'),
'semantics_decrease_playback_speed':
MessageLookupByLibrary.simpleMessage(
'Verringern Sie die Wiedergabegeschwindigkeit'),
'semantics_episode_tile_collapsed': MessageLookupByLibrary.simpleMessage(
'Episodenlistenelement. Zeigt Bild, Zusammenfassung und Hauptsteuerelemente.'),
'semantics_episode_tile_collapsed_hint':
MessageLookupByLibrary.simpleMessage(
'erweitern und weitere Details und zusätzliche Optionen anzeigen'),
'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage(
'Episodenlistenelement. Beschreibung, Hauptsteuerelemente und zusätzliche Steuerelemente werden angezeigt.'),
'semantics_episode_tile_expanded_hint':
MessageLookupByLibrary.simpleMessage(
'Reduzieren und Zusammenfassung anzeigen, Download- und Wiedergabesteuerung'),
'semantics_expand_podcast_description':
MessageLookupByLibrary.simpleMessage(
'Erweitern Sie die Beschreibung der Podcast'),
'semantics_increase_playback_speed':
MessageLookupByLibrary.simpleMessage(
'Erhöhen Sie die Wiedergabegeschwindigkeit'),
'semantics_layout_option_compact_grid':
MessageLookupByLibrary.simpleMessage('Kompaktes Rasterlayout'),
'semantics_layout_option_grid':
MessageLookupByLibrary.simpleMessage('Gitterstruktur'),
'semantics_layout_option_list':
MessageLookupByLibrary.simpleMessage('Listenlayout'),
'semantics_main_player_header':
MessageLookupByLibrary.simpleMessage('Hauptfenster des Players'),
'semantics_mark_episode_played':
MessageLookupByLibrary.simpleMessage('Mark Episode as played'),
'semantics_mark_episode_unplayed':
MessageLookupByLibrary.simpleMessage('Mark Episode as un-played'),
'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage(
'Mini-Player. Wischen Sie nach rechts, um die Schaltfläche „Wiedergabe/Pause“ anzuzeigen. Aktivieren, um das Hauptfenster des Players zu öffnen'),
'semantics_play_pause_toggle': MessageLookupByLibrary.simpleMessage(
'Umschalten zwischen Wiedergabe und Pause'),
'semantics_podcast_details_header':
MessageLookupByLibrary.simpleMessage(
'Podcast-Details und Episodenseite'),
'semantics_remove_from_queue': MessageLookupByLibrary.simpleMessage(
'Entfernen Sie die Episode aus der Warteschlange'),
'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage(
'Vollbild-Player-Modus beim Episodenstart'),
'settings_auto_update_episodes': MessageLookupByLibrary.simpleMessage(
'Folgen automatisch aktualisieren'),
'settings_auto_update_episodes_10min':
MessageLookupByLibrary.simpleMessage(
'10 Minuten seit dem letzten Update'),
'settings_auto_update_episodes_12hour':
MessageLookupByLibrary.simpleMessage(
'12 Stunden seit dem letzten Update'),
'settings_auto_update_episodes_1hour':
MessageLookupByLibrary.simpleMessage(
'1 Stunde seit dem letzten Update'),
'settings_auto_update_episodes_30min':
MessageLookupByLibrary.simpleMessage(
'30 Minuten seit dem letzten Update'),
'settings_auto_update_episodes_3hour':
MessageLookupByLibrary.simpleMessage(
'3 Stunden seit dem letzten Update'),
'settings_auto_update_episodes_6hour':
MessageLookupByLibrary.simpleMessage(
'6 Stunden seit dem letzten Update'),
'settings_auto_update_episodes_always':
MessageLookupByLibrary.simpleMessage('Immer'),
'settings_auto_update_episodes_heading':
MessageLookupByLibrary.simpleMessage(
'Folgen in der Detailansicht aktualisieren, nachdem'),
'settings_auto_update_episodes_never':
MessageLookupByLibrary.simpleMessage('Noch nie'),
'settings_data_divider_label':
MessageLookupByLibrary.simpleMessage('DATEN'),
'settings_delete_played_label': MessageLookupByLibrary.simpleMessage(
'Heruntergeladene Episoden nach dem Abspielen löschen'),
'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage(
'Episoden auf SD-Karte herunterladen'),
'settings_download_switch_card': MessageLookupByLibrary.simpleMessage(
'Neue Downloads werden auf der SD-Karte gespeichert. Bestehende Downloads bleiben im internen Speicher.'),
'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage(
'Neue Downloads werden im internen Speicher gespeichert. Bestehende Downloads verbleiben auf der SD-Karte.'),
'settings_download_switch_label':
MessageLookupByLibrary.simpleMessage('Speicherort ändern'),
'settings_episodes_divider_label':
MessageLookupByLibrary.simpleMessage('EPISODEN'),
'settings_export_opml':
MessageLookupByLibrary.simpleMessage('OPML exportieren'),
'settings_import_opml':
MessageLookupByLibrary.simpleMessage('OPML importieren'),
'settings_label': MessageLookupByLibrary.simpleMessage('Einstellungen'),
'settings_mark_deleted_played_label':
MessageLookupByLibrary.simpleMessage(
'Markieren Sie gelöschte Episoden als abgespielt'),
'settings_personalisation_divider_label':
MessageLookupByLibrary.simpleMessage('PERSONALISIERUNG'),
'settings_playback_divider_label':
MessageLookupByLibrary.simpleMessage('WIEDERGABE'),
'settings_theme_switch_label':
MessageLookupByLibrary.simpleMessage('Dark theme'),
'show_notes_label':
MessageLookupByLibrary.simpleMessage('Notizen anzeigen'),
'sleep_episode_label':
MessageLookupByLibrary.simpleMessage('Ende der Folge'),
'sleep_minute_label': m0,
'sleep_off_label': MessageLookupByLibrary.simpleMessage('Aus'),
'sleep_timer_label':
MessageLookupByLibrary.simpleMessage('Sleep-Timer'),
'stop_download_button_label':
MessageLookupByLibrary.simpleMessage('Halt'),
'stop_download_confirmation': MessageLookupByLibrary.simpleMessage(
'Möchten Sie diesen Download wirklich beenden und die Episode löschen?'),
'stop_download_title':
MessageLookupByLibrary.simpleMessage('Stop Download'),
'subscribe_button_label':
MessageLookupByLibrary.simpleMessage('Folgen'),
'subscribe_label': MessageLookupByLibrary.simpleMessage('Folgen'),
'transcript_label': MessageLookupByLibrary.simpleMessage('Transkript'),
'transcript_why_not_label':
MessageLookupByLibrary.simpleMessage('Warum nicht?'),
'transcript_why_not_url': MessageLookupByLibrary.simpleMessage(
'https://www.pinepods.online/docs/Features/Transcript'),
'unsubscribe_button_label':
MessageLookupByLibrary.simpleMessage('Entfolgen'),
'unsubscribe_label':
MessageLookupByLibrary.simpleMessage('Nicht mehr folgen'),
'unsubscribe_message': MessageLookupByLibrary.simpleMessage(
'Wenn Sie nicht mehr folgen, werden alle heruntergeladenen Folgen dieses Podcasts gelöscht.'),
'up_next_queue_label':
MessageLookupByLibrary.simpleMessage('Als nächstes')
};
}

View File

@@ -0,0 +1,350 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a en locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// @dart=2.12
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = MessageLookup();
typedef String? MessageIfAbsent(String? messageStr, List<Object>? args);
class MessageLookup extends MessageLookupByLibrary {
@override
String get localeName => 'en';
static m0(minutes) => "${minutes} minutes";
@override
final Map<String, dynamic> messages =
_notInlinedMessages(_notInlinedMessages);
static Map<String, dynamic> _notInlinedMessages(_) => {
'about_label': MessageLookupByLibrary.simpleMessage('About'),
'add_rss_feed_option':
MessageLookupByLibrary.simpleMessage('Add RSS Feed'),
'app_title':
MessageLookupByLibrary.simpleMessage('Pinepods Podcast Client'),
'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'),
'audio_effect_trim_silence_label':
MessageLookupByLibrary.simpleMessage('Trim Silence'),
'audio_effect_volume_boost_label':
MessageLookupByLibrary.simpleMessage('Volume Boost'),
'audio_settings_playback_speed_label':
MessageLookupByLibrary.simpleMessage('Playback Speed'),
'auto_scroll_transcript_label':
MessageLookupByLibrary.simpleMessage('Follow transcript'),
'cancel_button_label': MessageLookupByLibrary.simpleMessage('Cancel'),
'cancel_download_button_label':
MessageLookupByLibrary.simpleMessage('Cancel download'),
'cancel_option_label': MessageLookupByLibrary.simpleMessage('Cancel'),
'chapters_label': MessageLookupByLibrary.simpleMessage('Chapters'),
'clear_queue_button_label':
MessageLookupByLibrary.simpleMessage('CLEAR QUEUE'),
'clear_search_button_label':
MessageLookupByLibrary.simpleMessage('Clear search text'),
'close_button_label': MessageLookupByLibrary.simpleMessage('Close'),
'consent_message': MessageLookupByLibrary.simpleMessage(
'This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by Pinepods.'),
'continue_button_label':
MessageLookupByLibrary.simpleMessage('Continue'),
'delete_button_label': MessageLookupByLibrary.simpleMessage('Delete'),
'delete_episode_button_label':
MessageLookupByLibrary.simpleMessage('Delete downloaded episode'),
'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage(
'Are you sure you wish to delete this episode?'),
'delete_episode_title':
MessageLookupByLibrary.simpleMessage('Delete Episode'),
'delete_label': MessageLookupByLibrary.simpleMessage('Delete'),
'discover': MessageLookupByLibrary.simpleMessage('Discover'),
'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage(
'<All>,Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime'),
'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage(
'<All>,After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling'),
'download_episode_button_label':
MessageLookupByLibrary.simpleMessage('Download episode'),
'downloads': MessageLookupByLibrary.simpleMessage('Downloads'),
'empty_queue_message':
MessageLookupByLibrary.simpleMessage('Your queue is empty'),
'episode_details_button_label':
MessageLookupByLibrary.simpleMessage('Show episode information'),
'episode_filter_clear_filters_button_label':
MessageLookupByLibrary.simpleMessage('Clear Filters'),
'episode_filter_no_episodes_title_description':
MessageLookupByLibrary.simpleMessage(
'This podcast has no episodes matching your search criteria and filter'),
'episode_filter_no_episodes_title_label':
MessageLookupByLibrary.simpleMessage('No Episodes Found'),
'episode_filter_none_label':
MessageLookupByLibrary.simpleMessage('None'),
'episode_filter_played_label':
MessageLookupByLibrary.simpleMessage('Played'),
'episode_filter_semantic_label':
MessageLookupByLibrary.simpleMessage('Filter episodes'),
'episode_filter_started_label':
MessageLookupByLibrary.simpleMessage('Started'),
'episode_filter_unplayed_label':
MessageLookupByLibrary.simpleMessage('Unplayed'),
'episode_label': MessageLookupByLibrary.simpleMessage('Episode'),
'episode_sort_alphabetical_ascending_label':
MessageLookupByLibrary.simpleMessage('Alphabetical A-Z'),
'episode_sort_alphabetical_descending_label':
MessageLookupByLibrary.simpleMessage('Alphabetical Z-A'),
'episode_sort_earliest_first_label':
MessageLookupByLibrary.simpleMessage('Earliest first'),
'episode_sort_latest_first_label':
MessageLookupByLibrary.simpleMessage('Latest first'),
'episode_sort_none_label':
MessageLookupByLibrary.simpleMessage('Default'),
'episode_sort_semantic_label':
MessageLookupByLibrary.simpleMessage('Sort episodes'),
'error_no_connection': MessageLookupByLibrary.simpleMessage(
'Unable to play episode. Please check your connection and try again.'),
'error_playback_fail': MessageLookupByLibrary.simpleMessage(
'An unexpected error occurred during playback. Please check your connection and try again.'),
'fast_forward_button_label': MessageLookupByLibrary.simpleMessage(
'Fast-forward episode 30 seconds'),
'feedback_menu_item_label':
MessageLookupByLibrary.simpleMessage('Feedback'),
'go_back_button_label': MessageLookupByLibrary.simpleMessage('Go Back'),
'label_opml_importing':
MessageLookupByLibrary.simpleMessage('Importing'),
'layout_label': MessageLookupByLibrary.simpleMessage('Layout'),
'library': MessageLookupByLibrary.simpleMessage('Library'),
'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage(
'Mark all episodes as not played'),
'mark_episodes_played_label':
MessageLookupByLibrary.simpleMessage('Mark all episodes as played'),
'mark_played_label':
MessageLookupByLibrary.simpleMessage('Mark Played'),
'mark_unplayed_label':
MessageLookupByLibrary.simpleMessage('Mark Unplayed'),
'minimise_player_window_button_label':
MessageLookupByLibrary.simpleMessage('Minimise player window'),
'more_label': MessageLookupByLibrary.simpleMessage('More'),
'new_episodes_label':
MessageLookupByLibrary.simpleMessage('New episodes are available'),
'new_episodes_view_now_label':
MessageLookupByLibrary.simpleMessage('VIEW NOW'),
'no_downloads_message': MessageLookupByLibrary.simpleMessage(
'You do not have any downloaded episodes'),
'no_podcast_details_message': MessageLookupByLibrary.simpleMessage(
'Could not load podcast episodes. Please check your connection.'),
'no_search_results_message':
MessageLookupByLibrary.simpleMessage('No podcasts found'),
'no_subscriptions_message': MessageLookupByLibrary.simpleMessage(
'Head to Settings to Connect a Pinepods Server if you haven\'t yet!'),
'no_transcript_available_label': MessageLookupByLibrary.simpleMessage(
'A transcript is not available for this podcast'),
'notes_label': MessageLookupByLibrary.simpleMessage('Description'),
'now_playing_episode_position':
MessageLookupByLibrary.simpleMessage('Episode position'),
'now_playing_episode_time_remaining':
MessageLookupByLibrary.simpleMessage('Time remaining'),
'now_playing_queue_label':
MessageLookupByLibrary.simpleMessage('Now Playing'),
'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'),
'open_show_website_label':
MessageLookupByLibrary.simpleMessage('Open show website'),
'opml_export_button_label':
MessageLookupByLibrary.simpleMessage('Export'),
'opml_import_button_label':
MessageLookupByLibrary.simpleMessage('Import'),
'opml_import_export_label':
MessageLookupByLibrary.simpleMessage('OPML Import/Export'),
'pause_button_label':
MessageLookupByLibrary.simpleMessage('Pause episode'),
'play_button_label':
MessageLookupByLibrary.simpleMessage('Play episode'),
'play_download_button_label':
MessageLookupByLibrary.simpleMessage('Play downloaded episode'),
'playback_speed_label':
MessageLookupByLibrary.simpleMessage('Playback speed'),
'podcast_funding_dialog_header':
MessageLookupByLibrary.simpleMessage('Podcast Funding'),
'podcast_options_overflow_menu_semantic_label':
MessageLookupByLibrary.simpleMessage('Options menu'),
'queue_add_label': MessageLookupByLibrary.simpleMessage('Add'),
'queue_clear_button_label':
MessageLookupByLibrary.simpleMessage('Clear'),
'queue_clear_label': MessageLookupByLibrary.simpleMessage(
'Are you sure you wish to clear the queue?'),
'queue_clear_label_title':
MessageLookupByLibrary.simpleMessage('Clear Queue'),
'queue_remove_label': MessageLookupByLibrary.simpleMessage('Remove'),
'refresh_feed_label':
MessageLookupByLibrary.simpleMessage('Refresh episodes'),
'resume_button_label':
MessageLookupByLibrary.simpleMessage('Resume episode'),
'rewind_button_label':
MessageLookupByLibrary.simpleMessage('Rewind episode 10 seconds'),
'scrim_episode_details_selector':
MessageLookupByLibrary.simpleMessage('Dismiss episode details'),
'scrim_episode_filter_selector':
MessageLookupByLibrary.simpleMessage('Dismiss episode filter'),
'scrim_episode_sort_selector':
MessageLookupByLibrary.simpleMessage('Dismiss episode sort'),
'scrim_layout_selector':
MessageLookupByLibrary.simpleMessage('Dismiss layout selector'),
'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage(
'Dismiss sleep timer selector'),
'scrim_speed_selector': MessageLookupByLibrary.simpleMessage(
'Dismiss playback speed selector'),
'search_back_button_label':
MessageLookupByLibrary.simpleMessage('Back'),
'search_button_label': MessageLookupByLibrary.simpleMessage('Search'),
'search_episodes_label':
MessageLookupByLibrary.simpleMessage('Search episodes'),
'search_for_podcasts_hint':
MessageLookupByLibrary.simpleMessage('Search for podcasts'),
'search_provider_label':
MessageLookupByLibrary.simpleMessage('Search provider'),
'search_transcript_label':
MessageLookupByLibrary.simpleMessage('Search transcript'),
'semantic_announce_searching':
MessageLookupByLibrary.simpleMessage('Searching, please wait.'),
'semantic_chapter_link_label':
MessageLookupByLibrary.simpleMessage('Chapter web link'),
'semantic_current_chapter_label':
MessageLookupByLibrary.simpleMessage('Current chapter'),
'semantic_current_value_label':
MessageLookupByLibrary.simpleMessage('Current value'),
'semantic_playing_options_collapse_label':
MessageLookupByLibrary.simpleMessage(
'Close playing options slider'),
'semantic_playing_options_expand_label':
MessageLookupByLibrary.simpleMessage('Open playing options slider'),
'semantic_podcast_artwork_label':
MessageLookupByLibrary.simpleMessage('Podcast artwork'),
'semantics_add_to_queue':
MessageLookupByLibrary.simpleMessage('Add episode to queue'),
'semantics_collapse_podcast_description':
MessageLookupByLibrary.simpleMessage(
'Collapse podcast description'),
'semantics_decrease_playback_speed':
MessageLookupByLibrary.simpleMessage('Decrease playback speed'),
'semantics_episode_tile_collapsed':
MessageLookupByLibrary.simpleMessage(
'Episode list item. Showing image, summary and main controls.'),
'semantics_episode_tile_collapsed_hint':
MessageLookupByLibrary.simpleMessage(
'expand and show more details and additional options'),
'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage(
'Episode list item. Showing description, main controls and additional controls.'),
'semantics_episode_tile_expanded_hint':
MessageLookupByLibrary.simpleMessage(
'collapse and show summary, download and play control'),
'semantics_expand_podcast_description':
MessageLookupByLibrary.simpleMessage('Expand podcast description'),
'semantics_increase_playback_speed':
MessageLookupByLibrary.simpleMessage('Increase playback speed'),
'semantics_layout_option_compact_grid':
MessageLookupByLibrary.simpleMessage('Compact grid layout'),
'semantics_layout_option_grid':
MessageLookupByLibrary.simpleMessage('Grid layout'),
'semantics_layout_option_list':
MessageLookupByLibrary.simpleMessage('List layout'),
'semantics_main_player_header':
MessageLookupByLibrary.simpleMessage('Main player window'),
'semantics_mark_episode_played':
MessageLookupByLibrary.simpleMessage('Mark Episode as played'),
'semantics_mark_episode_unplayed':
MessageLookupByLibrary.simpleMessage('Mark Episode as un-played'),
'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage(
'Mini player. Swipe right to play/pause button. Activate to open main player window'),
'semantics_play_pause_toggle':
MessageLookupByLibrary.simpleMessage('Play/pause toggle'),
'semantics_podcast_details_header':
MessageLookupByLibrary.simpleMessage(
'Podcast details and episodes page'),
'semantics_remove_from_queue':
MessageLookupByLibrary.simpleMessage('Remove episode from queue'),
'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage(
'Full screen player mode on episode start'),
'settings_auto_update_episodes':
MessageLookupByLibrary.simpleMessage('Auto update episodes'),
'settings_auto_update_episodes_10min':
MessageLookupByLibrary.simpleMessage(
'10 minutes since last update'),
'settings_auto_update_episodes_12hour':
MessageLookupByLibrary.simpleMessage('12 hours since last update'),
'settings_auto_update_episodes_1hour':
MessageLookupByLibrary.simpleMessage('1 hour since last update'),
'settings_auto_update_episodes_30min':
MessageLookupByLibrary.simpleMessage(
'30 minutes since last update'),
'settings_auto_update_episodes_3hour':
MessageLookupByLibrary.simpleMessage('3 hours since last update'),
'settings_auto_update_episodes_6hour':
MessageLookupByLibrary.simpleMessage('6 hours since last update'),
'settings_auto_update_episodes_always':
MessageLookupByLibrary.simpleMessage('Always'),
'settings_auto_update_episodes_heading':
MessageLookupByLibrary.simpleMessage(
'Refresh episodes on details screen after'),
'settings_auto_update_episodes_never':
MessageLookupByLibrary.simpleMessage('Never'),
'settings_data_divider_label':
MessageLookupByLibrary.simpleMessage('DATA'),
'settings_delete_played_label': MessageLookupByLibrary.simpleMessage(
'Delete downloaded episodes once played'),
'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage(
'Download episodes to SD card'),
'settings_download_switch_card': MessageLookupByLibrary.simpleMessage(
'New downloads will be saved to the SD card. Existing downloads will remain on internal storage.'),
'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage(
'New downloads will be saved to internal storage. Existing downloads will remain on the SD card.'),
'settings_download_switch_label':
MessageLookupByLibrary.simpleMessage('Change storage location'),
'settings_episodes_divider_label':
MessageLookupByLibrary.simpleMessage('EPISODES'),
'settings_export_opml':
MessageLookupByLibrary.simpleMessage('Export OPML'),
'settings_import_opml':
MessageLookupByLibrary.simpleMessage('Import OPML'),
'settings_label': MessageLookupByLibrary.simpleMessage('Settings'),
'settings_mark_deleted_played_label':
MessageLookupByLibrary.simpleMessage(
'Mark deleted episodes as played'),
'settings_personalisation_divider_label':
MessageLookupByLibrary.simpleMessage('Personalisation'),
'settings_playback_divider_label':
MessageLookupByLibrary.simpleMessage('Playback'),
'settings_theme_switch_label':
MessageLookupByLibrary.simpleMessage('Dark theme'),
'show_notes_label': MessageLookupByLibrary.simpleMessage('Show notes'),
'sleep_episode_label':
MessageLookupByLibrary.simpleMessage('End of episode'),
'sleep_minute_label': m0,
'sleep_off_label': MessageLookupByLibrary.simpleMessage('Off'),
'sleep_timer_label':
MessageLookupByLibrary.simpleMessage('Sleep Timer'),
'stop_download_button_label':
MessageLookupByLibrary.simpleMessage('Stop'),
'stop_download_confirmation': MessageLookupByLibrary.simpleMessage(
'Are you sure you wish to stop this download and delete the episode?'),
'stop_download_title':
MessageLookupByLibrary.simpleMessage('Stop Download'),
'subscribe_button_label':
MessageLookupByLibrary.simpleMessage('Follow'),
'subscribe_label': MessageLookupByLibrary.simpleMessage('Follow'),
'transcript_label': MessageLookupByLibrary.simpleMessage('Transcript'),
'transcript_why_not_label':
MessageLookupByLibrary.simpleMessage('Why not?'),
'transcript_why_not_url': MessageLookupByLibrary.simpleMessage(
'https://www.pinepods.online/docs/Features/Transcript'),
'unsubscribe_button_label':
MessageLookupByLibrary.simpleMessage('Unfollow'),
'unsubscribe_label': MessageLookupByLibrary.simpleMessage('Unfollow'),
'unsubscribe_message': MessageLookupByLibrary.simpleMessage(
'Unfollowing will delete all downloaded episodes of this podcast.'),
'up_next_queue_label': MessageLookupByLibrary.simpleMessage('Up Next')
};
}

View File

@@ -0,0 +1,359 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a it locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// @dart=2.12
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = MessageLookup();
typedef String? MessageIfAbsent(String? messageStr, List<Object>? args);
class MessageLookup extends MessageLookupByLibrary {
@override
String get localeName => 'it';
static m0(minutes) => "${minutes} minuti";
@override
final Map<String, dynamic> messages =
_notInlinedMessages(_notInlinedMessages);
static Map<String, dynamic> _notInlinedMessages(_) => {
'about_label': MessageLookupByLibrary.simpleMessage('Info'),
'add_rss_feed_option':
MessageLookupByLibrary.simpleMessage('Aggiungi un Feed RSS'),
'app_title': MessageLookupByLibrary.simpleMessage('Pinepods'),
'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'),
'audio_effect_trim_silence_label':
MessageLookupByLibrary.simpleMessage('Rimuovi Silenzio'),
'audio_effect_volume_boost_label':
MessageLookupByLibrary.simpleMessage('Incrementa Volume'),
'audio_settings_playback_speed_label':
MessageLookupByLibrary.simpleMessage('Velocità Riproduzione'),
'auto_scroll_transcript_label':
MessageLookupByLibrary.simpleMessage('Trascrizione sincronizzata'),
'cancel_button_label': MessageLookupByLibrary.simpleMessage('Annulla'),
'cancel_download_button_label':
MessageLookupByLibrary.simpleMessage('Annulla il download'),
'cancel_option_label': MessageLookupByLibrary.simpleMessage('Annulla'),
'chapters_label': MessageLookupByLibrary.simpleMessage('Capitoli'),
'clear_queue_button_label':
MessageLookupByLibrary.simpleMessage('PULISCI CODA'),
'clear_search_button_label':
MessageLookupByLibrary.simpleMessage('Pulisci il campo di ricerca'),
'close_button_label': MessageLookupByLibrary.simpleMessage('Chiudi'),
'consent_message': MessageLookupByLibrary.simpleMessage(
'Questo link per la ricerca fondi ti porterà a un sito esterno dove avrai la possibilità di supportare direttamente questo show. I link sono forniti dagli autori del podcast e non sono verificati da Pinepods.'),
'continue_button_label':
MessageLookupByLibrary.simpleMessage('Continua'),
'delete_button_label': MessageLookupByLibrary.simpleMessage('Elimina'),
'delete_episode_button_label':
MessageLookupByLibrary.simpleMessage('Elimina episodio scaricato'),
'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage(
'Sicura/o di voler eliminare questo episodio?'),
'delete_episode_title':
MessageLookupByLibrary.simpleMessage('Elimina Episodio'),
'delete_label': MessageLookupByLibrary.simpleMessage('Elimina'),
'discover': MessageLookupByLibrary.simpleMessage('Scopri'),
'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage(
'<Tutti>,Arte,Business,Commedia,Educazione,Fiction,Governativi,Salute e Benessere,Storia,Bambini e Famiglia,Tempo Libero,Musica,Notizie,Religione e Spiritualità,Scienza,Società e Cultura,Sport,TV e Film,Tecnologia,True Crime'),
'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage(
'<Tutti>,Dopo-Spettacolo,Alternativi,Animali,Animazione,Arte,Astronomia,Automotive,Aviazione,Baseball,Pallacanestro,Bellezza,Libri,Buddismo,Business,Carriera,Chimica,Cristianità,Clima,Commedia,Commenti,Corsi,Artigianato,Cricket,Cryptocurrency,Cultura,Giornalieri,Design,Documentari,Dramma,Terra,Educazione,Intrattenimento,Imprenditoria,Famiglia,Fantasy,Fashion,Fiction,Film,Fitness,Cibo,Football,Giochi,Giardinaggio,Golf,Governativi,Salute,Induismo,Storia,Hobbies,Hockey,Casa,Come Fare,Improvvisazione,Interviste,Investimenti,Islam,Giornalismo,Giudaismo,Bambini,Lingue,Apprendimento,Tempo-Libero,Stili di Vita,Gestione,Manga,Marketing,Matematica,Medicina,Mentale,Musica,Naturale,Natura,Notizie,NonProfit,Nutrizione,Genitorialità,Esecuzione,Personale,Animali-Domestici,Filosofia,Fisica,Posti,Politica,Relazioni,Religione,Recensioni,Giochi-di-Ruolo,Rugby,Corsa,Scienza,Miglioramento-Personale,Sessualità,Calcio,Social,Società,Spiritualità,Sports,Stand-Up,Storie,Nuoto,TV,Tabletop,Tecnologia,Tennis,Viaggi,True Crime,Video-Giochi,Visivo,Pallavolo,Meteo,Natura-Selvaggia,Wrestling'),
'download_episode_button_label':
MessageLookupByLibrary.simpleMessage('Scarica episodio'),
'downloads': MessageLookupByLibrary.simpleMessage('Scaricati'),
'empty_queue_message':
MessageLookupByLibrary.simpleMessage('La tua coda è vuota'),
'episode_details_button_label': MessageLookupByLibrary.simpleMessage(
'Mostra le informazioni sull\'episodio'),
'episode_filter_clear_filters_button_label':
MessageLookupByLibrary.simpleMessage('Pulisci i Filtri'),
'episode_filter_no_episodes_title_description':
MessageLookupByLibrary.simpleMessage(
'Questo podcast non ha episodi che corrispondono ai tuoi criteri di ricerca e filtro'),
'episode_filter_no_episodes_title_label':
MessageLookupByLibrary.simpleMessage('Nessun episodio trovato'),
'episode_filter_none_label':
MessageLookupByLibrary.simpleMessage('Nessuno'),
'episode_filter_played_label':
MessageLookupByLibrary.simpleMessage('Riprodotto'),
'episode_filter_semantic_label':
MessageLookupByLibrary.simpleMessage('Filtra gli episodi'),
'episode_filter_started_label':
MessageLookupByLibrary.simpleMessage('Avviato'),
'episode_filter_unplayed_label':
MessageLookupByLibrary.simpleMessage('Non riprodotto'),
'episode_label': MessageLookupByLibrary.simpleMessage('Episodio'),
'episode_sort_alphabetical_ascending_label':
MessageLookupByLibrary.simpleMessage('Ordine Alfabetico A-Z'),
'episode_sort_alphabetical_descending_label':
MessageLookupByLibrary.simpleMessage('Ordine Alfabetico Z-A'),
'episode_sort_earliest_first_label':
MessageLookupByLibrary.simpleMessage('I più vecchi'),
'episode_sort_latest_first_label':
MessageLookupByLibrary.simpleMessage('Gli ultimi'),
'episode_sort_none_label':
MessageLookupByLibrary.simpleMessage('Default'),
'episode_sort_semantic_label':
MessageLookupByLibrary.simpleMessage('Ordina gli episodi'),
'error_no_connection': MessageLookupByLibrary.simpleMessage(
'Impossibile riprodurre l\'episodio. Per favore, verifica la tua connessione e prova di nuovo.'),
'error_playback_fail': MessageLookupByLibrary.simpleMessage(
'Sì è verificato un errore inatteso durante la riproduzione. Per favore, verifica la tua connessione e prova di nuovo.'),
'fast_forward_button_label':
MessageLookupByLibrary.simpleMessage('Manda avanti di 30 secondi'),
'feedback_menu_item_label':
MessageLookupByLibrary.simpleMessage('Feedback'),
'go_back_button_label':
MessageLookupByLibrary.simpleMessage('Torna indietro'),
'label_opml_importing':
MessageLookupByLibrary.simpleMessage('Importazione in corso'),
'layout_label': MessageLookupByLibrary.simpleMessage('Layout'),
'library': MessageLookupByLibrary.simpleMessage('Libreria'),
'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage(
'Marca tutti gli episodi come non riprodotti'),
'mark_episodes_played_label': MessageLookupByLibrary.simpleMessage(
'Marca tutti gli episodi come riprodotti'),
'mark_played_label':
MessageLookupByLibrary.simpleMessage('Marca Riprodotto'),
'mark_unplayed_label':
MessageLookupByLibrary.simpleMessage('Marca da Riprodurre'),
'minimise_player_window_button_label':
MessageLookupByLibrary.simpleMessage(
'Minimizza la finestra del player'),
'more_label': MessageLookupByLibrary.simpleMessage('Di Più'),
'new_episodes_label': MessageLookupByLibrary.simpleMessage(
'Nuovi episodi sono disponibili'),
'new_episodes_view_now_label':
MessageLookupByLibrary.simpleMessage('VEDI ORA'),
'no_downloads_message': MessageLookupByLibrary.simpleMessage(
'Non hai nessun episodio scaricato'),
'no_podcast_details_message': MessageLookupByLibrary.simpleMessage(
'Non è possibile caricare gli episodi. Verifica la tua connessione, per favore.'),
'no_search_results_message':
MessageLookupByLibrary.simpleMessage('Nessun podcast trovato'),
'no_subscriptions_message': MessageLookupByLibrary.simpleMessage(
'Tappa il pulsante di ricerca sottostante o usa la barra di ricerca per trovare il tuo primo podcast'),
'no_transcript_available_label': MessageLookupByLibrary.simpleMessage(
'Nessuna trascrizione disponibile per questo podcast'),
'notes_label': MessageLookupByLibrary.simpleMessage('Note'),
'now_playing_episode_position':
MessageLookupByLibrary.simpleMessage('Posizione dell\'episodio'),
'now_playing_episode_time_remaining':
MessageLookupByLibrary.simpleMessage('Tempo rimanente'),
'now_playing_queue_label':
MessageLookupByLibrary.simpleMessage('In Riproduzione'),
'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'),
'open_show_website_label':
MessageLookupByLibrary.simpleMessage('Vai al sito web dello show'),
'opml_export_button_label':
MessageLookupByLibrary.simpleMessage('Esporta'),
'opml_import_button_label':
MessageLookupByLibrary.simpleMessage('Importa'),
'opml_import_export_label':
MessageLookupByLibrary.simpleMessage('OPML Importa/Esporta'),
'pause_button_label':
MessageLookupByLibrary.simpleMessage('Sospendi episodio'),
'play_button_label':
MessageLookupByLibrary.simpleMessage('Riproduci episodio'),
'play_download_button_label': MessageLookupByLibrary.simpleMessage(
'Riproduci l\'episodio scaricato'),
'playback_speed_label':
MessageLookupByLibrary.simpleMessage('Velocità di riproduzione'),
'podcast_funding_dialog_header':
MessageLookupByLibrary.simpleMessage('Podcast Fondi'),
'podcast_options_overflow_menu_semantic_label':
MessageLookupByLibrary.simpleMessage('Menu opzioni'),
'queue_add_label': MessageLookupByLibrary.simpleMessage('Aggiungi'),
'queue_clear_button_label':
MessageLookupByLibrary.simpleMessage('Svuota'),
'queue_clear_label': MessageLookupByLibrary.simpleMessage(
'Sicuro/a di voler ripulire la coda?'),
'queue_clear_label_title':
MessageLookupByLibrary.simpleMessage('Svuota la Coda'),
'queue_remove_label': MessageLookupByLibrary.simpleMessage('Rimuovi'),
'refresh_feed_label':
MessageLookupByLibrary.simpleMessage('Recupera nuovi episodi'),
'resume_button_label':
MessageLookupByLibrary.simpleMessage('Riprendi episodio'),
'rewind_button_label':
MessageLookupByLibrary.simpleMessage('Riavvolgi di 10 secondi'),
'scrim_episode_details_selector': MessageLookupByLibrary.simpleMessage(
'Chiudi i dettagli dell\'episodio'),
'scrim_episode_filter_selector': MessageLookupByLibrary.simpleMessage(
'Chiudi il filtro degli episodi'),
'scrim_episode_sort_selector': MessageLookupByLibrary.simpleMessage(
'Chiudi ordinamento degli episodi'),
'scrim_layout_selector': MessageLookupByLibrary.simpleMessage(
'Chiudi il selettore del layout'),
'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage(
'Chiudere il selettore del timer di spegnimento'),
'scrim_speed_selector': MessageLookupByLibrary.simpleMessage(
'Chiudere il selettore della velocità di riproduzione'),
'search_back_button_label':
MessageLookupByLibrary.simpleMessage('Indietro'),
'search_button_label': MessageLookupByLibrary.simpleMessage('Cerca'),
'search_episodes_label':
MessageLookupByLibrary.simpleMessage('Cerca episodi'),
'search_for_podcasts_hint':
MessageLookupByLibrary.simpleMessage('Ricerca dei podcasts'),
'search_provider_label':
MessageLookupByLibrary.simpleMessage('Provider di ricerca'),
'search_transcript_label':
MessageLookupByLibrary.simpleMessage('Cerca trascrizione'),
'semantic_announce_searching': MessageLookupByLibrary.simpleMessage(
'Ricerca in corso, attender prego.'),
'semantic_chapter_link_label':
MessageLookupByLibrary.simpleMessage('Web link al capitolo'),
'semantic_current_chapter_label':
MessageLookupByLibrary.simpleMessage('Capitolo attuale'),
'semantic_current_value_label':
MessageLookupByLibrary.simpleMessage('Impostazioni correnti'),
'semantic_playing_options_collapse_label':
MessageLookupByLibrary.simpleMessage(
'Chiudere il cursore delle opzioni di riproduzione'),
'semantic_playing_options_expand_label':
MessageLookupByLibrary.simpleMessage(
'Aprire il cursore delle opzioni di riproduzione'),
'semantic_podcast_artwork_label':
MessageLookupByLibrary.simpleMessage('Podcast artwork'),
'semantics_add_to_queue':
MessageLookupByLibrary.simpleMessage('Aggiungi episodio alla coda'),
'semantics_collapse_podcast_description':
MessageLookupByLibrary.simpleMessage(
'Collassa la descrizione del podcast'),
'semantics_decrease_playback_speed':
MessageLookupByLibrary.simpleMessage('Rallenta la riproduzione'),
'semantics_episode_tile_collapsed': MessageLookupByLibrary.simpleMessage(
'Voce dell\'elenco degli episodi. Visualizza immagine, sommario e i controlli principali.'),
'semantics_episode_tile_collapsed_hint':
MessageLookupByLibrary.simpleMessage(
'espandi e visualizza più dettagli e opzioni aggiuntive'),
'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage(
'Voce dell\'elenco degli episodi. Visualizza descrizione, controlli principali e controlli aggiuntivi.'),
'semantics_episode_tile_expanded_hint':
MessageLookupByLibrary.simpleMessage(
'collassa e visualizza il sommario, download e controlli di riproduzione'),
'semantics_expand_podcast_description':
MessageLookupByLibrary.simpleMessage(
'Espandi la descrizione del podcast'),
'semantics_increase_playback_speed':
MessageLookupByLibrary.simpleMessage('Incrementa la riproduzione'),
'semantics_layout_option_compact_grid':
MessageLookupByLibrary.simpleMessage('Griglia compatta'),
'semantics_layout_option_grid':
MessageLookupByLibrary.simpleMessage('Griglia'),
'semantics_layout_option_list':
MessageLookupByLibrary.simpleMessage('Lista'),
'semantics_main_player_header': MessageLookupByLibrary.simpleMessage(
'Finestra principale del player'),
'semantics_mark_episode_played': MessageLookupByLibrary.simpleMessage(
'Marca Episodio come riprodotto'),
'semantics_mark_episode_unplayed': MessageLookupByLibrary.simpleMessage(
'Marca Episodio come non-riprodotto'),
'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage(
'Mini player. Swipe a destra per riprodurre/mettere in pausa. Attivare per aprire la finestra principale del player'),
'semantics_play_pause_toggle':
MessageLookupByLibrary.simpleMessage('Play/pause toggle'),
'semantics_podcast_details_header':
MessageLookupByLibrary.simpleMessage(
'Podcast pagina dettagli ed episodi'),
'semantics_remove_from_queue':
MessageLookupByLibrary.simpleMessage('Rimuovi episodio dalla coda'),
'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage(
'Player a tutto schermo quando l\'episodio inizia'),
'settings_auto_update_episodes': MessageLookupByLibrary.simpleMessage(
'Aggiorna automaticamente gli episodi'),
'settings_auto_update_episodes_10min':
MessageLookupByLibrary.simpleMessage(
'10 minuti dall\'ultimo aggiornamento'),
'settings_auto_update_episodes_12hour':
MessageLookupByLibrary.simpleMessage(
'12 ore dall\'ultimo aggiornamento'),
'settings_auto_update_episodes_1hour':
MessageLookupByLibrary.simpleMessage(
'1 ora dall\'ultimo aggiornamento'),
'settings_auto_update_episodes_30min':
MessageLookupByLibrary.simpleMessage(
'30 minuti dall\'ultimo aggiornamento'),
'settings_auto_update_episodes_3hour':
MessageLookupByLibrary.simpleMessage(
'3 ore dall\'ultimo aggiornamento'),
'settings_auto_update_episodes_6hour':
MessageLookupByLibrary.simpleMessage(
'6 ore dall\'ultimo aggiornamento'),
'settings_auto_update_episodes_always':
MessageLookupByLibrary.simpleMessage('Sempre'),
'settings_auto_update_episodes_heading':
MessageLookupByLibrary.simpleMessage(
'Aggiorna gli episodi nella schermata successiva'),
'settings_auto_update_episodes_never':
MessageLookupByLibrary.simpleMessage('Mai'),
'settings_data_divider_label':
MessageLookupByLibrary.simpleMessage('DATI'),
'settings_delete_played_label': MessageLookupByLibrary.simpleMessage(
'Elimina gli episodi scaricati una volta riprodotti'),
'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage(
'Scarica gli episodi nella card SD'),
'settings_download_switch_card': MessageLookupByLibrary.simpleMessage(
'I nuovi downloads saranno salvati nella card SD. I downloads esistenti rimarranno nello storage interno.'),
'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage(
'I nuovi downloads saranno salvati nello storage interno. I downloads esistenti rimarranno nella card SD.'),
'settings_download_switch_label': MessageLookupByLibrary.simpleMessage(
'Cambia la posizione per lo storage'),
'settings_episodes_divider_label':
MessageLookupByLibrary.simpleMessage('EPISODI'),
'settings_export_opml':
MessageLookupByLibrary.simpleMessage('Esporta OPML'),
'settings_import_opml':
MessageLookupByLibrary.simpleMessage('Importa OPML'),
'settings_label': MessageLookupByLibrary.simpleMessage('Impostazioni'),
'settings_mark_deleted_played_label':
MessageLookupByLibrary.simpleMessage(
'Marca gli episodi eliminati come riprodotti'),
'settings_personalisation_divider_label':
MessageLookupByLibrary.simpleMessage('PERSONALIZZAZIONI'),
'settings_playback_divider_label':
MessageLookupByLibrary.simpleMessage('RIPRODUZIONE'),
'settings_theme_switch_label':
MessageLookupByLibrary.simpleMessage('Tema scuro'),
'show_notes_label':
MessageLookupByLibrary.simpleMessage('Visualizza le note'),
'sleep_episode_label':
MessageLookupByLibrary.simpleMessage('Fine dell\'episodio'),
'sleep_minute_label': m0,
'sleep_off_label': MessageLookupByLibrary.simpleMessage('Off'),
'sleep_timer_label':
MessageLookupByLibrary.simpleMessage('Timer di Riposo'),
'stop_download_button_label':
MessageLookupByLibrary.simpleMessage('Stop'),
'stop_download_confirmation': MessageLookupByLibrary.simpleMessage(
'Sicura/o di voler fermare il download ed eliminare l\'episodio?'),
'stop_download_title':
MessageLookupByLibrary.simpleMessage('Stop Download'),
'subscribe_button_label': MessageLookupByLibrary.simpleMessage('Segui'),
'subscribe_label': MessageLookupByLibrary.simpleMessage('Segui'),
'transcript_label':
MessageLookupByLibrary.simpleMessage('Trascrizioni'),
'transcript_why_not_label':
MessageLookupByLibrary.simpleMessage('Perché no?'),
'transcript_why_not_url': MessageLookupByLibrary.simpleMessage(
'https://www.pinepods.online/docs/Features/Transcript'),
'unsubscribe_button_label':
MessageLookupByLibrary.simpleMessage('Non Seguire'),
'unsubscribe_label':
MessageLookupByLibrary.simpleMessage('Smetti di seguire'),
'unsubscribe_message': MessageLookupByLibrary.simpleMessage(
'Smettendo di seguire questo podcast, tutti gli episodi scaricati verranno eliminati.'),
'up_next_queue_label':
MessageLookupByLibrary.simpleMessage('Vai al Prossimo')
};
}

View File

@@ -0,0 +1,349 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a messages locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// @dart=2.12
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = MessageLookup();
typedef String? MessageIfAbsent(String? messageStr, List<Object>? args);
class MessageLookup extends MessageLookupByLibrary {
@override
String get localeName => 'messages';
static m0(minutes) => "${minutes} minutes";
@override
final Map<String, dynamic> messages =
_notInlinedMessages(_notInlinedMessages);
static Map<String, dynamic> _notInlinedMessages(_) => {
'about_label': MessageLookupByLibrary.simpleMessage('About'),
'add_rss_feed_option':
MessageLookupByLibrary.simpleMessage('Add RSS Feed'),
'app_title':
MessageLookupByLibrary.simpleMessage('Pinepods Podcast Client'),
'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'),
'audio_effect_trim_silence_label':
MessageLookupByLibrary.simpleMessage('Trim Silence'),
'audio_effect_volume_boost_label':
MessageLookupByLibrary.simpleMessage('Volume Boost'),
'audio_settings_playback_speed_label':
MessageLookupByLibrary.simpleMessage('Playback Speed'),
'auto_scroll_transcript_label':
MessageLookupByLibrary.simpleMessage('Follow transcript'),
'cancel_button_label': MessageLookupByLibrary.simpleMessage('Cancel'),
'cancel_download_button_label':
MessageLookupByLibrary.simpleMessage('Cancel download'),
'cancel_option_label': MessageLookupByLibrary.simpleMessage('Cancel'),
'chapters_label': MessageLookupByLibrary.simpleMessage('Chapters'),
'clear_queue_button_label':
MessageLookupByLibrary.simpleMessage('CLEAR QUEUE'),
'clear_search_button_label':
MessageLookupByLibrary.simpleMessage('Clear search text'),
'close_button_label': MessageLookupByLibrary.simpleMessage('Close'),
'consent_message': MessageLookupByLibrary.simpleMessage(
'This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by Pinepods.'),
'continue_button_label':
MessageLookupByLibrary.simpleMessage('Continue'),
'delete_button_label': MessageLookupByLibrary.simpleMessage('Delete'),
'delete_episode_button_label':
MessageLookupByLibrary.simpleMessage('Delete downloaded episode'),
'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage(
'Are you sure you wish to delete this episode?'),
'delete_episode_title':
MessageLookupByLibrary.simpleMessage('Delete Episode'),
'delete_label': MessageLookupByLibrary.simpleMessage('Delete'),
'discover': MessageLookupByLibrary.simpleMessage('Discover'),
'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage(
'<All>,Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime'),
'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage(
'<All>,After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling'),
'download_episode_button_label':
MessageLookupByLibrary.simpleMessage('Download episode'),
'downloads': MessageLookupByLibrary.simpleMessage('Downloads'),
'empty_queue_message':
MessageLookupByLibrary.simpleMessage('Your queue is empty'),
'episode_details_button_label':
MessageLookupByLibrary.simpleMessage('Show episode information'),
'episode_filter_clear_filters_button_label':
MessageLookupByLibrary.simpleMessage('Clear Filters'),
'episode_filter_no_episodes_title_description':
MessageLookupByLibrary.simpleMessage('No Episodes Found'),
'episode_filter_no_episodes_title_label':
MessageLookupByLibrary.simpleMessage('No Episodes Found'),
'episode_filter_none_label':
MessageLookupByLibrary.simpleMessage('None'),
'episode_filter_played_label':
MessageLookupByLibrary.simpleMessage('Played'),
'episode_filter_semantic_label':
MessageLookupByLibrary.simpleMessage('Episode filter'),
'episode_filter_started_label':
MessageLookupByLibrary.simpleMessage('Started'),
'episode_filter_unplayed_label':
MessageLookupByLibrary.simpleMessage('Unplayed'),
'episode_label': MessageLookupByLibrary.simpleMessage('Episode'),
'episode_sort_alphabetical_ascending_label':
MessageLookupByLibrary.simpleMessage('Alphabetical A-Z'),
'episode_sort_alphabetical_descending_label':
MessageLookupByLibrary.simpleMessage('Alphabetical Z-A'),
'episode_sort_earliest_first_label':
MessageLookupByLibrary.simpleMessage('Earliest first'),
'episode_sort_latest_first_label':
MessageLookupByLibrary.simpleMessage('Latest first'),
'episode_sort_none_label':
MessageLookupByLibrary.simpleMessage('Default'),
'episode_sort_semantic_label':
MessageLookupByLibrary.simpleMessage('Episode sort'),
'error_no_connection': MessageLookupByLibrary.simpleMessage(
'Unable to play episode. Please check your connection and try again.'),
'error_playback_fail': MessageLookupByLibrary.simpleMessage(
'An unexpected error occurred during playback. Please check your connection and try again.'),
'fast_forward_button_label': MessageLookupByLibrary.simpleMessage(
'Fast-forward episode 30 seconds'),
'feedback_menu_item_label':
MessageLookupByLibrary.simpleMessage('Feedback'),
'go_back_button_label': MessageLookupByLibrary.simpleMessage('Go Back'),
'label_opml_importing':
MessageLookupByLibrary.simpleMessage('Importing'),
'layout_label': MessageLookupByLibrary.simpleMessage('Layout'),
'library': MessageLookupByLibrary.simpleMessage('Library'),
'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage(
'Mark all episodes as not played'),
'mark_episodes_played_label':
MessageLookupByLibrary.simpleMessage('Mark all episodes as played'),
'mark_played_label':
MessageLookupByLibrary.simpleMessage('Mark Played'),
'mark_unplayed_label':
MessageLookupByLibrary.simpleMessage('Mark Unplayed'),
'minimise_player_window_button_label':
MessageLookupByLibrary.simpleMessage('Minimise player window'),
'more_label': MessageLookupByLibrary.simpleMessage('More'),
'new_episodes_label':
MessageLookupByLibrary.simpleMessage('New episodes are available'),
'new_episodes_view_now_label':
MessageLookupByLibrary.simpleMessage('VIEW NOW'),
'no_downloads_message': MessageLookupByLibrary.simpleMessage(
'You do not have any downloaded episodes'),
'no_podcast_details_message': MessageLookupByLibrary.simpleMessage(
'Could not load podcast episodes. Please check your connection.'),
'no_search_results_message':
MessageLookupByLibrary.simpleMessage('No podcasts found'),
'no_subscriptions_message': MessageLookupByLibrary.simpleMessage(
'Head to Settings to Connect a Pinepods Server if you haven\'t yet!'),
'no_transcript_available_label': MessageLookupByLibrary.simpleMessage(
'A transcript is not available for this podcast'),
'notes_label': MessageLookupByLibrary.simpleMessage('Description'),
'now_playing_episode_position':
MessageLookupByLibrary.simpleMessage('Episode position'),
'now_playing_episode_time_remaining':
MessageLookupByLibrary.simpleMessage('Time remaining'),
'now_playing_queue_label':
MessageLookupByLibrary.simpleMessage('Now Playing'),
'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'),
'open_show_website_label':
MessageLookupByLibrary.simpleMessage('Open show website'),
'opml_export_button_label':
MessageLookupByLibrary.simpleMessage('Export'),
'opml_import_button_label':
MessageLookupByLibrary.simpleMessage('Import'),
'opml_import_export_label':
MessageLookupByLibrary.simpleMessage('OPML Import/Export'),
'pause_button_label':
MessageLookupByLibrary.simpleMessage('Pause episode'),
'play_button_label':
MessageLookupByLibrary.simpleMessage('Play episode'),
'play_download_button_label':
MessageLookupByLibrary.simpleMessage('Play downloaded episode'),
'playback_speed_label':
MessageLookupByLibrary.simpleMessage('Playback speed'),
'podcast_funding_dialog_header':
MessageLookupByLibrary.simpleMessage('Podcast Funding'),
'podcast_options_overflow_menu_semantic_label':
MessageLookupByLibrary.simpleMessage('Options menu'),
'queue_add_label': MessageLookupByLibrary.simpleMessage('Add'),
'queue_clear_button_label':
MessageLookupByLibrary.simpleMessage('Clear'),
'queue_clear_label': MessageLookupByLibrary.simpleMessage(
'Are you sure you wish to clear the queue?'),
'queue_clear_label_title':
MessageLookupByLibrary.simpleMessage('Clear Queue'),
'queue_remove_label': MessageLookupByLibrary.simpleMessage('Remove'),
'refresh_feed_label':
MessageLookupByLibrary.simpleMessage('Refresh episodes'),
'resume_button_label':
MessageLookupByLibrary.simpleMessage('Resume episode'),
'rewind_button_label':
MessageLookupByLibrary.simpleMessage('Rewind episode 10 seconds'),
'scrim_episode_details_selector':
MessageLookupByLibrary.simpleMessage('Dismiss episode details'),
'scrim_episode_filter_selector':
MessageLookupByLibrary.simpleMessage('Dismiss episode filter'),
'scrim_episode_sort_selector':
MessageLookupByLibrary.simpleMessage('Dismiss episode sort'),
'scrim_layout_selector':
MessageLookupByLibrary.simpleMessage('Dismiss layout selector'),
'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage(
'Dismiss sleep timer selector'),
'scrim_speed_selector': MessageLookupByLibrary.simpleMessage(
'Dismiss playback speed selector'),
'search_back_button_label':
MessageLookupByLibrary.simpleMessage('Back'),
'search_button_label': MessageLookupByLibrary.simpleMessage('Search'),
'search_episodes_label':
MessageLookupByLibrary.simpleMessage('Search episodes'),
'search_for_podcasts_hint':
MessageLookupByLibrary.simpleMessage('Search for podcasts'),
'search_provider_label':
MessageLookupByLibrary.simpleMessage('Search provider'),
'search_transcript_label':
MessageLookupByLibrary.simpleMessage('Search transcript'),
'semantic_announce_searching':
MessageLookupByLibrary.simpleMessage('Searching, please wait.'),
'semantic_chapter_link_label':
MessageLookupByLibrary.simpleMessage('Chapter web link'),
'semantic_current_chapter_label':
MessageLookupByLibrary.simpleMessage('Current chapter'),
'semantic_current_value_label':
MessageLookupByLibrary.simpleMessage('Current value'),
'semantic_playing_options_collapse_label':
MessageLookupByLibrary.simpleMessage(
'Close playing options slider'),
'semantic_playing_options_expand_label':
MessageLookupByLibrary.simpleMessage('Open playing options slider'),
'semantic_podcast_artwork_label':
MessageLookupByLibrary.simpleMessage('Podcast artwork'),
'semantics_add_to_queue':
MessageLookupByLibrary.simpleMessage('Add episode to queue'),
'semantics_collapse_podcast_description':
MessageLookupByLibrary.simpleMessage(
'Collapse podcast description'),
'semantics_decrease_playback_speed':
MessageLookupByLibrary.simpleMessage('Decrease playback speed'),
'semantics_episode_tile_collapsed':
MessageLookupByLibrary.simpleMessage(
'Episode list item. Showing image, summary and main controls.'),
'semantics_episode_tile_collapsed_hint':
MessageLookupByLibrary.simpleMessage(
'expand and show more details and additional options'),
'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage(
'Episode list item. Showing description, main controls and additional controls.'),
'semantics_episode_tile_expanded_hint':
MessageLookupByLibrary.simpleMessage(
'collapse and show summary, download and play control'),
'semantics_expand_podcast_description':
MessageLookupByLibrary.simpleMessage('Expand podcast description'),
'semantics_increase_playback_speed':
MessageLookupByLibrary.simpleMessage('Increase playback speed'),
'semantics_layout_option_compact_grid':
MessageLookupByLibrary.simpleMessage('Compact grid layout'),
'semantics_layout_option_grid':
MessageLookupByLibrary.simpleMessage('Grid layout'),
'semantics_layout_option_list':
MessageLookupByLibrary.simpleMessage('List layout'),
'semantics_main_player_header':
MessageLookupByLibrary.simpleMessage('Main player window'),
'semantics_mark_episode_played':
MessageLookupByLibrary.simpleMessage('Mark Episode as played'),
'semantics_mark_episode_unplayed':
MessageLookupByLibrary.simpleMessage('Mark Episode as un-played'),
'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage(
'Mini player. Swipe right to play/pause button. Activate to open main player window'),
'semantics_play_pause_toggle':
MessageLookupByLibrary.simpleMessage('Play/pause toggle'),
'semantics_podcast_details_header':
MessageLookupByLibrary.simpleMessage(
'Podcast details and episodes page'),
'semantics_remove_from_queue':
MessageLookupByLibrary.simpleMessage('Remove episode from queue'),
'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage(
'Full screen player mode on episode start'),
'settings_auto_update_episodes':
MessageLookupByLibrary.simpleMessage('Auto update episodes'),
'settings_auto_update_episodes_10min':
MessageLookupByLibrary.simpleMessage(
'10 minutes since last update'),
'settings_auto_update_episodes_12hour':
MessageLookupByLibrary.simpleMessage('12 hours since last update'),
'settings_auto_update_episodes_1hour':
MessageLookupByLibrary.simpleMessage('1 hour since last update'),
'settings_auto_update_episodes_30min':
MessageLookupByLibrary.simpleMessage(
'30 minutes since last update'),
'settings_auto_update_episodes_3hour':
MessageLookupByLibrary.simpleMessage('3 hours since last update'),
'settings_auto_update_episodes_6hour':
MessageLookupByLibrary.simpleMessage('6 hours since last update'),
'settings_auto_update_episodes_always':
MessageLookupByLibrary.simpleMessage('Always'),
'settings_auto_update_episodes_heading':
MessageLookupByLibrary.simpleMessage(
'Refresh episodes on details screen after'),
'settings_auto_update_episodes_never':
MessageLookupByLibrary.simpleMessage('Never'),
'settings_data_divider_label':
MessageLookupByLibrary.simpleMessage('DATA'),
'settings_delete_played_label': MessageLookupByLibrary.simpleMessage(
'Delete downloaded episodes once played'),
'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage(
'Download episodes to SD card'),
'settings_download_switch_card': MessageLookupByLibrary.simpleMessage(
'New downloads will be saved to the SD card. Existing downloads will remain on internal storage.'),
'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage(
'New downloads will be saved to internal storage. Existing downloads will remain on the SD card.'),
'settings_download_switch_label':
MessageLookupByLibrary.simpleMessage('Change storage location'),
'settings_episodes_divider_label':
MessageLookupByLibrary.simpleMessage('EPISODES'),
'settings_export_opml':
MessageLookupByLibrary.simpleMessage('Export OPML'),
'settings_import_opml':
MessageLookupByLibrary.simpleMessage('Import OPML'),
'settings_label': MessageLookupByLibrary.simpleMessage('Settings'),
'settings_mark_deleted_played_label':
MessageLookupByLibrary.simpleMessage(
'Mark deleted episodes as played'),
'settings_personalisation_divider_label':
MessageLookupByLibrary.simpleMessage('Personalisation'),
'settings_playback_divider_label':
MessageLookupByLibrary.simpleMessage('Playback'),
'settings_theme_switch_label':
MessageLookupByLibrary.simpleMessage('Dark theme'),
'show_notes_label': MessageLookupByLibrary.simpleMessage('Show notes'),
'sleep_episode_label':
MessageLookupByLibrary.simpleMessage('End of episode'),
'sleep_minute_label': m0,
'sleep_off_label': MessageLookupByLibrary.simpleMessage('Off'),
'sleep_timer_label':
MessageLookupByLibrary.simpleMessage('Sleep Timer'),
'stop_download_button_label':
MessageLookupByLibrary.simpleMessage('Stop'),
'stop_download_confirmation': MessageLookupByLibrary.simpleMessage(
'Are you sure you wish to stop this download and delete the episode?'),
'stop_download_title':
MessageLookupByLibrary.simpleMessage('Stop Download'),
'subscribe_button_label':
MessageLookupByLibrary.simpleMessage('Follow'),
'subscribe_label': MessageLookupByLibrary.simpleMessage('Follow'),
'transcript_label': MessageLookupByLibrary.simpleMessage('Transcript'),
'transcript_why_not_label':
MessageLookupByLibrary.simpleMessage('Why not?'),
'transcript_why_not_url': MessageLookupByLibrary.simpleMessage(
'https://www.pinepods.online/docs/Features/Transcript'),
'unsubscribe_button_label':
MessageLookupByLibrary.simpleMessage('Unfollow'),
'unsubscribe_label': MessageLookupByLibrary.simpleMessage('Unfollow'),
'unsubscribe_message': MessageLookupByLibrary.simpleMessage(
'Unfollowing will delete all downloaded episodes of this podcast.'),
'up_next_queue_label': MessageLookupByLibrary.simpleMessage('Up Next')
};
}

View File

@@ -0,0 +1,99 @@
// 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:io';
import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/ui/pinepods_podcast_app.dart';
import 'package:pinepods_mobile/ui/widgets/restart_widget.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
// ignore_for_file: avoid_print
void main() async {
List<int> certificateAuthorityBytes = [];
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(statusBarColor: Colors.transparent));
// Initialize app logger
final appLogger = AppLogger();
await appLogger.initialize();
Logger.root.level = Level.FINE;
Logger.root.onRecord.listen((record) {
print('${record.level.name}: - ${record.time}: ${record.loggerName}: ${record.message}');
// Also log to our app logger
LogLevel appLogLevel;
switch (record.level.name) {
case 'SEVERE':
appLogLevel = LogLevel.critical;
break;
case 'WARNING':
appLogLevel = LogLevel.warning;
break;
case 'INFO':
appLogLevel = LogLevel.info;
break;
case 'FINE':
case 'FINER':
case 'FINEST':
appLogLevel = LogLevel.debug;
break;
default:
appLogLevel = LogLevel.info;
break;
}
appLogger.log(appLogLevel, record.loggerName, record.message);
});
var mobileSettingsService = (await MobileSettingsService.instance())!;
certificateAuthorityBytes = await setupCertificateAuthority();
runApp(RestartWidget(
child: PinepodsPodcastApp(
mobileSettingsService: mobileSettingsService,
certificateAuthorityBytes: certificateAuthorityBytes,
),
));
}
/// When certificate authorities certificates expire, older devices may not be able to handle
/// the re-issued certificate resulting in SSL errors being thrown. This routine is called to
/// manually install the newer certificates on older devices so they continue to work.
Future<List<int>> setupCertificateAuthority() async {
List<int> ca = [];
var loadedCerts = false;
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
var major = androidInfo.version.release.split('.');
if ((int.tryParse(major[0]) ?? 100.0) < 8.0) {
ByteData data = await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem');
ca.addAll(data.buffer.asUint8List());
loadedCerts = true;
}
if ((int.tryParse(major[0]) ?? 100.0) < 10.0) {
ByteData data = await PlatformAssetBundle().load('assets/ca/globalsign-gcc-r6-alphassl-ca-2023.pem');
ca.addAll(data.buffer.asUint8List());
loadedCerts = true;
}
if (loadedCerts) {
SecurityContext.defaultContext.setTrustedCertificatesBytes(ca);
}
}
return ca;
}

View File

@@ -0,0 +1,48 @@
// 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:flutter/material.dart';
import 'package:flutter/widgets.dart';
/// This class will observe the current route.
///
/// This gives us an easy way to tell what screen we are on from elsewhere within
/// the application. This is useful, for example, when responding to external links
/// and determining if we need to display the podcast details or just update the
/// current screen.
class NavigationRouteObserver extends NavigatorObserver {
final List<Route<dynamic>?> _routeStack = <Route<dynamic>?>[];
static final NavigationRouteObserver _instance = NavigationRouteObserver._internal();
NavigationRouteObserver._internal();
factory NavigationRouteObserver() {
return _instance;
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routeStack.removeLast();
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routeStack.add(route);
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routeStack.remove(route);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
int oldRouteIndex = _routeStack.indexOf(oldRoute);
_routeStack.replaceRange(oldRouteIndex, oldRouteIndex + 1, [newRoute]);
}
Route<dynamic>? get top => _routeStack.last;
}

View File

@@ -0,0 +1,70 @@
// 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/entities/episode.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/state/episode_state.dart';
/// An abstract class that represent the actions supported by the chosen
/// database or storage implementation.
abstract class Repository {
/// General
Future<void> close();
/// Podcasts
Future<Podcast?> findPodcastById(num id);
Future<Podcast?> findPodcastByGuid(String guid);
Future<Podcast> savePodcast(Podcast podcast, {bool withEpisodes = true});
Future<void> deletePodcast(Podcast podcast);
Future<List<Podcast>> subscriptions();
/// Episodes
Future<List<Episode>> findAllEpisodes();
Future<Episode?> findEpisodeById(int id);
Future<Episode?> findEpisodeByGuid(String guid);
Future<List<Episode?>> findEpisodesByPodcastGuid(
String pguid, {
PodcastEpisodeFilter filter = PodcastEpisodeFilter.none,
PodcastEpisodeSort sort = PodcastEpisodeSort.none,
});
Future<Episode?> findEpisodeByTaskId(String taskId);
Future<Episode> saveEpisode(Episode episode, [bool updateIfSame = false]);
Future<List<Episode>> saveEpisodes(List<Episode> episodes, [bool updateIfSame = false]);
Future<void> deleteEpisode(Episode episode);
Future<void> deleteEpisodes(List<Episode> episodes);
Future<List<Episode>> findDownloadsByPodcastGuid(String pguid);
Future<List<Episode>> findDownloads();
Future<Transcript?> findTranscriptById(int id);
Future<Transcript> saveTranscript(Transcript transcript);
Future<void> deleteTranscriptById(int id);
Future<void> deleteTranscriptsById(List<int> id);
/// Queue
Future<void> saveQueue(List<Episode> episodes);
Future<List<Episode>> loadQueue();
/// Event listeners
Stream<Podcast>? podcastListener;
Stream<EpisodeState>? episodeListener;
}

View File

@@ -0,0 +1,52 @@
// 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:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';
typedef DatabaseUpgrade = Future<void> Function(Database, int, int);
/// Provides a database instance to other services and handles the opening
/// of the Sembast DB.
class DatabaseService {
Completer<Database>? _databaseCompleter;
String databaseName;
int? version = 1;
DatabaseUpgrade? upgraderCallback;
DatabaseService(
this.databaseName, {
this.version,
this.upgraderCallback,
});
Future<Database> get database async {
if (_databaseCompleter == null) {
_databaseCompleter = Completer();
await _openDatabase();
}
return _databaseCompleter!.future;
}
Future _openDatabase() async {
final appDocumentDir = await getApplicationDocumentsDirectory();
final dbPath = join(appDocumentDir.path, databaseName);
final database = await databaseFactoryIo.openDatabase(
dbPath,
version: version,
onVersionChanged: (db, oldVersion, newVersion) async {
if (upgraderCallback != null) {
await upgraderCallback!(db, oldVersion, newVersion);
}
},
);
_databaseCompleter!.complete(database);
}
}

View File

@@ -0,0 +1,681 @@
// 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/core/extensions.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/queue.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/repository/sembast/sembast_database_service.dart';
import 'package:pinepods_mobile/state/episode_state.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:sembast/sembast.dart';
/// An implementation of [Repository] that is backed by
/// [Sembast](https://github.com/tekartik/sembast.dart/tree/master/sembast)
class SembastRepository extends Repository {
final log = Logger('SembastRepository');
final _podcastSubject = BehaviorSubject<Podcast>();
final _episodeSubject = BehaviorSubject<EpisodeState>();
final _podcastStore = intMapStoreFactory.store('podcast');
final _episodeStore = intMapStoreFactory.store('episode');
final _queueStore = intMapStoreFactory.store('queue');
final _transcriptStore = intMapStoreFactory.store('transcript');
final _queueGuids = <String>[];
late DatabaseService _databaseService;
Future<Database> get _db async => _databaseService.database;
SembastRepository({
bool cleanup = true,
String databaseName = 'pinepods.db',
}) {
_databaseService = DatabaseService(databaseName, version: 2, upgraderCallback: dbUpgrader);
if (cleanup) {
_cleanupEpisodes().then((value) {
log.fine('Orphan episodes cleanup complete');
});
}
}
/// Saves the [Podcast] instance and associated [Episode]s. Podcasts are
/// only stored when we subscribe to them, so at the point we store a
/// new podcast we store the current [DateTime] to mark the
/// subscription date.
@override
Future<Podcast> savePodcast(Podcast podcast, {bool withEpisodes = true}) async {
log.fine('Saving podcast (${podcast.id ?? -1}) ${podcast.url}');
final finder = podcast.id == null
? Finder(filter: Filter.equals('guid', podcast.guid))
: Finder(filter: Filter.byKey(podcast.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _podcastStore.findFirst(await _db, finder: finder);
podcast.lastUpdated = DateTime.now();
if (snapshot == null) {
podcast.subscribedDate = DateTime.now();
podcast.id = await _podcastStore.add(await _db, podcast.toMap());
} else {
await _podcastStore.update(await _db, podcast.toMap(), finder: finder);
}
if (withEpisodes) {
await _saveEpisodes(podcast.episodes);
}
_podcastSubject.add(podcast);
return podcast;
}
@override
Future<List<Podcast>> subscriptions() async {
// Custom sort order to ignore title case.
final titleSortOrder = SortOrder<String>.custom('title', (title1, title2) {
return title1.toLowerCase().compareTo(title2.toLowerCase());
});
final finder = Finder(sortOrders: [
titleSortOrder,
]);
final List<RecordSnapshot<int, Map<String, Object?>>> subscriptionSnapshot = await _podcastStore.find(
await _db,
finder: finder,
);
final subs = subscriptionSnapshot.map((snapshot) {
final subscription = Podcast.fromMap(snapshot.key, snapshot.value);
return subscription;
}).toList();
return subs;
}
@override
Future<void> deletePodcast(Podcast podcast) async {
final db = await _db;
await db.transaction((txn) async {
final podcastFinder = Finder(filter: Filter.byKey(podcast.id));
final episodeFinder = Finder(filter: Filter.equals('pguid', podcast.guid));
await _podcastStore.delete(
txn,
finder: podcastFinder,
);
await _episodeStore.delete(
txn,
finder: episodeFinder,
);
});
}
@override
Future<Podcast?> findPodcastById(num id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _podcastStore.findFirst(await _db, finder: finder);
if (snapshot != null) {
var p = Podcast.fromMap(snapshot.key, snapshot.value);
// Now attach all episodes for this podcast
p.episodes = await findEpisodesByPodcastGuid(
p.guid,
filter: p.filter,
sort: p.sort,
);
return p;
}
return null;
}
@override
Future<Podcast?> findPodcastByGuid(String guid) async {
final finder = Finder(filter: Filter.equals('guid', guid));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _podcastStore.findFirst(await _db, finder: finder);
if (snapshot != null) {
var p = Podcast.fromMap(snapshot.key, snapshot.value);
// Now attach all episodes for this podcast
p.episodes = await findEpisodesByPodcastGuid(
p.guid,
filter: p.filter,
sort: p.sort,
);
return p;
}
return null;
}
@override
Future<List<Episode>> findAllEpisodes() async {
final finder = Finder(
sortOrders: [SortOrder('publicationDate', false)],
);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
return results;
}
@override
Future<Episode?> findEpisodeById(int? id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>> snapshot =
(await _episodeStore.findFirst(await _db, finder: finder))!;
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
}
@override
Future<Episode?> findEpisodeByGuid(String guid) async {
final finder = Finder(filter: Filter.equals('guid', guid));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
return null;
}
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
}
// TODO: Remove nullable on pguid as this does not make sense.
@override
Future<List<Episode>> findEpisodesByPodcastGuid(
String? pguid, {
PodcastEpisodeFilter filter = PodcastEpisodeFilter.none,
PodcastEpisodeSort sort = PodcastEpisodeSort.none,
}) async {
var episodeFilter = Filter.equals('pguid', pguid);
var sortOrder = SortOrder('publicationDate', false);
// If we have an additional episode filter and/or sort, apply it.
episodeFilter = _applyEpisodeFilter(filter, episodeFilter, pguid);
sortOrder = _applyEpisodeSort(sort, sortOrder);
final finder = Finder(
filter: episodeFilter,
sortOrders: [sortOrder],
);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) async {
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
}).toList();
final episodeList = Future.wait(results);
return episodeList;
}
@override
Future<List<Episode>> findDownloadsByPodcastGuid(String pguid) async {
final finder = Finder(
filter: Filter.and([
Filter.equals('pguid', pguid),
Filter.equals('downloadPercentage', '100'),
]),
sortOrders: [SortOrder('publicationDate', false)],
);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
return results;
}
@override
Future<List<Episode>> findDownloads() async {
final finder =
Finder(filter: Filter.equals('downloadPercentage', '100'), sortOrders: [SortOrder('publicationDate', false)]);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
return results;
}
@override
Future<void> deleteEpisode(Episode episode) async {
final finder = Finder(filter: Filter.byKey(episode.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
// Oops!
} else {
await _episodeStore.delete(await _db, finder: finder);
_episodeSubject.add(EpisodeDeleteState(episode));
}
}
@override
Future<void> deleteEpisodes(List<Episode> episodes) async {
var d = await _db;
if (episodes.isNotEmpty) {
for (var chunk in episodes.chunk(100)) {
await d.transaction((txn) async {
var futures = <Future<int>>[];
for (var episode in chunk) {
final finder = Finder(filter: Filter.byKey(episode.id));
futures.add(_episodeStore.delete(txn, finder: finder));
}
if (futures.isNotEmpty) {
await Future.wait(futures);
}
});
}
}
}
@override
Future<Episode> saveEpisode(Episode episode, [bool updateIfSame = false]) async {
var e = await _saveEpisode(episode, updateIfSame);
_episodeSubject.add(EpisodeUpdateState(e));
return e;
}
@override
Future<List<Episode>> saveEpisodes(List<Episode> episodes, [bool updateIfSame = false]) async {
final updatedEpisodes = <Episode>[];
for (var es in episodes) {
var e = await _saveEpisode(es, updateIfSame);
updatedEpisodes.add(e);
_episodeSubject.add(EpisodeUpdateState(e));
}
return updatedEpisodes;
}
@override
Future<List<Episode>> loadQueue() async {
var episodes = <Episode>[];
final RecordSnapshot<int, Map<String, Object?>>? snapshot = await _queueStore.record(1).getSnapshot(await _db);
if (snapshot != null) {
var queue = Queue.fromMap(snapshot.key, snapshot.value);
var episodeFinder = Finder(filter: Filter.inList('guid', queue.guids));
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: episodeFinder);
episodes = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
}
return episodes;
}
@override
Future<void> saveQueue(List<Episode> episodes) async {
/// Check to see if we have any ad-hoc episodes and save them first
for (var e in episodes) {
if (e.pguid == null || e.pguid!.isEmpty) {
_saveEpisode(e, false);
}
}
var guids = episodes.map((e) => e.guid).toList();
/// Only bother saving if the queue has changed
if (!listEquals(guids, _queueGuids)) {
final queue = Queue(guids: guids);
await _queueStore.record(1).put(await _db, queue.toMap());
_queueGuids.clear();
_queueGuids.addAll(guids);
}
}
@override
Future<Transcript?> findTranscriptById(int? id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _transcriptStore.findFirst(await _db, finder: finder);
return snapshot == null ? null : Transcript.fromMap(snapshot.key, snapshot.value);
}
@override
Future<void> deleteTranscriptById(int id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _transcriptStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
// Oops!
} else {
await _transcriptStore.delete(await _db, finder: finder);
}
}
@override
Future<void> deleteTranscriptsById(List<int> id) async {
var d = await _db;
if (id.isNotEmpty) {
for (var chunk in id.chunk(100)) {
await d.transaction((txn) async {
var futures = <Future<int>>[];
for (var id in chunk) {
final finder = Finder(filter: Filter.byKey(id));
futures.add(_transcriptStore.delete(txn, finder: finder));
}
if (futures.isNotEmpty) {
await Future.wait(futures);
}
});
}
}
}
@override
Future<Transcript> saveTranscript(Transcript transcript) async {
final finder = Finder(filter: Filter.byKey(transcript.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _transcriptStore.findFirst(await _db, finder: finder);
transcript.lastUpdated = DateTime.now();
if (snapshot == null) {
transcript.id = await _transcriptStore.add(await _db, transcript.toMap());
} else {
await _transcriptStore.update(await _db, transcript.toMap(), finder: finder);
}
return transcript;
}
Future<void> _cleanupEpisodes() async {
final threshold = DateTime.now().subtract(const Duration(days: 60)).millisecondsSinceEpoch;
/// Find all streamed episodes over the threshold.
final filter = Filter.and([
Filter.equals('downloadState', 0),
Filter.lessThan('lastUpdated', threshold),
]);
final orphaned = <Episode>[];
final pguids = <String?>[];
final List<RecordSnapshot<int, Map<String, Object?>>> episodes =
await _episodeStore.find(await _db, finder: Finder(filter: filter));
// First, find all podcasts
for (var podcast in await _podcastStore.find(await _db)) {
pguids.add(podcast.value['guid'] as String?);
}
for (var episode in episodes) {
final pguid = episode.value['pguid'] as String?;
final podcast = pguids.contains(pguid);
if (!podcast) {
orphaned.add(Episode.fromMap(episode.key, episode.value));
}
}
await deleteEpisodes(orphaned);
}
SortOrder<Object?> _applyEpisodeSort(PodcastEpisodeSort sort, SortOrder<Object?> sortOrder) {
switch (sort) {
case PodcastEpisodeSort.none:
case PodcastEpisodeSort.latestFirst:
sortOrder = SortOrder('publicationDate', false);
break;
case PodcastEpisodeSort.earliestFirst:
sortOrder = SortOrder('publicationDate', true);
break;
case PodcastEpisodeSort.alphabeticalDescending:
sortOrder = SortOrder<String>.custom('title', (title1, title2) {
return title2.toLowerCase().compareTo(title1.toLowerCase());
});
break;
case PodcastEpisodeSort.alphabeticalAscending:
sortOrder = SortOrder<String>.custom('title', (title1, title2) {
return title1.toLowerCase().compareTo(title2.toLowerCase());
});
break;
}
return sortOrder;
}
Filter _applyEpisodeFilter(PodcastEpisodeFilter filter, Filter episodeFilter, String? pguid) {
// If we have an additional episode filter, apply it.
switch (filter) {
case PodcastEpisodeFilter.none:
episodeFilter = Filter.equals('pguid', pguid);
break;
case PodcastEpisodeFilter.started:
episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.notEquals('position', '0')]);
break;
case PodcastEpisodeFilter.played:
episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.equals('played', 'true')]);
break;
case PodcastEpisodeFilter.notPlayed:
episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.equals('played', 'false')]);
break;
}
return episodeFilter;
}
/// Saves a list of episodes to the repository. To improve performance we
/// split the episodes into chunks of 100 and save any that have been updated
/// in that chunk in a single transaction.
Future<void> _saveEpisodes(List<Episode?>? episodes) async {
var d = await _db;
var dateStamp = DateTime.now();
if (episodes != null && episodes.isNotEmpty) {
for (var chunk in episodes.chunk(100)) {
await d.transaction((txn) async {
var futures = <Future<int>>[];
for (var episode in chunk) {
episode!.lastUpdated = dateStamp;
if (episode.id == null) {
futures.add(_episodeStore.add(txn, episode.toMap()).then((id) => episode.id = id));
} else {
final finder = Finder(filter: Filter.byKey(episode.id));
var existingEpisode = await findEpisodeById(episode.id);
if (existingEpisode == null || existingEpisode != episode) {
futures.add(_episodeStore.update(txn, episode.toMap(), finder: finder));
}
}
}
if (futures.isNotEmpty) {
await Future.wait(futures);
}
});
}
}
}
Future<Episode> _saveEpisode(Episode episode, bool updateIfSame) async {
final finder = Finder(filter: Filter.byKey(episode.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
episode.lastUpdated = DateTime.now();
episode.id = await _episodeStore.add(await _db, episode.toMap());
} else {
var e = Episode.fromMap(episode.id, snapshot.value);
episode.lastUpdated = DateTime.now();
if (updateIfSame || episode != e) {
await _episodeStore.update(await _db, episode.toMap(), finder: finder);
}
}
return episode;
}
@override
Future<Episode?> findEpisodeByTaskId(String taskId) async {
final finder = Finder(filter: Filter.equals('downloadTaskId', taskId));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot != null) {
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
} else {
return null;
}
}
Future<Episode> _loadEpisodeSnapshot(int key, Map<String, Object?> snapshot) async {
var episode = Episode.fromMap(key, snapshot);
if (episode.transcriptId! > 0) {
episode.transcript = await findTranscriptById(episode.transcriptId);
}
return episode;
}
@override
Future<void> close() async {
final d = await _db;
await d.close();
}
Future<void> dbUpgrader(Database db, int oldVersion, int newVersion) async {
if (oldVersion == 1) {
await _upgradeV2(db);
}
}
/// In v1 we allowed http requests, where as now we force to https. As we currently use the
/// URL as the GUID we need to upgrade any followed podcasts that have a http base to https.
/// We use the passed [Database] rather than _db to prevent deadlocking, hence the direct
/// update to data within this routine rather than using the existing find/update methods.
Future<void> _upgradeV2(Database db) async {
List<RecordSnapshot<int, Map<String, Object?>>> data = await _podcastStore.find(db);
final podcasts = data.map((e) => Podcast.fromMap(e.key, e.value)).toList();
log.info('Upgrading Sembast store to V2');
for (var podcast in podcasts) {
if (podcast.guid!.startsWith('http:')) {
final idFinder = Finder(filter: Filter.byKey(podcast.id));
final guid = podcast.guid!.replaceFirst('http:', 'https:');
final episodeFinder = Finder(
filter: Filter.equals('pguid', podcast.guid),
);
log.fine('Upgrading GUID ${podcast.guid} - to $guid');
var upgradedPodcast = Podcast(
id: podcast.id,
guid: guid,
url: podcast.url,
link: podcast.link,
title: podcast.title,
description: podcast.description,
imageUrl: podcast.imageUrl,
thumbImageUrl: podcast.thumbImageUrl,
copyright: podcast.copyright,
funding: podcast.funding,
persons: podcast.persons,
lastUpdated: DateTime.now(),
);
final List<RecordSnapshot<int, Map<String, Object?>>> episodeData =
await _episodeStore.find(db, finder: episodeFinder);
final episodes = episodeData.map((e) => Episode.fromMap(e.key, e.value)).toList();
// Now upgrade episodes
for (var e in episodes) {
e.pguid = guid;
log.fine('Updating episode guid for ${e.title} from ${e.pguid} to $guid');
final epf = Finder(filter: Filter.byKey(e.id));
await _episodeStore.update(db, e.toMap(), finder: epf);
}
upgradedPodcast.episodes = episodes;
await _podcastStore.update(db, upgradedPodcast.toMap(), finder: idFinder);
}
}
}
@override
Stream<EpisodeState> get episodeListener => _episodeSubject.stream;
@override
Stream<Podcast> get podcastListener => _podcastSubject.stream;
}

View File

@@ -0,0 +1,112 @@
// 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/entities/episode.dart';
import 'package:pinepods_mobile/entities/sleep.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/state/transcript_state_event.dart';
import 'package:rxdart/rxdart.dart';
enum AudioState {
none,
buffering,
starting,
playing,
pausing,
stopped,
error,
}
class PositionState {
Duration position;
late Duration length;
late int percentage;
Episode? episode;
final bool buffering;
PositionState({
required this.position,
required this.length,
required this.percentage,
this.episode,
this.buffering = false,
});
PositionState.emptyState()
: position = const Duration(seconds: 0),
length = const Duration(seconds: 0),
percentage = 0,
buffering = false;
}
/// This class defines the audio playback options supported by Pinepods.
///
/// The implementing classes will then handle the specifics for the platform we are running on.
abstract class AudioPlayerService {
/// Play a new episode, optionally resume at last save point.
Future<void> playEpisode({required Episode episode, bool resume = true});
/// Resume playing of current episode
Future<void> play();
/// Stop playing of current episode. Set update to false to stop
/// playback without saving any episode or positional updates.
Future<void> stop();
/// Pause the current episode.
Future<void> pause();
/// Rewind the current episode by pre-set number of seconds.
Future<void> rewind();
/// Fast forward the current episode by pre-set number of seconds.
Future<void> fastForward();
/// Seek to the specified position within the current episode.
Future<void> seek({required int position});
/// Call when the app is resumed to re-establish the audio service.
Future<Episode?> resume();
/// Add an episode to the playback queue
Future<void> addUpNextEpisode(Episode episode);
/// Remove an episode from the playback queue if it exists
Future<bool> removeUpNextEpisode(Episode episode);
/// Remove an episode from the playback queue if it exists
Future<bool> moveUpNextEpisode(Episode episode, int oldIndex, int newIndex);
/// Empty the up next queue
Future<void> clearUpNext();
/// Call when the app is about to be suspended.
Future<void> suspend();
/// Call to set the playback speed.
Future<void> setPlaybackSpeed(double speed);
/// Call to toggle trim silence.
Future<void> trimSilence(bool trim);
/// Call to toggle trim silence.
Future<void> volumeBoost(bool boost);
Future<void> searchTranscript(String search);
Future<void> clearTranscript();
void sleep(Sleep sleep);
Episode? nowPlaying;
/// Event listeners
Stream<AudioState>? playingState;
ValueStream<PositionState>? playPosition;
ValueStream<Episode?>? episodeEvent;
Stream<TranscriptState>? transcriptEvent;
Stream<int>? playbackError;
Stream<QueueListState>? queueState;
Stream<Sleep>? sleepStream;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
// Global authentication notifier for cross-context communication
class AuthNotifier {
static VoidCallback? _globalLoginSuccessCallback;
static void setGlobalLoginSuccessCallback(VoidCallback? callback) {
_globalLoginSuccessCallback = callback;
}
static void notifyLoginSuccess() {
_globalLoginSuccessCallback?.call();
}
static void clearGlobalLoginSuccessCallback() {
_globalLoginSuccessCallback = null;
}
}

View File

@@ -0,0 +1,27 @@
// 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/entities/downloadable.dart';
class DownloadProgress {
final String id;
final int percentage;
final DownloadState status;
DownloadProgress(
this.id,
this.percentage,
this.status,
);
}
abstract class DownloadManager {
Future<String?> enqueueTask(String url, String downloadPath, String fileName);
Stream<DownloadProgress> get downloadProgress;
void dispose();
}

View File

@@ -0,0 +1,13 @@
// 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/entities/episode.dart';
abstract class DownloadService {
Future<bool> downloadEpisode(Episode episode);
Future<Episode?> findEpisodeByTaskId(String taskId);
void dispose();
}

View File

@@ -0,0 +1,119 @@
// 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 'dart:isolate';
import 'dart:ui';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/services/download/download_manager.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:logging/logging.dart';
/// A [DownloadManager] for handling downloading of podcasts on a mobile device.
@pragma('vm:entry-point')
class MobileDownloaderManager implements DownloadManager {
static const portName = 'downloader_send_port';
final log = Logger('MobileDownloaderManager');
final ReceivePort _port = ReceivePort();
final downloadController = StreamController<DownloadProgress>();
var _lastUpdateTime = 0;
@override
Stream<DownloadProgress> get downloadProgress => downloadController.stream;
MobileDownloaderManager() {
_init();
}
Future _init() async {
log.fine('Initialising download manager');
await FlutterDownloader.initialize();
IsolateNameServer.removePortNameMapping(portName);
IsolateNameServer.registerPortWithName(_port.sendPort, portName);
var tasks = await FlutterDownloader.loadTasks();
// Update the status of any tasks that may have been updated whilst
// Pinepods was close or in the background.
if (tasks != null && tasks.isNotEmpty) {
for (var t in tasks) {
_updateDownloadState(id: t.taskId, progress: t.progress, status: t.status);
/// If we are not queued or running we can safely clean up this event
if (t.status != DownloadTaskStatus.enqueued && t.status != DownloadTaskStatus.running) {
FlutterDownloader.remove(taskId: t.taskId, shouldDeleteContent: false);
}
}
}
_port.listen((dynamic data) {
final id = (data as List<dynamic>)[0] as String;
final status = DownloadTaskStatus.fromInt(data[1] as int);
final progress = data[2] as int;
_updateDownloadState(id: id, progress: progress, status: status);
});
FlutterDownloader.registerCallback(downloadCallback);
}
@override
Future<String?> enqueueTask(String url, String downloadPath, String fileName) async {
return await FlutterDownloader.enqueue(
url: url,
savedDir: downloadPath,
fileName: fileName,
showNotification: true,
openFileFromNotification: false,
headers: {
'User-Agent': Environment.userAgent(),
},
);
}
@override
void dispose() {
IsolateNameServer.removePortNameMapping(portName);
downloadController.close();
}
void _updateDownloadState({required String id, required int progress, required DownloadTaskStatus status}) {
var state = DownloadState.none;
var updateTime = DateTime.now().millisecondsSinceEpoch;
if (status == DownloadTaskStatus.enqueued) {
state = DownloadState.queued;
} else if (status == DownloadTaskStatus.canceled) {
state = DownloadState.cancelled;
} else if (status == DownloadTaskStatus.complete) {
state = DownloadState.downloaded;
} else if (status == DownloadTaskStatus.running) {
state = DownloadState.downloading;
} else if (status == DownloadTaskStatus.failed) {
state = DownloadState.failed;
} else if (status == DownloadTaskStatus.paused) {
state = DownloadState.paused;
}
/// If we are running, we want to limit notifications to 1 per second. Otherwise,
/// small downloads can cause a flood of events. Any other status we always want
/// to push through.
if (status != DownloadTaskStatus.running ||
progress == 0 ||
progress == 100 ||
updateTime > _lastUpdateTime + 1000) {
downloadController.add(DownloadProgress(id, progress, state));
_lastUpdateTime = updateTime;
}
}
@pragma('vm:entry-point')
static void downloadCallback(String id, int status, int progress) {
IsolateNameServer.lookupPortByName('downloader_send_port')?.send([id, status, progress]);
}
}

View File

@@ -0,0 +1,180 @@
// 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 'dart:io';
import 'package:pinepods_mobile/core/utils.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/services/download/download_manager.dart';
import 'package:pinepods_mobile/services/download/download_service.dart';
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:logging/logging.dart';
import 'package:mp3_info/mp3_info.dart';
import 'package:rxdart/rxdart.dart';
/// An implementation of a [DownloadService] that handles downloading
/// of episodes on mobile.
class MobileDownloadService extends DownloadService {
static BehaviorSubject<DownloadProgress> downloadProgress = BehaviorSubject<DownloadProgress>();
final log = Logger('MobileDownloadService');
final Repository repository;
final DownloadManager downloadManager;
final PodcastService podcastService;
MobileDownloadService({required this.repository, required this.downloadManager, required this.podcastService}) {
downloadManager.downloadProgress.pipe(downloadProgress);
downloadProgress.listen((progress) {
_updateDownloadProgress(progress);
});
}
@override
void dispose() {
downloadManager.dispose();
}
@override
Future<bool> downloadEpisode(Episode episode) async {
try {
final season = episode.season > 0 ? episode.season.toString() : '';
final epno = episode.episode > 0 ? episode.episode.toString() : '';
var dirty = false;
if (await hasStoragePermission()) {
// If this episode contains chapter, fetch them first.
if (episode.hasChapters && episode.chaptersUrl != null) {
var chapters = await podcastService.loadChaptersByUrl(url: episode.chaptersUrl!);
episode.chapters = chapters;
dirty = true;
}
// Next, if the episode supports transcripts download that next
if (episode.hasTranscripts) {
var sub = episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.json);
sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.subrip);
sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.html);
if (sub != null) {
var transcript = await podcastService.loadTranscriptByUrl(transcriptUrl: sub);
transcript = await podcastService.saveTranscript(transcript);
episode.transcript = transcript;
episode.transcriptId = transcript.id;
dirty = true;
}
}
if (dirty) {
await podcastService.saveEpisode(episode);
}
final episodePath = await resolveDirectory(episode: episode);
final downloadPath = await resolveDirectory(episode: episode, full: true);
var uri = Uri.parse(episode.contentUrl!);
// Ensure the download directory exists
await createDownloadDirectory(episode);
// Filename should be last segment of URI.
var filename = safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.mp3')));
filename ??= safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.m4a')));
if (filename == null) {
//TODO: Handle unsupported format.
} else {
// The last segment could also be a full URL. Take a second pass.
if (filename.contains('/')) {
try {
uri = Uri.parse(filename);
filename = uri.pathSegments.last;
} on FormatException {
// It wasn't a URL...
}
}
// Some podcasts use the same file name for each episode. If we have a
// season and/or episode number provided by iTunes we can use that. We
// will also append the filename with the publication date if available.
var pubDate = '';
if (episode.publicationDate != null) {
pubDate = '${episode.publicationDate!.millisecondsSinceEpoch ~/ 1000}-';
}
filename = '$season$epno$pubDate$filename';
log.fine('Download episode (${episode.title}) $filename to $downloadPath/$filename');
/// If we get a redirect to an http endpoint the download will fail. Let's fully resolve
/// the URL before calling download and ensure it is https.
var url = await resolveUrl(episode.contentUrl!, forceHttps: true);
final taskId = await downloadManager.enqueueTask(url, downloadPath, filename);
// Update the episode with download data
episode.filepath = episodePath;
episode.filename = filename;
episode.downloadTaskId = taskId;
episode.downloadState = DownloadState.downloading;
episode.downloadPercentage = 0;
await repository.saveEpisode(episode);
return true;
}
}
return false;
} catch (e, stack) {
log.warning('Episode download failed (${episode.title})', e, stack);
return false;
}
}
@override
Future<Episode?> findEpisodeByTaskId(String taskId) {
return repository.findEpisodeByTaskId(taskId);
}
Future<void> _updateDownloadProgress(DownloadProgress progress) async {
var episode = await repository.findEpisodeByTaskId(progress.id);
if (episode != null) {
// We might be called during the cleanup routine during startup.
// Do not bother updating if nothing has changed.
if (episode.downloadPercentage != progress.percentage || episode.downloadState != progress.status) {
episode.downloadPercentage = progress.percentage;
episode.downloadState = progress.status;
if (progress.percentage == 100) {
if (await hasStoragePermission()) {
final filename = await resolvePath(episode);
// If we do not have a duration for this file - let's calculate it
if (episode.duration == 0) {
var mp3Info = MP3Processor.fromFile(File(filename));
episode.duration = mp3Info.duration.inSeconds;
}
}
}
await repository.saveEpisode(episode);
}
}
}
}

View File

@@ -0,0 +1,202 @@
// lib/services/error_handling_service.dart
import 'dart:io';
import 'package:http/http.dart' as http;
/// Service for handling and categorizing errors, especially server connection issues
class ErrorHandlingService {
/// Checks if an error indicates a server connection issue
static bool isServerConnectionError(dynamic error) {
if (error == null) return false;
final errorString = error.toString().toLowerCase();
// Network-related errors
if (error is SocketException) return true;
if (error is HttpException) return true;
if (error is http.ClientException) return true;
// Check for common connection error patterns
final connectionErrorPatterns = [
'connection refused',
'connection timeout',
'connection failed',
'network is unreachable',
'no route to host',
'connection reset',
'connection aborted',
'host is unreachable',
'server unavailable',
'service unavailable',
'bad gateway',
'gateway timeout',
'connection timed out',
'failed host lookup',
'no address associated with hostname',
'network unreachable',
'operation timed out',
'handshake failure',
'certificate verify failed',
'ssl handshake failed',
'unable to connect',
'server closed the connection',
'connection closed',
'broken pipe',
'no internet connection',
'offline',
'dns lookup failed',
'name resolution failed',
];
return connectionErrorPatterns.any((pattern) => errorString.contains(pattern));
}
/// Checks if an error indicates authentication/authorization issues
static bool isAuthenticationError(dynamic error) {
if (error == null) return false;
final errorString = error.toString().toLowerCase();
final authErrorPatterns = [
'unauthorized',
'authentication failed',
'invalid credentials',
'access denied',
'forbidden',
'token expired',
'invalid token',
'login required',
'401',
'403',
];
return authErrorPatterns.any((pattern) => errorString.contains(pattern));
}
/// Checks if an error indicates server-side issues (5xx errors)
static bool isServerError(dynamic error) {
if (error == null) return false;
final errorString = error.toString().toLowerCase();
final serverErrorPatterns = [
'internal server error',
'server error',
'service unavailable',
'bad gateway',
'gateway timeout',
'500',
'502',
'503',
'504',
'505',
];
return serverErrorPatterns.any((pattern) => errorString.contains(pattern));
}
/// Gets a user-friendly error message based on the error type
static String getUserFriendlyErrorMessage(dynamic error) {
if (error == null) return 'An unknown error occurred';
if (isServerConnectionError(error)) {
return 'Unable to connect to the PinePods server. Please check your internet connection and server settings.';
}
if (isAuthenticationError(error)) {
return 'Authentication failed. Please check your login credentials.';
}
if (isServerError(error)) {
return 'The PinePods server is experiencing issues. Please try again later.';
}
// Return the original error message for other types of errors
return error.toString();
}
/// Gets an appropriate title for the error
static String getErrorTitle(dynamic error) {
if (error == null) return 'Error';
if (isServerConnectionError(error)) {
return 'Server Unavailable';
}
if (isAuthenticationError(error)) {
return 'Authentication Error';
}
if (isServerError(error)) {
return 'Server Error';
}
return 'Error';
}
/// Gets troubleshooting suggestions based on the error type
static List<String> getTroubleshootingSteps(dynamic error) {
if (error == null) return ['Please try again later'];
if (isServerConnectionError(error)) {
return [
'Check your internet connection',
'Verify server URL in settings',
'Ensure the PinePods server is running',
'Check if the server port is accessible',
'Contact your administrator if the issue persists',
];
}
if (isAuthenticationError(error)) {
return [
'Check your username and password',
'Ensure your account is still active',
'Try logging out and logging back in',
'Contact your administrator for help',
];
}
if (isServerError(error)) {
return [
'Wait a few minutes and try again',
'Check if the server is overloaded',
'Contact your administrator',
'Check server logs for more details',
];
}
return [
'Try refreshing the page',
'Restart the app if the issue persists',
'Contact support for assistance',
];
}
/// Wraps an async function call with error handling
static Future<T> handleApiCall<T>(
Future<T> Function() apiCall, {
String? context,
}) async {
try {
return await apiCall();
} catch (error) {
// Log the error with context if provided
if (context != null) {
print('API Error in $context: $error');
}
// Re-throw the error to be handled by the UI layer
rethrow;
}
}
}
/// Extension to make error checking easier
extension ErrorTypeExtension on dynamic {
bool get isServerConnectionError => ErrorHandlingService.isServerConnectionError(this);
bool get isAuthenticationError => ErrorHandlingService.isAuthenticationError(this);
bool get isServerError => ErrorHandlingService.isServerError(this);
String get userFriendlyMessage => ErrorHandlingService.getUserFriendlyErrorMessage(this);
String get errorTitle => ErrorHandlingService.getErrorTitle(this);
List<String> get troubleshootingSteps => ErrorHandlingService.getTroubleshootingSteps(this);
}

View File

@@ -0,0 +1,35 @@
// lib/services/global_services.dart
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
/// Global service access point for the app
class GlobalServices {
static PinepodsAudioService? _pinepodsAudioService;
static PinepodsService? _pinepodsService;
/// Set the global services (called from PinepodsPodcastApp)
static void initialize({
required PinepodsAudioService pinepodsAudioService,
required PinepodsService pinepodsService,
}) {
_pinepodsAudioService = pinepodsAudioService;
_pinepodsService = pinepodsService;
}
/// Update global service credentials (called when user logs in or settings change)
static void setCredentials(String server, String apiKey) {
_pinepodsService?.setCredentials(server, apiKey);
}
/// Get the global PinepodsAudioService instance
static PinepodsAudioService? get pinepodsAudioService => _pinepodsAudioService;
/// Get the global PinepodsService instance
static PinepodsService? get pinepodsService => _pinepodsService;
/// Clear services (for testing or cleanup)
static void clear() {
_pinepodsAudioService = null;
_pinepodsService = null;
}
}

View File

@@ -0,0 +1,488 @@
// lib/services/logging/app_logger.dart
import 'dart:io';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path_helper;
import 'package:intl/intl.dart';
enum LogLevel {
debug,
info,
warning,
error,
critical,
}
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String tag;
final String message;
final String? stackTrace;
LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
this.stackTrace,
});
String get levelString {
switch (level) {
case LogLevel.debug:
return 'DEBUG';
case LogLevel.info:
return 'INFO';
case LogLevel.warning:
return 'WARN';
case LogLevel.error:
return 'ERROR';
case LogLevel.critical:
return 'CRITICAL';
}
}
String get formattedMessage {
final timeStr = timestamp.toString().substring(0, 19); // Remove milliseconds for readability
var result = '[$timeStr] [$levelString] [$tag] $message';
if (stackTrace != null && stackTrace!.isNotEmpty) {
result += '\nStackTrace: $stackTrace';
}
return result;
}
}
class DeviceInfo {
final String platform;
final String osVersion;
final String model;
final String manufacturer;
final String appVersion;
final String buildNumber;
DeviceInfo({
required this.platform,
required this.osVersion,
required this.model,
required this.manufacturer,
required this.appVersion,
required this.buildNumber,
});
String get formattedInfo {
return '''
Device Information:
- Platform: $platform
- OS Version: $osVersion
- Model: $model
- Manufacturer: $manufacturer
- App Version: $appVersion
- Build Number: $buildNumber
''';
}
}
class AppLogger {
static final AppLogger _instance = AppLogger._internal();
factory AppLogger() => _instance;
AppLogger._internal();
static const int maxLogEntries = 1000; // Keep last 1000 log entries in memory
static const int maxSessionFiles = 5; // Keep last 5 session log files
static const String crashLogFileName = 'pinepods_last_crash.txt';
final Queue<LogEntry> _logs = Queue<LogEntry>();
DeviceInfo? _deviceInfo;
File? _currentSessionFile;
File? _crashLogFile;
Directory? _logsDirectory;
String? _sessionId;
bool _isInitialized = false;
// Initialize the logger and collect device info
Future<void> initialize() async {
if (_isInitialized) return;
await _collectDeviceInfo();
await _initializeLogFiles();
await _setupCrashHandler();
await _loadPreviousCrash();
_isInitialized = true;
// Log initialization
log(LogLevel.info, 'AppLogger', 'Logger initialized successfully');
}
Future<void> _collectDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
final packageInfo = await PackageInfo.fromPlatform();
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
_deviceInfo = DeviceInfo(
platform: 'Android',
osVersion: 'Android ${androidInfo.version.release} (API ${androidInfo.version.sdkInt})',
model: '${androidInfo.manufacturer} ${androidInfo.model}',
manufacturer: androidInfo.manufacturer,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
_deviceInfo = DeviceInfo(
platform: 'iOS',
osVersion: '${iosInfo.systemName} ${iosInfo.systemVersion}',
model: iosInfo.model,
manufacturer: 'Apple',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} else {
_deviceInfo = DeviceInfo(
platform: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
}
} catch (e) {
// If device info collection fails, create a basic info object
try {
final packageInfo = await PackageInfo.fromPlatform();
_deviceInfo = DeviceInfo(
platform: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} catch (e2) {
_deviceInfo = DeviceInfo(
platform: 'Unknown',
osVersion: 'Unknown',
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: 'Unknown',
buildNumber: 'Unknown',
);
}
}
}
void log(LogLevel level, String tag, String message, [String? stackTrace]) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
stackTrace: stackTrace,
);
_logs.add(entry);
// Keep only the last maxLogEntries in memory
while (_logs.length > maxLogEntries) {
_logs.removeFirst();
}
// Write to current session file asynchronously (don't await to avoid blocking)
_writeToSessionFile(entry);
// Also print to console in debug mode
if (kDebugMode) {
print(entry.formattedMessage);
}
}
// Convenience methods for different log levels
void debug(String tag, String message) => log(LogLevel.debug, tag, message);
void info(String tag, String message) => log(LogLevel.info, tag, message);
void warning(String tag, String message) => log(LogLevel.warning, tag, message);
void error(String tag, String message, [String? stackTrace]) => log(LogLevel.error, tag, message, stackTrace);
void critical(String tag, String message, [String? stackTrace]) => log(LogLevel.critical, tag, message, stackTrace);
// Log an exception with automatic stack trace
void logException(String tag, String message, dynamic exception, [StackTrace? stackTrace]) {
final stackTraceStr = stackTrace?.toString() ?? exception.toString();
error(tag, '$message: $exception', stackTraceStr);
}
// Get all logs
List<LogEntry> get logs => _logs.toList();
// Get logs filtered by level
List<LogEntry> getLogsByLevel(LogLevel level) {
return _logs.where((log) => log.level == level).toList();
}
// Get logs from a specific time period
List<LogEntry> getLogsInTimeRange(DateTime start, DateTime end) {
return _logs.where((log) =>
log.timestamp.isAfter(start) && log.timestamp.isBefore(end)
).toList();
}
// Get formatted log string for copying
String getFormattedLogs() {
final buffer = StringBuffer();
// Add device info
if (_deviceInfo != null) {
buffer.writeln(_deviceInfo!.formattedInfo);
}
// Add separator
buffer.writeln('=' * 50);
buffer.writeln('Application Logs:');
buffer.writeln('=' * 50);
// Add all logs
for (final log in _logs) {
buffer.writeln(log.formattedMessage);
}
// Add footer
buffer.writeln();
buffer.writeln('=' * 50);
buffer.writeln('End of logs - Total entries: ${_logs.length}');
buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues');
return buffer.toString();
}
// Clear all logs
void clearLogs() {
_logs.clear();
log(LogLevel.info, 'AppLogger', 'Logs cleared by user');
}
// Initialize log files and directory structure
Future<void> _initializeLogFiles() async {
try {
final appDocDir = await getApplicationDocumentsDirectory();
_logsDirectory = Directory(path_helper.join(appDocDir.path, 'logs'));
// Create logs directory if it doesn't exist
if (!await _logsDirectory!.exists()) {
await _logsDirectory!.create(recursive: true);
}
// Clean up old session files (keep only last 5)
await _cleanupOldSessionFiles();
// Create new session file
_sessionId = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_currentSessionFile = File(path_helper.join(_logsDirectory!.path, 'session_$_sessionId.log'));
await _currentSessionFile!.create();
// Initialize crash log file
_crashLogFile = File(path_helper.join(_logsDirectory!.path, crashLogFileName));
if (!await _crashLogFile!.exists()) {
await _crashLogFile!.create();
}
log(LogLevel.info, 'AppLogger', 'Session log files initialized at ${_logsDirectory!.path}');
} catch (e) {
if (kDebugMode) {
print('Failed to initialize log files: $e');
}
}
}
// Clean up old session files, keeping only the most recent ones
Future<void> _cleanupOldSessionFiles() async {
try {
final files = await _logsDirectory!.list().toList();
final sessionFiles = files
.whereType<File>()
.where((f) => path_helper.basename(f.path).startsWith('session_'))
.toList();
// Sort by last modified date (newest first)
sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
// Delete files beyond the limit
if (sessionFiles.length > maxSessionFiles) {
for (int i = maxSessionFiles; i < sessionFiles.length; i++) {
await sessionFiles[i].delete();
}
}
} catch (e) {
if (kDebugMode) {
print('Failed to cleanup old session files: $e');
}
}
}
// Write log entry to current session file
Future<void> _writeToSessionFile(LogEntry entry) async {
if (_currentSessionFile == null) return;
try {
await _currentSessionFile!.writeAsString(
'${entry.formattedMessage}\n',
mode: FileMode.append,
);
} catch (e) {
// Silently fail to avoid logging loops
if (kDebugMode) {
print('Failed to write log to session file: $e');
}
}
}
// Setup crash handler
Future<void> _setupCrashHandler() async {
FlutterError.onError = (FlutterErrorDetails details) {
_logCrash('Flutter Error', details.exception.toString(), details.stack);
// Still call the default error handler
FlutterError.presentError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
_logCrash('Platform Error', error.toString(), stack);
return true; // Mark as handled
};
}
// Log crash to persistent storage
Future<void> _logCrash(String type, String error, StackTrace? stackTrace) async {
try {
final crashInfo = {
'timestamp': DateTime.now().toIso8601String(),
'sessionId': _sessionId,
'type': type,
'error': error,
'stackTrace': stackTrace?.toString(),
'deviceInfo': _deviceInfo?.formattedInfo,
'recentLogs': _logs.length > 20 ? _logs.skip(_logs.length - 20).map((e) => e.formattedMessage).toList() : _logs.map((e) => e.formattedMessage).toList(), // Only last 20 entries
};
if (_crashLogFile != null) {
await _crashLogFile!.writeAsString(jsonEncode(crashInfo));
}
// Also log through normal logging
critical('CrashHandler', '$type: $error', stackTrace?.toString());
} catch (e) {
if (kDebugMode) {
print('Failed to log crash: $e');
}
}
}
// Load and log previous crash if exists
Future<void> _loadPreviousCrash() async {
if (_crashLogFile == null || !await _crashLogFile!.exists()) return;
try {
final crashData = await _crashLogFile!.readAsString();
if (crashData.isNotEmpty) {
final crash = jsonDecode(crashData);
warning('PreviousCrash', 'Previous crash detected: ${crash['type']} at ${crash['timestamp']}');
warning('PreviousCrash', 'Session: ${crash['sessionId'] ?? 'unknown'}');
warning('PreviousCrash', 'Error: ${crash['error']}');
if (crash['stackTrace'] != null) {
warning('PreviousCrash', 'Stack trace available in crash log file');
}
}
} catch (e) {
warning('AppLogger', 'Failed to load previous crash info: $e');
}
}
// Get list of available session files
Future<List<File>> getSessionFiles() async {
if (_logsDirectory == null) return [];
try {
final files = await _logsDirectory!.list().toList();
final sessionFiles = files
.whereType<File>()
.where((f) => path_helper.basename(f.path).startsWith('session_'))
.toList();
// Sort by last modified date (newest first)
sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
return sessionFiles;
} catch (e) {
return [];
}
}
// Get current session file path
String? get currentSessionPath => _currentSessionFile?.path;
// Get crash log file path
String? get crashLogPath => _crashLogFile?.path;
// Get logs directory path
String? get logsDirectoryPath => _logsDirectory?.path;
// Check if previous crash exists
Future<bool> hasPreviousCrash() async {
if (_crashLogFile == null) return false;
try {
final exists = await _crashLogFile!.exists();
if (!exists) return false;
final content = await _crashLogFile!.readAsString();
return content.isNotEmpty;
} catch (e) {
return false;
}
}
// Clear crash log
Future<void> clearCrashLog() async {
if (_crashLogFile != null && await _crashLogFile!.exists()) {
await _crashLogFile!.writeAsString('');
}
}
// Get formatted logs with session info
String getFormattedLogsWithSessionInfo() {
final buffer = StringBuffer();
// Add session info
buffer.writeln('Session ID: $_sessionId');
buffer.writeln('Session started: ${DateTime.now().toString()}');
// Add device info
if (_deviceInfo != null) {
buffer.writeln(_deviceInfo!.formattedInfo);
}
// Add separator
buffer.writeln('=' * 50);
buffer.writeln('Application Logs (Current Session):');
buffer.writeln('=' * 50);
// Add all logs
for (final log in _logs) {
buffer.writeln(log.formattedMessage);
}
// Add footer
buffer.writeln();
buffer.writeln('=' * 50);
buffer.writeln('End of logs - Total entries: ${_logs.length}');
buffer.writeln('Session file: ${_currentSessionFile?.path}');
buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues');
return buffer.toString();
}
// Get device info
DeviceInfo? get deviceInfo => _deviceInfo;
}

View File

@@ -0,0 +1,532 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class PinepodsLoginService {
static const String userAgent = 'PinePods Mobile/1.0';
/// Verify if the server is a valid PinePods instance
static Future<bool> verifyPinepodsInstance(String serverUrl) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/pinepods_check');
final response = await http.get(
url,
headers: {'User-Agent': userAgent},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['pinepods_instance'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Initial login - returns either API key or MFA session info
static Future<InitialLoginResponse> initialLogin(String serverUrl, String username, String password) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final credentials = base64Encode(utf8.encode('$username:$password'));
final authHeader = 'Basic $credentials';
final url = Uri.parse('$normalizedUrl/api/data/get_key');
final response = await http.get(
url,
headers: {
'Authorization': authHeader,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// Check if MFA is required
if (data['status'] == 'mfa_required' && data['mfa_required'] == true) {
return InitialLoginResponse.mfaRequired(
serverUrl: normalizedUrl,
username: username,
userId: data['user_id'],
mfaSessionToken: data['mfa_session_token'],
);
}
// Normal flow - no MFA required
final apiKey = data['retrieved_key'];
if (apiKey != null) {
return InitialLoginResponse.success(apiKey: apiKey);
}
}
return InitialLoginResponse.failure('Authentication failed');
} catch (e) {
return InitialLoginResponse.failure('Error: ${e.toString()}');
}
}
/// Legacy method for backwards compatibility
@deprecated
static Future<String?> getApiKey(String serverUrl, String username, String password) async {
final result = await initialLogin(serverUrl, username, password);
return result.isSuccess ? result.apiKey : null;
}
/// Verify API key is valid
static Future<bool> verifyApiKey(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_key');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['status'] == 'success';
}
return false;
} catch (e) {
return false;
}
}
/// Get user ID
static Future<int?> getUserId(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/get_user');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success' && data['retrieved_id'] != null) {
return data['retrieved_id'] as int;
}
}
return null;
} catch (e) {
return null;
}
}
/// Get user details
static Future<UserDetails?> getUserDetails(String serverUrl, String apiKey, int userId) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/user_details_id/$userId');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return UserDetails.fromJson(data);
}
return null;
} catch (e) {
return null;
}
}
/// Get API configuration
static Future<ApiConfig?> getApiConfig(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/config');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return ApiConfig.fromJson(data);
}
return null;
} catch (e) {
return null;
}
}
/// Check if MFA is enabled for user
static Future<bool> checkMfaEnabled(String serverUrl, String apiKey, int userId) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/check_mfa_enabled/$userId');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['mfa_enabled'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Verify MFA code and get API key during login (secure flow)
static Future<String?> verifyMfaAndGetKey(String serverUrl, String mfaSessionToken, String mfaCode) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa_and_get_key');
final requestBody = jsonEncode({
'mfa_session_token': mfaSessionToken,
'mfa_code': mfaCode,
});
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['verified'] == true && data['status'] == 'success') {
return data['retrieved_key'];
}
}
return null;
} catch (e) {
return null;
}
}
/// Legacy MFA verification (for post-login MFA checks)
@deprecated
static Future<bool> verifyMfa(String serverUrl, String apiKey, int userId, String mfaCode) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa');
final requestBody = jsonEncode({
'user_id': userId,
'mfa_code': mfaCode,
});
final response = await http.post(
url,
headers: {
'Api-Key': apiKey,
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['verified'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Complete login flow (new secure MFA implementation)
static Future<LoginResult> login(String serverUrl, String username, String password) async {
try {
// Step 1: Verify server
final isPinepods = await verifyPinepodsInstance(serverUrl);
if (!isPinepods) {
return LoginResult.failure('Not a valid PinePods server');
}
// Step 2: Initial login - get API key or MFA session
final initialResult = await initialLogin(serverUrl, username, password);
if (!initialResult.isSuccess) {
return LoginResult.failure(initialResult.errorMessage ?? 'Login failed');
}
if (initialResult.requiresMfa) {
// MFA required - return MFA prompt state
return LoginResult.mfaRequired(
serverUrl: initialResult.serverUrl!,
username: username,
userId: initialResult.userId!,
mfaSessionToken: initialResult.mfaSessionToken!,
);
}
// No MFA required - complete login with API key
return await _completeLoginWithApiKey(
serverUrl,
username,
initialResult.apiKey!,
);
} catch (e) {
return LoginResult.failure('Error: ${e.toString()}');
}
}
/// Complete MFA login flow
static Future<LoginResult> completeMfaLogin({
required String serverUrl,
required String username,
required String mfaSessionToken,
required String mfaCode,
}) async {
try {
// Verify MFA and get API key
final apiKey = await verifyMfaAndGetKey(serverUrl, mfaSessionToken, mfaCode);
if (apiKey == null) {
return LoginResult.failure('Invalid MFA code');
}
// Complete login with verified API key
return await _completeLoginWithApiKey(serverUrl, username, apiKey);
} catch (e) {
return LoginResult.failure('Error: ${e.toString()}');
}
}
/// Complete login flow with API key (common logic)
static Future<LoginResult> _completeLoginWithApiKey(String serverUrl, String username, String apiKey) async {
// Step 1: Verify API key
final isValidKey = await verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
return LoginResult.failure('API key verification failed');
}
// Step 2: Get user ID
final userId = await getUserId(serverUrl, apiKey);
if (userId == null) {
return LoginResult.failure('Failed to get user ID');
}
// Step 3: Get user details
final userDetails = await getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
return LoginResult.failure('Failed to get user details');
}
// Step 4: Get API configuration
final apiConfig = await getApiConfig(serverUrl, apiKey);
if (apiConfig == null) {
return LoginResult.failure('Failed to get server configuration');
}
return LoginResult.success(
serverUrl: serverUrl,
apiKey: apiKey,
userId: userId,
userDetails: userDetails,
apiConfig: apiConfig,
);
}
}
class InitialLoginResponse {
final bool isSuccess;
final bool requiresMfa;
final String? errorMessage;
final String? apiKey;
final String? serverUrl;
final String? username;
final int? userId;
final String? mfaSessionToken;
InitialLoginResponse._({
required this.isSuccess,
required this.requiresMfa,
this.errorMessage,
this.apiKey,
this.serverUrl,
this.username,
this.userId,
this.mfaSessionToken,
});
factory InitialLoginResponse.success({required String apiKey}) {
return InitialLoginResponse._(
isSuccess: true,
requiresMfa: false,
apiKey: apiKey,
);
}
factory InitialLoginResponse.mfaRequired({
required String serverUrl,
required String username,
required int userId,
required String mfaSessionToken,
}) {
return InitialLoginResponse._(
isSuccess: true,
requiresMfa: true,
serverUrl: serverUrl,
username: username,
userId: userId,
mfaSessionToken: mfaSessionToken,
);
}
factory InitialLoginResponse.failure(String errorMessage) {
return InitialLoginResponse._(
isSuccess: false,
requiresMfa: false,
errorMessage: errorMessage,
);
}
}
class LoginResult {
final bool isSuccess;
final bool requiresMfa;
final String? errorMessage;
final String? serverUrl;
final String? apiKey;
final String? username;
final int? userId;
final String? mfaSessionToken;
final UserDetails? userDetails;
final ApiConfig? apiConfig;
LoginResult._({
required this.isSuccess,
required this.requiresMfa,
this.errorMessage,
this.serverUrl,
this.apiKey,
this.username,
this.userId,
this.mfaSessionToken,
this.userDetails,
this.apiConfig,
});
factory LoginResult.success({
required String serverUrl,
required String apiKey,
required int userId,
required UserDetails userDetails,
required ApiConfig apiConfig,
}) {
return LoginResult._(
isSuccess: true,
requiresMfa: false,
serverUrl: serverUrl,
apiKey: apiKey,
userId: userId,
userDetails: userDetails,
apiConfig: apiConfig,
);
}
factory LoginResult.failure(String errorMessage) {
return LoginResult._(
isSuccess: false,
requiresMfa: false,
errorMessage: errorMessage,
);
}
factory LoginResult.mfaRequired({
required String serverUrl,
required String username,
required int userId,
required String mfaSessionToken,
}) {
return LoginResult._(
isSuccess: false,
requiresMfa: true,
serverUrl: serverUrl,
username: username,
userId: userId,
mfaSessionToken: mfaSessionToken,
);
}
}
class UserDetails {
final int userId;
final String? fullname;
final String? username;
final String? email;
UserDetails({
required this.userId,
this.fullname,
this.username,
this.email,
});
factory UserDetails.fromJson(Map<String, dynamic> json) {
return UserDetails(
userId: json['UserID'],
fullname: json['Fullname'],
username: json['Username'],
email: json['Email'],
);
}
}
class ApiConfig {
final String? apiUrl;
final String? proxyUrl;
final String? proxyHost;
final String? proxyPort;
final String? proxyProtocol;
final String? reverseProxy;
final String? peopleUrl;
ApiConfig({
this.apiUrl,
this.proxyUrl,
this.proxyHost,
this.proxyPort,
this.proxyProtocol,
this.reverseProxy,
this.peopleUrl,
});
factory ApiConfig.fromJson(Map<String, dynamic> json) {
return ApiConfig(
apiUrl: json['api_url'],
proxyUrl: json['proxy_url'],
proxyHost: json['proxy_host'],
proxyPort: json['proxy_port'],
proxyProtocol: json['proxy_protocol'],
reverseProxy: json['reverse_proxy'],
peopleUrl: json['people_url'],
);
}
}

View File

@@ -0,0 +1,405 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
class OidcService {
static const String userAgent = 'PinePods Mobile/1.0';
static const String callbackUrlScheme = 'pinepods';
static const String callbackPath = '/auth/callback';
/// Get available OIDC providers from server
static Future<List<OidcProvider>> getPublicProviders(String serverUrl) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/public_oidc_providers');
final response = await http.get(
url,
headers: {'User-Agent': userAgent},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final providers = (data['providers'] as List)
.map((provider) => OidcProvider.fromJson(provider))
.toList();
return providers;
}
return [];
} catch (e) {
return [];
}
}
/// Generate PKCE code verifier and challenge for secure OIDC flow
static OidcPkce generatePkce() {
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
return OidcPkce(
codeVerifier: codeVerifier,
codeChallenge: codeChallenge,
);
}
/// Generate random state parameter
static String generateState() {
final random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
/// Store OIDC state on server (matches web implementation)
static Future<bool> storeOidcState({
required String serverUrl,
required String state,
required String clientId,
String? originUrl,
String? codeVerifier,
}) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/auth/store_state');
final requestBody = jsonEncode({
'state': state,
'client_id': clientId,
'origin_url': originUrl,
'code_verifier': codeVerifier,
});
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
return response.statusCode == 200;
} catch (e) {
return false;
}
}
/// Build authorization URL and return it for in-app browser use
static Future<String?> buildOidcLoginUrl({
required OidcProvider provider,
required String serverUrl,
required String state,
OidcPkce? pkce,
}) async {
try {
// Store state on server first - use web origin for in-app browser
final stateStored = await storeOidcState(
serverUrl: serverUrl,
state: state,
clientId: provider.clientId,
originUrl: '$serverUrl/oauth/callback', // Use web callback for in-app browser
codeVerifier: pkce?.codeVerifier, // Include PKCE code verifier
);
if (!stateStored) {
return null;
}
// Build authorization URL
final authUri = Uri.parse(provider.authorizationUrl);
final queryParams = <String, String>{
'client_id': provider.clientId,
'response_type': 'code',
'scope': provider.scope,
'redirect_uri': '$serverUrl/api/auth/callback',
'state': state,
};
// Add PKCE parameters if provided
if (pkce != null) {
queryParams['code_challenge'] = pkce.codeChallenge;
queryParams['code_challenge_method'] = 'S256';
}
final authUrl = authUri.replace(queryParameters: queryParams);
return authUrl.toString();
} catch (e) {
return null;
}
}
/// Extract API key from callback URL (for in-app browser)
static String? extractApiKeyFromUrl(String url) {
try {
final uri = Uri.parse(url);
// Check if this is our callback URL with API key
if (uri.path.contains('/oauth/callback')) {
return uri.queryParameters['api_key'];
}
return null;
} catch (e) {
return null;
}
}
/// Handle OIDC callback and extract authentication result
static OidcCallbackResult parseCallback(String callbackUrl) {
try {
final uri = Uri.parse(callbackUrl);
final queryParams = uri.queryParameters;
// Check for error
if (queryParams.containsKey('error')) {
return OidcCallbackResult.error(
error: queryParams['error'] ?? 'Unknown error',
errorDescription: queryParams['error_description'],
);
}
// Check if we have an API key directly (PinePods backend provides this)
final apiKey = queryParams['api_key'];
if (apiKey != null && apiKey.isNotEmpty) {
return OidcCallbackResult.success(
apiKey: apiKey,
state: queryParams['state'],
);
}
// Fallback: Extract traditional OAuth code and state
final code = queryParams['code'];
final state = queryParams['state'];
if (code != null && state != null) {
return OidcCallbackResult.success(
code: code,
state: state,
);
}
return OidcCallbackResult.error(
error: 'missing_parameters',
errorDescription: 'Neither API key nor authorization code found in callback',
);
} catch (e) {
return OidcCallbackResult.error(
error: 'parse_error',
errorDescription: e.toString(),
);
}
}
/// Complete OIDC authentication by verifying with server
static Future<OidcAuthResult> completeAuthentication({
required String serverUrl,
required String code,
required String state,
OidcPkce? pkce,
}) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/auth/oidc_complete');
final requestBody = <String, dynamic>{
'code': code,
'state': state,
};
// Add PKCE verifier if provided
if (pkce != null) {
requestBody['code_verifier'] = pkce.codeVerifier;
}
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: jsonEncode(requestBody),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return OidcAuthResult.success(
apiKey: data['api_key'],
userId: data['user_id'],
serverUrl: normalizedUrl,
);
} else {
final errorData = jsonDecode(response.body);
return OidcAuthResult.failure(
errorData['error'] ?? 'Authentication failed',
);
}
} catch (e) {
return OidcAuthResult.failure('Network error: ${e.toString()}');
}
}
/// Generate secure random code verifier
static String _generateCodeVerifier() {
final random = Random.secure();
// Generate 32 random bytes (256 bits) which will create a ~43 character base64url string
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
// Use base64url encoding (- and _ instead of + and /) and remove padding
return base64UrlEncode(bytes).replaceAll('=', '');
}
/// Generate code challenge from verifier using SHA256
static String _generateCodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64UrlEncode(digest.bytes)
.replaceAll('=', '')
.replaceAll('+', '-')
.replaceAll('/', '_');
}
}
/// OIDC Provider model
class OidcProvider {
final int providerId;
final String providerName;
final String clientId;
final String authorizationUrl;
final String scope;
final String? buttonColor;
final String? buttonText;
final String? buttonTextColor;
final String? iconSvg;
OidcProvider({
required this.providerId,
required this.providerName,
required this.clientId,
required this.authorizationUrl,
required this.scope,
this.buttonColor,
this.buttonText,
this.buttonTextColor,
this.iconSvg,
});
factory OidcProvider.fromJson(Map<String, dynamic> json) {
return OidcProvider(
providerId: json['provider_id'],
providerName: json['provider_name'],
clientId: json['client_id'],
authorizationUrl: json['authorization_url'],
scope: json['scope'],
buttonColor: json['button_color'],
buttonText: json['button_text'],
buttonTextColor: json['button_text_color'],
iconSvg: json['icon_svg'],
);
}
/// Get display text for the provider button
String get displayText => buttonText ?? 'Login with $providerName';
/// Get button color or default
String get buttonColorHex => buttonColor ?? '#007bff';
/// Get button text color or default
String get buttonTextColorHex => buttonTextColor ?? '#ffffff';
}
/// PKCE (Proof Key for Code Exchange) parameters
class OidcPkce {
final String codeVerifier;
final String codeChallenge;
OidcPkce({
required this.codeVerifier,
required this.codeChallenge,
});
}
/// OIDC callback parsing result
class OidcCallbackResult {
final bool isSuccess;
final String? code;
final String? state;
final String? apiKey;
final String? error;
final String? errorDescription;
OidcCallbackResult._({
required this.isSuccess,
this.code,
this.state,
this.apiKey,
this.error,
this.errorDescription,
});
factory OidcCallbackResult.success({
String? code,
String? state,
String? apiKey,
}) {
return OidcCallbackResult._(
isSuccess: true,
code: code,
state: state,
apiKey: apiKey,
);
}
factory OidcCallbackResult.error({
required String error,
String? errorDescription,
}) {
return OidcCallbackResult._(
isSuccess: false,
error: error,
errorDescription: errorDescription,
);
}
bool get hasApiKey => apiKey != null && apiKey!.isNotEmpty;
bool get hasCode => code != null && code!.isNotEmpty;
}
/// OIDC authentication completion result
class OidcAuthResult {
final bool isSuccess;
final String? apiKey;
final int? userId;
final String? serverUrl;
final String? errorMessage;
OidcAuthResult._({
required this.isSuccess,
this.apiKey,
this.userId,
this.serverUrl,
this.errorMessage,
});
factory OidcAuthResult.success({
required String apiKey,
required int userId,
required String serverUrl,
}) {
return OidcAuthResult._(
isSuccess: true,
apiKey: apiKey,
userId: userId,
serverUrl: serverUrl,
);
}
factory OidcAuthResult.failure(String errorMessage) {
return OidcAuthResult._(
isSuccess: false,
errorMessage: errorMessage,
);
}
}

View File

@@ -0,0 +1,504 @@
// lib/services/pinepods/pinepods_audio_service.dart
import 'dart:async';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:logging/logging.dart';
class PinepodsAudioService {
final log = Logger('PinepodsAudioService');
final AudioPlayerService _audioPlayerService;
final PinepodsService _pinepodsService;
final SettingsBloc _settingsBloc;
Timer? _episodeUpdateTimer;
Timer? _userStatsTimer;
int? _currentEpisodeId;
int? _currentUserId;
bool _isYoutube = false;
double _lastRecordedPosition = 0;
/// Callbacks for pause/stop events
Function()? _onPauseCallback;
Function()? _onStopCallback;
PinepodsAudioService(
this._audioPlayerService,
this._pinepodsService,
this._settingsBloc, {
Function()? onPauseCallback,
Function()? onStopCallback,
}) : _onPauseCallback = onPauseCallback,
_onStopCallback = onStopCallback;
/// Play a PinePods episode with full server integration
Future<void> playPinepodsEpisode({
required PinepodsEpisode pinepodsEpisode,
bool resume = true,
}) async {
try {
final settings = _settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
log.warning('No user ID found - cannot play episode with server tracking');
return;
}
_currentUserId = userId;
_isYoutube = pinepodsEpisode.isYoutube;
log.info('Starting PinePods episode playback: ${pinepodsEpisode.episodeTitle}');
// Use the episode ID that's already available from the PinepodsEpisode
final episodeId = pinepodsEpisode.episodeId;
if (episodeId == 0) {
log.warning('Episode ID is 0 - cannot track playback');
return;
}
_currentEpisodeId = episodeId;
// Get podcast ID for settings
final podcastId = await _pinepodsService.getPodcastIdFromEpisode(
episodeId,
userId,
pinepodsEpisode.isYoutube,
);
// Get playback settings (speed, skip times)
final playDetails = await _pinepodsService.getPlayEpisodeDetails(
userId,
podcastId,
pinepodsEpisode.isYoutube,
);
// Fetch podcast 2.0 data including chapters
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId);
// Convert PinepodsEpisode to Episode for the audio player
final episode = _convertToEpisode(pinepodsEpisode, playDetails, podcast2Data);
// Set playback speed
await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed);
// Start playing with the existing audio service
await _audioPlayerService.playEpisode(episode: episode, resume: resume);
// Handle skip intro if enabled and episode just started
if (playDetails.startSkip > 0 && !resume) {
await Future.delayed(const Duration(milliseconds: 500)); // Wait for player to initialize
await _audioPlayerService.seek(position: playDetails.startSkip);
}
// Add to history
log.info('Adding episode $episodeId to history for user $userId');
final initialPosition = resume ? (pinepodsEpisode.listenDuration ?? 0).toDouble() : 0.0;
await _pinepodsService.recordListenDuration(
episodeId,
userId,
initialPosition, // Send seconds like web app does
pinepodsEpisode.isYoutube,
);
// Queue episode for tracking
log.info('Queueing episode $episodeId for user $userId');
await _pinepodsService.queueEpisode(
episodeId,
userId,
pinepodsEpisode.isYoutube,
);
// Increment played count
log.info('Incrementing played count for user $userId');
await _pinepodsService.incrementPlayed(userId);
// Start periodic updates
_startPeriodicUpdates();
log.info('PinePods episode playback started successfully');
} catch (e) {
log.severe('Error playing PinePods episode: $e');
rethrow;
}
}
/// Start periodic updates to server
void _startPeriodicUpdates() {
_stopPeriodicUpdates(); // Clean up any existing timers
log.info('Starting periodic updates - episode position every 15s, user stats every 60s');
// Episode position updates every 15 seconds (more frequent for reliability)
_episodeUpdateTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => _safeUpdateEpisodePosition(),
);
// User listen time updates every 60 seconds
_userStatsTimer = Timer.periodic(
const Duration(seconds: 60),
(_) => _safeUpdateUserListenTime(),
);
}
/// Safely update episode position without affecting playback
void _safeUpdateEpisodePosition() async {
try {
await _updateEpisodePosition();
} catch (e) {
log.warning('Periodic sync completely failed but playback continues: $e');
// Completely isolate any network failures from affecting playback
}
}
/// Update episode position on server
Future<void> _updateEpisodePosition() async {
// Updating episode position
if (_currentEpisodeId == null || _currentUserId == null) {
log.warning('Skipping scheduled sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
return;
}
try {
final positionState = _audioPlayerService.playPosition?.value;
if (positionState == null) return;
final currentPosition = positionState.position.inSeconds.toDouble();
// Only update if position has changed by more than 2 seconds (more responsive)
if ((currentPosition - _lastRecordedPosition).abs() > 2) {
// Convert seconds to minutes for the API
final currentPositionMinutes = currentPosition / 60.0;
// Position changed, syncing to server
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
currentPosition, // Send seconds like web app does
_isYoutube,
);
_lastRecordedPosition = currentPosition;
// Sync completed successfully
}
} catch (e) {
log.warning('Failed to update episode position: $e');
}
}
/// Safely update user listen time without affecting playback
void _safeUpdateUserListenTime() async {
try {
await _updateUserListenTime();
} catch (e) {
log.warning('User stats sync completely failed but playback continues: $e');
// Completely isolate any network failures from affecting playback
}
}
/// Update user listen time statistics
Future<void> _updateUserListenTime() async {
if (_currentUserId == null) return;
try {
await _pinepodsService.incrementListenTime(_currentUserId!);
// User listen time updated
} catch (e) {
log.warning('Failed to update user listen time: $e');
}
}
/// Sync current position to server immediately (for pause/stop events)
Future<void> syncCurrentPositionToServer() async {
// Syncing current position to server
if (_currentEpisodeId == null || _currentUserId == null) {
log.warning('Cannot sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
return;
}
try {
final positionState = _audioPlayerService.playPosition?.value;
if (positionState == null) {
log.warning('Cannot sync - positionState is null');
return;
}
final currentPosition = positionState.position.inSeconds.toDouble();
log.info('Syncing position to server: ${currentPosition}s for episode $_currentEpisodeId');
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
currentPosition, // Send seconds like web app does
_isYoutube,
);
_lastRecordedPosition = currentPosition;
log.info('Successfully synced position to server: ${currentPosition}s');
} catch (e) {
log.warning('Failed to sync position to server: $e');
log.warning('Stack trace: ${StackTrace.current}');
}
}
/// Get server position for current episode
Future<double?> getServerPosition() async {
if (_currentEpisodeId == null || _currentUserId == null) return null;
try {
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
_currentEpisodeId!,
_currentUserId!,
isYoutube: _isYoutube,
);
return episodeMetadata?.listenDuration?.toDouble();
} catch (e) {
log.warning('Failed to get server position: $e');
return null;
}
}
/// Get server position for any episode
Future<double?> getServerPositionForEpisode(int episodeId, int userId, bool isYoutube) async {
try {
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
episodeId,
userId,
isYoutube: isYoutube,
);
return episodeMetadata?.listenDuration?.toDouble();
} catch (e) {
log.warning('Failed to get server position for episode $episodeId: $e');
return null;
}
}
/// Record listen duration when episode ends or is stopped
Future<void> recordListenDuration(double listenDuration) async {
if (_currentEpisodeId == null || _currentUserId == null) return;
try {
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
listenDuration,
_isYoutube,
);
log.info('Recorded listen duration: ${listenDuration}s');
} catch (e) {
log.warning('Failed to record listen duration: $e');
}
}
/// Handle pause event - sync position to server
Future<void> onPause() async {
try {
await syncCurrentPositionToServer();
log.info('Pause event handled - position synced to server');
} catch (e) {
log.warning('Pause sync failed but pause succeeded: $e');
}
_onPauseCallback?.call();
}
/// Handle stop event - sync position to server
Future<void> onStop() async {
try {
await syncCurrentPositionToServer();
log.info('Stop event handled - position synced to server');
} catch (e) {
log.warning('Stop sync failed but stop succeeded: $e');
}
_onStopCallback?.call();
}
/// Stop periodic updates
void _stopPeriodicUpdates() {
_episodeUpdateTimer?.cancel();
_userStatsTimer?.cancel();
_episodeUpdateTimer = null;
_userStatsTimer = null;
}
/// Convert PinepodsEpisode to Episode for the audio player
Episode _convertToEpisode(PinepodsEpisode pinepodsEpisode, PlayEpisodeDetails playDetails, Map<String, dynamic>? podcast2Data) {
// Determine the content URL
String contentUrl;
if (pinepodsEpisode.downloaded && _currentEpisodeId != null && _currentUserId != null) {
// Use stream URL for local episodes
contentUrl = _pinepodsService.getStreamUrl(
_currentEpisodeId!,
_currentUserId!,
isYoutube: pinepodsEpisode.isYoutube,
isLocal: true,
);
} else if (pinepodsEpisode.isYoutube && _currentEpisodeId != null && _currentUserId != null) {
// Use stream URL for YouTube episodes
contentUrl = _pinepodsService.getStreamUrl(
_currentEpisodeId!,
_currentUserId!,
isYoutube: true,
isLocal: false,
);
} else {
// Use original URL for external episodes
contentUrl = pinepodsEpisode.episodeUrl;
}
// Process podcast 2.0 data
List<Chapter> chapters = [];
List<Person> persons = [];
List<TranscriptUrl> transcriptUrls = [];
String? chaptersUrl;
if (podcast2Data != null) {
// Extract chapters data
final chaptersData = podcast2Data['chapters'] as List<dynamic>?;
if (chaptersData != null) {
try {
chapters = chaptersData.map((chapterData) {
return Chapter(
title: chapterData['title'] ?? '',
startTime: _parseDouble(chapterData['startTime'] ?? chapterData['start_time'] ?? 0) ?? 0.0,
endTime: _parseDouble(chapterData['endTime'] ?? chapterData['end_time']),
imageUrl: chapterData['img'] ?? chapterData['image'],
url: chapterData['url'],
toc: chapterData['toc'] ?? true,
);
}).toList();
log.info('Loaded ${chapters.length} chapters from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing chapters from podcast 2.0 data: $e');
}
}
// Extract chapters URL if available
chaptersUrl = podcast2Data['chapters_url'];
// Extract persons data
final personsData = podcast2Data['people'] as List<dynamic>?;
if (personsData != null) {
try {
persons = personsData.map((personData) {
return Person(
name: personData['name'] ?? '',
role: personData['role'] ?? '',
group: personData['group'] ?? '',
image: personData['img'],
link: personData['href'],
);
}).toList();
log.info('Loaded ${persons.length} persons from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing persons from podcast 2.0 data: $e');
}
}
// Extract transcript data
final transcriptsData = podcast2Data['transcripts'] as List<dynamic>?;
if (transcriptsData != null) {
try {
transcriptUrls = transcriptsData.map((transcriptData) {
TranscriptFormat format = TranscriptFormat.unsupported;
// Determine format from URL, mime_type, or type field
final url = transcriptData['url'] ?? '';
final mimeType = transcriptData['mime_type'] ?? '';
final type = transcriptData['type'] ?? '';
// Processing transcript
if (url.toLowerCase().contains('.json') ||
mimeType.toLowerCase().contains('json') ||
type.toLowerCase().contains('json')) {
format = TranscriptFormat.json;
// Detected JSON transcript
} else if (url.toLowerCase().contains('.srt') ||
mimeType.toLowerCase().contains('srt') ||
type.toLowerCase().contains('srt') ||
type.toLowerCase().contains('subrip') ||
url.toLowerCase().contains('subrip')) {
format = TranscriptFormat.subrip;
// Detected SubRip transcript
} else if (url.toLowerCase().contains('transcript') ||
mimeType.toLowerCase().contains('html') ||
type.toLowerCase().contains('html')) {
format = TranscriptFormat.html;
// Detected HTML transcript
} else {
log.warning('Transcript format not recognized: mimeType=$mimeType, type=$type');
}
return TranscriptUrl(
url: url,
type: format,
language: transcriptData['language'] ?? transcriptData['lang'] ?? 'en',
rel: transcriptData['rel'],
);
}).toList();
log.info('Loaded ${transcriptUrls.length} transcript URLs from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing transcripts from podcast 2.0 data: $e');
}
}
}
return Episode(
guid: pinepodsEpisode.episodeUrl,
podcast: pinepodsEpisode.podcastName,
title: pinepodsEpisode.episodeTitle,
description: pinepodsEpisode.episodeDescription,
link: pinepodsEpisode.episodeUrl,
publicationDate: DateTime.tryParse(pinepodsEpisode.episodePubDate) ?? DateTime.now(),
author: '',
duration: (pinepodsEpisode.episodeDuration * 1000).round(), // Convert to milliseconds
contentUrl: contentUrl,
position: pinepodsEpisode.completed ? 0 : ((pinepodsEpisode.listenDuration ?? 0) * 1000).round(), // Convert to milliseconds, reset to 0 for completed episodes
imageUrl: pinepodsEpisode.episodeArtwork,
played: pinepodsEpisode.completed,
chapters: chapters,
chaptersUrl: chaptersUrl,
persons: persons,
transcriptUrls: transcriptUrls,
);
}
/// Helper method to safely parse double values
double? _parseDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
try {
return double.parse(value);
} catch (e) {
log.warning('Failed to parse double from string: $value');
return null;
}
}
return null;
}
/// Clean up resources
void dispose() {
_stopPeriodicUpdates();
_currentEpisodeId = null;
_currentUserId = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,808 @@
// Copyright 2019 Ben Hills. 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:collection';
import 'dart:io';
import 'package:pinepods_mobile/api/podcast/podcast_api.dart';
import 'package:pinepods_mobile/core/utils.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/l10n/messages_all.dart';
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
import 'package:pinepods_mobile/state/episode_state.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:podcast_search/podcast_search.dart' as podcast_search;
class MobilePodcastService extends PodcastService {
final descriptionRegExp1 = RegExp(r'(</p><br>|</p></br>|<p><br></p>|<p></br></p>)');
final descriptionRegExp2 = RegExp(r'(<p><br></p>|<p></br></p>)');
final log = Logger('MobilePodcastService');
final _cache = _PodcastCache(maxItems: 10, expiration: const Duration(minutes: 30));
var _categories = <String>[];
var _intlCategories = <String?>[];
var _intlCategoriesSorted = <String>[];
MobilePodcastService({
required super.api,
required super.repository,
required super.settingsService,
}) {
_init();
}
Future<void> _init() async {
final List<Locale> systemLocales = PlatformDispatcher.instance.locales;
var currentLocale = Platform.localeName;
// Attempt to get current locale
var supportedLocale = await initializeMessages(Platform.localeName);
// If we do not support the default, try all supported locales
if (!supportedLocale) {
for (var l in systemLocales) {
supportedLocale = await initializeMessages('${l.languageCode}_${l.countryCode}');
if (supportedLocale) {
currentLocale = '${l.languageCode}_${l.countryCode}';
break;
}
}
if (!supportedLocale) {
// We give up! Default to English
currentLocale = 'en';
supportedLocale = await initializeMessages(currentLocale);
}
}
_setupGenres(currentLocale);
/// Listen for user changes in search provider. If changed, reload the genre list
settingsService.settingsListener.where((event) => event == 'search').listen((event) {
_setupGenres(currentLocale);
});
}
void _setupGenres(String locale) {
var categoryList = '';
/// Fetch the correct categories for the current local and selected provider.
if (settingsService.searchProvider == 'itunes') {
_categories = PodcastService.itunesGenres;
categoryList = Intl.message('discovery_categories_itunes', locale: locale);
} else {
_categories = PodcastService.podcastIndexGenres;
categoryList = Intl.message('discovery_categories_pindex', locale: locale);
}
_intlCategories = categoryList.split(',');
_intlCategoriesSorted = categoryList.split(',');
_intlCategoriesSorted.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
}
@override
Future<podcast_search.SearchResult> search({
required String term,
String? country,
String? attribute,
int? limit,
String? language,
int version = 0,
bool explicit = false,
}) {
return api.search(
term,
country: country,
attribute: attribute,
limit: limit,
language: language,
explicit: explicit,
searchProvider: settingsService.searchProvider,
);
}
@override
Future<podcast_search.SearchResult> charts({
int size = 20,
String? genre,
String? countryCode = '',
String? languageCode = '',
}) {
var providerGenre = _decodeGenre(genre);
return api.charts(
size: size,
searchProvider: settingsService.searchProvider,
genre: providerGenre,
countryCode: countryCode,
languageCode: languageCode,
);
}
@override
List<String> genres() {
return _intlCategoriesSorted;
}
/// Loads the specified [Podcast]. If the Podcast instance has an ID we'll fetch
/// it from storage. If not, we'll check the cache to see if we have seen it
/// recently and return that if available. If not, we'll make a call to load
/// it from the network.
/// TODO: The complexity of this method is now too high - needs to be refactored.
@override
Future<Podcast?> loadPodcast({
required Podcast podcast,
bool highlightNewEpisodes = false,
bool refresh = false,
}) async {
log.fine('loadPodcast. ID ${podcast.id} (refresh $refresh)');
if (podcast.id == null || refresh) {
podcast_search.Podcast? loadedPodcast;
var imageUrl = podcast.imageUrl;
var thumbImageUrl = podcast.thumbImageUrl;
var sourceUrl = podcast.url;
if (!refresh) {
log.fine('Not a refresh so try to fetch from cache');
loadedPodcast = _cache.item(podcast.url);
}
// If we didn't get a cache hit load the podcast feed.
if (loadedPodcast == null) {
var tries = 2;
var url = podcast.url;
while (tries-- > 0) {
try {
log.fine('Loading podcast from feed $url');
loadedPodcast = await _loadPodcastFeed(url: url);
tries = 0;
} catch (e) {
if (tries > 0) {
//TODO: Needs improving to only fall back if original URL was http and we forced it up to https.
if (e is podcast_search.PodcastCertificateException && url.startsWith('https')) {
log.fine('Certificate error whilst fetching podcast. Fallback to http and try again');
url = url.replaceFirst('https', 'http');
}
} else {
rethrow;
}
}
}
_cache.store(loadedPodcast!);
}
final title = _format(loadedPodcast.title);
final description = _format(loadedPodcast.description);
final copyright = _format(loadedPodcast.copyright);
final funding = <Funding>[];
final persons = <Person>[];
final existingEpisodes = await repository.findEpisodesByPodcastGuid(sourceUrl);
// If imageUrl is null we have not loaded the podcast as a result of a search.
if (imageUrl == null || imageUrl.isEmpty || refresh) {
imageUrl = loadedPodcast.image;
thumbImageUrl = loadedPodcast.image;
}
for (var f in loadedPodcast.funding) {
if (f.url != null) {
funding.add(Funding(url: f.url!, value: f.value ?? ''));
}
}
for (var p in loadedPodcast.persons) {
persons.add(Person(
name: p.name,
role: p.role ?? '',
group: p.group ?? '',
image: p.image,
link: p.link,
));
}
Podcast pc = Podcast(
guid: sourceUrl,
url: sourceUrl,
link: loadedPodcast.link,
title: title,
description: description,
imageUrl: imageUrl,
thumbImageUrl: thumbImageUrl,
copyright: copyright,
funding: funding,
persons: persons,
episodes: <Episode>[],
);
/// We could be following this podcast already. Let's check.
var follow = await repository.findPodcastByGuid(sourceUrl);
if (follow != null) {
// We are, so swap in the stored ID so we update the saved version later.
pc.id = follow.id;
// And preserve any filter & sort applied
pc.filter = follow.filter;
pc.sort = follow.sort;
}
// Usually, episodes are order by reverse publication date - but not always.
// Enforce that ordering. To prevent unnecessary sorting, we'll sample the
// first two episodes to see what order they are in.
if (loadedPodcast.episodes.length > 1) {
if (loadedPodcast.episodes[0].publicationDate!.millisecondsSinceEpoch <
loadedPodcast.episodes[1].publicationDate!.millisecondsSinceEpoch) {
loadedPodcast.episodes.sort((e1, e2) => e2.publicationDate!.compareTo(e1.publicationDate!));
}
}
// Loop through all episodes in the feed and check to see if we already have that episode
// stored. If we don't, it's a new episode so add it; if we do update our copy in case it's changed.
for (final episode in loadedPodcast.episodes) {
final existingEpisode = existingEpisodes.firstWhereOrNull((ep) => ep!.guid == episode.guid);
final author = episode.author?.replaceAll('\n', '').trim() ?? '';
final title = _format(episode.title);
final description = _format(episode.description);
final content = episode.content;
final episodeImage = episode.imageUrl == null || episode.imageUrl!.isEmpty ? pc.imageUrl : episode.imageUrl;
final episodeThumbImage =
episode.imageUrl == null || episode.imageUrl!.isEmpty ? pc.thumbImageUrl : episode.imageUrl;
final duration = episode.duration?.inSeconds ?? 0;
final transcriptUrls = <TranscriptUrl>[];
final episodePersons = <Person>[];
for (var t in episode.transcripts) {
late TranscriptFormat type;
switch (t.type) {
case podcast_search.TranscriptFormat.subrip:
type = TranscriptFormat.subrip;
break;
case podcast_search.TranscriptFormat.json:
type = TranscriptFormat.json;
break;
case podcast_search.TranscriptFormat.vtt:
type = TranscriptFormat.subrip; // Map VTT to subrip for now
break;
case podcast_search.TranscriptFormat.unsupported:
type = TranscriptFormat.unsupported;
break;
}
transcriptUrls.add(TranscriptUrl(url: t.url, type: type));
}
if (episode.persons.isNotEmpty) {
for (var p in episode.persons) {
episodePersons.add(Person(
name: p.name,
role: p.role!,
group: p.group!,
image: p.image,
link: p.link,
));
}
} else if (persons.isNotEmpty) {
episodePersons.addAll(persons);
}
if (existingEpisode == null) {
pc.newEpisodes = highlightNewEpisodes && pc.id != null;
pc.episodes.add(Episode(
highlight: pc.newEpisodes,
pguid: pc.guid,
guid: episode.guid,
podcast: pc.title,
title: title,
description: description,
content: content,
author: author,
season: episode.season ?? 0,
episode: episode.episode ?? 0,
contentUrl: episode.contentUrl,
link: episode.link,
imageUrl: episodeImage,
thumbImageUrl: episodeThumbImage,
duration: duration,
publicationDate: episode.publicationDate,
chaptersUrl: episode.chapters?.url,
transcriptUrls: transcriptUrls,
persons: episodePersons,
chapters: <Chapter>[],
));
} else {
/// Check if the ancillary episode data has changed.
if (!listEquals(existingEpisode.persons, episodePersons) ||
!listEquals(existingEpisode.transcriptUrls, transcriptUrls)) {
pc.updatedEpisodes = true;
}
existingEpisode.title = title;
existingEpisode.description = description;
existingEpisode.content = content;
existingEpisode.author = author;
existingEpisode.season = episode.season ?? 0;
existingEpisode.episode = episode.episode ?? 0;
existingEpisode.contentUrl = episode.contentUrl;
existingEpisode.link = episode.link;
existingEpisode.imageUrl = episodeImage;
existingEpisode.thumbImageUrl = episodeThumbImage;
existingEpisode.publicationDate = episode.publicationDate;
existingEpisode.chaptersUrl = episode.chapters?.url;
existingEpisode.transcriptUrls = transcriptUrls;
existingEpisode.persons = episodePersons;
// If the source duration is 0 do not update any saved, calculated duration.
if (duration > 0) {
existingEpisode.duration = duration;
}
pc.episodes.add(existingEpisode);
// Clear this episode from our existing list
existingEpisodes.remove(existingEpisode);
}
}
// Add any downloaded episodes that are no longer in the feed - they
// may have expired but we still want them.
var expired = <Episode>[];
for (final episode in existingEpisodes) {
var feedEpisode = loadedPodcast.episodes.firstWhereOrNull((ep) => ep.guid == episode!.guid);
if (feedEpisode == null && episode!.downloaded) {
pc.episodes.add(episode);
} else {
expired.add(episode!);
}
}
// If we are subscribed to this podcast and are simply refreshing we need to save the updated subscription.
// A non-null ID indicates this podcast is subscribed too. We also need to delete any expired episodes.
if (podcast.id != null && refresh) {
await repository.deleteEpisodes(expired);
pc = await repository.savePodcast(pc);
// Phew! Now, after all that, we have have a podcast filter in place. All episodes will have
// been saved, but we might not want to display them all. Let's filter.
pc.episodes = _sortAndFilterEpisodes(pc);
}
return pc;
} else {
return await loadPodcastById(id: podcast.id ?? 0);
}
}
@override
Future<Podcast?> loadPodcastById({required int id}) {
return repository.findPodcastById(id);
}
@override
Future<List<Chapter>> loadChaptersByUrl({required String url}) async {
var c = await _loadChaptersByUrl(url);
var chapters = <Chapter>[];
if (c != null) {
for (var chapter in c.chapters) {
chapters.add(Chapter(
title: chapter.title,
url: chapter.url,
imageUrl: chapter.imageUrl,
startTime: chapter.startTime,
endTime: chapter.endTime,
toc: chapter.toc,
));
}
}
return chapters;
}
/// This method will load either of the supported transcript types. Currently, we do not support
/// word level highlighting of transcripts, therefore this routine will also group transcript
/// lines together by speaker and/or timeframe.
@override
Future<Transcript> loadTranscriptByUrl({required TranscriptUrl transcriptUrl}) async {
var subtitles = <Subtitle>[];
var result = await _loadTranscriptByUrl(transcriptUrl);
var threshold = const Duration(seconds: 5);
Subtitle? groupSubtitle;
if (result != null) {
for (var index = 0; index < result.subtitles.length; index++) {
var subtitle = result.subtitles[index];
var completeGroup = true;
var data = subtitle.data;
if (groupSubtitle != null) {
if (transcriptUrl.type == TranscriptFormat.json) {
if (groupSubtitle.speaker == subtitle.speaker &&
(subtitle.start.compareTo(groupSubtitle.start + threshold) < 0 || subtitle.data.length == 1)) {
/// We need to handle transcripts that have spaces between sentences, and those
/// which do not.
if (groupSubtitle.data != null &&
(groupSubtitle.data!.endsWith(' ') || subtitle.data.startsWith(' ') || subtitle.data.length == 1)) {
data = '${groupSubtitle.data}${subtitle.data}';
} else {
data = '${groupSubtitle.data} ${subtitle.data.trim()}';
}
completeGroup = false;
}
} else {
if (groupSubtitle.start == subtitle.start) {
if (groupSubtitle.data != null &&
(groupSubtitle.data!.endsWith(' ') || subtitle.data.startsWith(' ') || subtitle.data.length == 1)) {
data = '${groupSubtitle.data}${subtitle.data}';
} else {
data = '${groupSubtitle.data} ${subtitle.data.trim()}';
}
completeGroup = false;
}
}
} else {
completeGroup = false;
groupSubtitle = Subtitle(
data: subtitle.data,
speaker: subtitle.speaker,
start: subtitle.start,
end: subtitle.end,
index: subtitle.index,
);
}
/// If we have a complete group, or we're the very last subtitle - add it.
if (completeGroup || index == result.subtitles.length - 1) {
groupSubtitle.data = groupSubtitle.data?.trim();
subtitles.add(groupSubtitle);
groupSubtitle = Subtitle(
data: subtitle.data,
speaker: subtitle.speaker,
start: subtitle.start,
end: subtitle.end,
index: subtitle.index,
);
} else {
groupSubtitle = Subtitle(
data: data,
speaker: subtitle.speaker,
start: groupSubtitle.start,
end: subtitle.end,
index: groupSubtitle.index,
);
}
}
}
return Transcript(subtitles: subtitles);
}
@override
Future<List<Episode>> loadDownloads() async {
return repository.findDownloads();
}
@override
Future<List<Episode>> loadEpisodes() async {
return repository.findAllEpisodes();
}
@override
Future<void> deleteDownload(Episode episode) async {
// If this episode is currently downloading, cancel the download first.
if (episode.downloadState == DownloadState.downloaded) {
if (settingsService.markDeletedEpisodesAsPlayed) {
episode.played = true;
}
} else if (episode.downloadState == DownloadState.downloading && episode.downloadPercentage! < 100) {
await FlutterDownloader.cancel(taskId: episode.downloadTaskId!);
}
episode.downloadTaskId = null;
episode.downloadPercentage = 0;
episode.position = 0;
episode.downloadState = DownloadState.none;
if (episode.transcriptId != null && episode.transcriptId! > 0) {
await repository.deleteTranscriptById(episode.transcriptId!);
}
await repository.saveEpisode(episode);
if (await hasStoragePermission()) {
final f = File.fromUri(Uri.file(await resolvePath(episode)));
log.fine('Deleting file ${f.path}');
if (await f.exists()) {
f.delete();
}
}
return;
}
@override
Future<void> toggleEpisodePlayed(Episode episode) async {
episode.played = !episode.played;
episode.position = 0;
repository.saveEpisode(episode);
}
@override
Future<List<Podcast>> subscriptions() {
return repository.subscriptions();
}
@override
Future<void> unsubscribe(Podcast podcast) async {
if (await hasStoragePermission()) {
final filename = join(await getStorageDirectory(), safeFile(podcast.title));
final d = Directory.fromUri(Uri.file(filename));
if (await d.exists()) {
await d.delete(recursive: true);
}
}
return repository.deletePodcast(podcast);
}
@override
Future<Podcast?> subscribe(Podcast? podcast) async {
// We may already have episodes download for this podcast before the user
// hit subscribe.
if (podcast != null && podcast.guid != null) {
var savedEpisodes = await repository.findEpisodesByPodcastGuid(podcast.guid!);
if (podcast.episodes.isNotEmpty) {
for (var episode in podcast.episodes) {
var savedEpisode = savedEpisodes.firstWhereOrNull((ep) => ep!.guid == episode.guid);
if (savedEpisode != null) {
episode.pguid = podcast.guid;
}
}
}
return repository.savePodcast(podcast);
}
return Future.value(null);
}
@override
Future<Podcast?> save(Podcast podcast, {bool withEpisodes = true}) async {
return repository.savePodcast(podcast, withEpisodes: withEpisodes);
}
@override
Future<Episode> saveEpisode(Episode episode) async {
return repository.saveEpisode(episode);
}
@override
Future<List<Episode>> saveEpisodes(List<Episode> episodes) async {
return repository.saveEpisodes(episodes);
}
@override
Future<Transcript> saveTranscript(Transcript transcript) async {
return repository.saveTranscript(transcript);
}
@override
Future<void> saveQueue(List<Episode> episodes) async {
await repository.saveQueue(episodes);
}
@override
Future<List<Episode>> loadQueue() async {
return await repository.loadQueue();
}
/// Remove HTML padding from the content. The padding may look fine within
/// the context of a browser, but can look out of place on a mobile screen.
String _format(String? input) {
return input?.trim().replaceAll(descriptionRegExp2, '').replaceAll(descriptionRegExp1, '</p>') ?? '';
}
Future<podcast_search.Chapters?> _loadChaptersByUrl(String url) {
return compute<_FeedComputer, podcast_search.Chapters?>(
_loadChaptersByUrlCompute, _FeedComputer(api: api, url: url));
}
static Future<podcast_search.Chapters?> _loadChaptersByUrlCompute(_FeedComputer c) async {
podcast_search.Chapters? result;
try {
result = await c.api.loadChapters(c.url);
} catch (e) {
final log = Logger('MobilePodcastService');
log.fine('Failed to download chapters');
log.fine(e);
}
return result;
}
Future<podcast_search.Transcript?> _loadTranscriptByUrl(TranscriptUrl transcriptUrl) {
return compute<_TranscriptComputer, podcast_search.Transcript?>(
_loadTranscriptByUrlCompute, _TranscriptComputer(api: api, transcriptUrl: transcriptUrl));
}
static Future<podcast_search.Transcript?> _loadTranscriptByUrlCompute(_TranscriptComputer c) async {
podcast_search.Transcript? result;
try {
result = await c.api.loadTranscript(c.transcriptUrl);
} catch (e) {
final log = Logger('MobilePodcastService');
log.fine('Failed to download transcript');
log.fine(e);
}
return result;
}
/// Loading and parsing a podcast feed can take several seconds. Larger feeds
/// can end up blocking the UI thread. We perform our feed load in a
/// separate isolate so that the UI can continue to present a loading
/// indicator whilst the data is fetched without locking the UI.
Future<podcast_search.Podcast> _loadPodcastFeed({required String url}) {
return compute<_FeedComputer, podcast_search.Podcast>(_loadPodcastFeedCompute, _FeedComputer(api: api, url: url));
}
/// We have to separate the process of calling compute as you cannot use
/// named parameters with compute. The podcast feed load API uses named
/// parameters so we need to change it to a single, positional parameter.
static Future<podcast_search.Podcast> _loadPodcastFeedCompute(_FeedComputer c) {
return c.api.loadFeed(c.url);
}
/// The service providers expect the genre to be passed in English. This function takes
/// the selected genre and returns the English version.
String _decodeGenre(String? genre) {
var index = _intlCategories.indexOf(genre);
var decodedGenre = '';
if (index >= 0) {
decodedGenre = _categories[index];
if (decodedGenre == '<All>') {
decodedGenre = '';
}
}
return decodedGenre;
}
List<Episode> _sortAndFilterEpisodes(Podcast podcast) {
var filteredEpisodes = <Episode>[];
switch (podcast.filter) {
case PodcastEpisodeFilter.none:
filteredEpisodes = podcast.episodes;
break;
case PodcastEpisodeFilter.started:
filteredEpisodes = podcast.episodes.where((e) => e.highlight || e.position > 0).toList();
break;
case PodcastEpisodeFilter.played:
filteredEpisodes = podcast.episodes.where((e) => e.highlight || e.played).toList();
break;
case PodcastEpisodeFilter.notPlayed:
filteredEpisodes = podcast.episodes.where((e) => e.highlight || !e.played).toList();
break;
}
switch (podcast.sort) {
case PodcastEpisodeSort.none:
case PodcastEpisodeSort.latestFirst:
filteredEpisodes.sort((e1, e2) => e2.publicationDate!.compareTo(e1.publicationDate!));
case PodcastEpisodeSort.earliestFirst:
filteredEpisodes.sort((e1, e2) => e1.publicationDate!.compareTo(e2.publicationDate!));
case PodcastEpisodeSort.alphabeticalAscending:
filteredEpisodes.sort((e1, e2) => e1.title!.toLowerCase().compareTo(e2.title!.toLowerCase()));
case PodcastEpisodeSort.alphabeticalDescending:
filteredEpisodes.sort((e1, e2) => e2.title!.toLowerCase().compareTo(e1.title!.toLowerCase()));
}
return filteredEpisodes;
}
@override
Stream<Podcast?>? get podcastListener => repository.podcastListener;
@override
Stream<EpisodeState>? get episodeListener => repository.episodeListener;
}
/// A simple cache to reduce the number of network calls when loading podcast
/// feeds. We can cache up to [maxItems] items with each item having an
/// expiration time of [expiration]. The cache works as a FIFO queue, so if we
/// attempt to store a new item in the cache and it is full we remove the
/// first (and therefore oldest) item from the cache. Cache misses are returned
/// as null.
class _PodcastCache {
final int maxItems;
final Duration expiration;
final Queue<_CacheItem> _queue;
_PodcastCache({required this.maxItems, required this.expiration}) : _queue = Queue<_CacheItem>();
podcast_search.Podcast? item(String key) {
var hit = _queue.firstWhereOrNull((_CacheItem i) => i.podcast.url == key);
podcast_search.Podcast? p;
if (hit != null) {
var now = DateTime.now();
if (now.difference(hit.dateAdded) <= expiration) {
p = hit.podcast;
} else {
_queue.remove(hit);
}
}
return p;
}
void store(podcast_search.Podcast podcast) {
if (_queue.length == maxItems) {
_queue.removeFirst();
}
_queue.addLast(_CacheItem(podcast));
}
}
/// A simple class that stores an instance of a Podcast and the
/// date and time it was added. This can be used by the cache to
/// keep a small and up-to-date list of searched for Podcasts.
class _CacheItem {
final podcast_search.Podcast podcast;
final DateTime dateAdded;
_CacheItem(this.podcast) : dateAdded = DateTime.now();
}
class _FeedComputer {
final PodcastApi api;
final String url;
_FeedComputer({required this.api, required this.url});
}
class _TranscriptComputer {
final PodcastApi api;
final TranscriptUrl transcriptUrl;
_TranscriptComputer({required this.api, required this.transcriptUrl});
}

View File

@@ -0,0 +1,230 @@
// 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/api/podcast/podcast_api.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/services/settings/settings_service.dart';
import 'package:pinepods_mobile/state/episode_state.dart';
import 'package:podcast_search/podcast_search.dart' as pcast;
/// The [PodcastService] handles interactions around podcasts including searching, fetching
/// the trending/charts podcasts, loading the podcast RSS feed and anciallary items such as
/// chapters and transcripts.
abstract class PodcastService {
final PodcastApi api;
final Repository repository;
final SettingsService settingsService;
static const itunesGenres = [
'<All>',
'Arts',
'Business',
'Comedy',
'Education',
'Fiction',
'Government',
'Health & Fitness',
'History',
'Kids & Family',
'Leisure',
'Music',
'News',
'Religion & Spirituality',
'Science',
'Society & Culture',
'Sports',
'TV & Film',
'Technology',
'True Crime',
];
static const podcastIndexGenres = <String>[
'<All>',
'After-Shows',
'Alternative',
'Animals',
'Animation',
'Arts',
'Astronomy',
'Automotive',
'Aviation',
'Baseball',
'Basketball',
'Beauty',
'Books',
'Buddhism',
'Business',
'Careers',
'Chemistry',
'Christianity',
'Climate',
'Comedy',
'Commentary',
'Courses',
'Crafts',
'Cricket',
'Cryptocurrency',
'Culture',
'Daily',
'Design',
'Documentary',
'Drama',
'Earth',
'Education',
'Entertainment',
'Entrepreneurship',
'Family',
'Fantasy',
'Fashion',
'Fiction',
'Film',
'Fitness',
'Food',
'Football',
'Games',
'Garden',
'Golf',
'Government',
'Health',
'Hinduism',
'History',
'Hobbies',
'Hockey',
'Home',
'How-To',
'Improv',
'Interviews',
'Investing',
'Islam',
'Journals',
'Judaism',
'Kids',
'Language',
'Learning',
'Leisure',
'Life',
'Management',
'Manga',
'Marketing',
'Mathematics',
'Medicine',
'Mental',
'Music',
'Natural',
'Nature',
'News',
'Non-Profit',
'Nutrition',
'Parenting',
'Performing',
'Personal',
'Pets',
'Philosophy',
'Physics',
'Places',
'Politics',
'Relationships',
'Religion',
'Reviews',
'Role-Playing',
'Rugby',
'Running',
'Science',
'Self-Improvement',
'Sexuality',
'Soccer',
'Social',
'Society',
'Spirituality',
'Sports',
'Stand-Up',
'Stories',
'Swimming',
'TV',
'Tabletop',
'Technology',
'Tennis',
'Travel',
'True Crime',
'Video-Games',
'Visual',
'Volleyball',
'Weather',
'Wilderness',
'Wrestling',
];
PodcastService({
required this.api,
required this.repository,
required this.settingsService,
});
Future<pcast.SearchResult> search({
required String term,
String? country,
String? attribute,
int? limit,
String? language,
int version = 0,
bool explicit = false,
});
Future<pcast.SearchResult> charts({
required int size,
String? genre,
String? countryCode,
String? languageCode,
});
List<String> genres();
Future<Podcast?> loadPodcast({
required Podcast podcast,
bool highlightNewEpisodes = false,
bool refresh = false,
});
Future<Podcast?> loadPodcastById({
required int id,
});
Future<List<Episode>> loadDownloads();
Future<List<Episode>> loadEpisodes();
Future<List<Chapter>> loadChaptersByUrl({required String url});
Future<Transcript> loadTranscriptByUrl({required TranscriptUrl transcriptUrl});
Future<void> deleteDownload(Episode episode);
Future<void> toggleEpisodePlayed(Episode episode);
Future<List<Podcast>> subscriptions();
Future<Podcast?> subscribe(Podcast podcast);
Future<void> unsubscribe(Podcast podcast);
Future<Podcast?> save(Podcast podcast, {bool withEpisodes = true});
Future<Episode> saveEpisode(Episode episode);
Future<List<Episode>> saveEpisodes(List<Episode> episodes);
Future<Transcript> saveTranscript(Transcript transcript);
Future<void> saveQueue(List<Episode> episodes);
Future<List<Episode>> loadQueue();
/// Event listeners
Stream<Podcast?>? podcastListener;
Stream<EpisodeState>? episodeListener;
}

View File

@@ -0,0 +1,162 @@
// lib/services/search_history_service.dart
import 'dart:convert';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service for managing search history for different search types
/// Stores search terms separately for episode search and podcast search
class SearchHistoryService {
final log = Logger('SearchHistoryService');
static const int maxHistoryItems = 30;
static const String episodeSearchKey = 'episode_search_history';
static const String podcastSearchKey = 'podcast_search_history';
SearchHistoryService();
/// Adds a search term to episode search history
/// Moves existing term to top if already exists, otherwise adds as new
Future<void> addEpisodeSearchTerm(String searchTerm) async {
print('SearchHistoryService.addEpisodeSearchTerm called with: "$searchTerm"');
await _addSearchTerm(episodeSearchKey, searchTerm);
}
/// Adds a search term to podcast search history
/// Moves existing term to top if already exists, otherwise adds as new
Future<void> addPodcastSearchTerm(String searchTerm) async {
print('SearchHistoryService.addPodcastSearchTerm called with: "$searchTerm"');
await _addSearchTerm(podcastSearchKey, searchTerm);
}
/// Gets episode search history, most recent first
Future<List<String>> getEpisodeSearchHistory() async {
print('SearchHistoryService.getEpisodeSearchHistory called');
return await _getSearchHistory(episodeSearchKey);
}
/// Gets podcast search history, most recent first
Future<List<String>> getPodcastSearchHistory() async {
print('SearchHistoryService.getPodcastSearchHistory called');
return await _getSearchHistory(podcastSearchKey);
}
/// Clears episode search history
Future<void> clearEpisodeSearchHistory() async {
await _clearSearchHistory(episodeSearchKey);
}
/// Clears podcast search history
Future<void> clearPodcastSearchHistory() async {
await _clearSearchHistory(podcastSearchKey);
}
/// Removes a specific term from episode search history
Future<void> removeEpisodeSearchTerm(String searchTerm) async {
await _removeSearchTerm(episodeSearchKey, searchTerm);
}
/// Removes a specific term from podcast search history
Future<void> removePodcastSearchTerm(String searchTerm) async {
await _removeSearchTerm(podcastSearchKey, searchTerm);
}
/// Internal method to add a search term to specified history type
Future<void> _addSearchTerm(String historyKey, String searchTerm) async {
if (searchTerm.trim().isEmpty) return;
final trimmedTerm = searchTerm.trim();
print('SearchHistoryService: Adding search term "$trimmedTerm" to $historyKey');
try {
final prefs = await SharedPreferences.getInstance();
// Get existing history
final historyJson = prefs.getString(historyKey);
List<String> history = [];
if (historyJson != null) {
final List<dynamic> decodedList = jsonDecode(historyJson);
history = decodedList.cast<String>();
}
print('SearchHistoryService: Existing data for $historyKey: $history');
// Remove if already exists (to avoid duplicates)
history.remove(trimmedTerm);
// Add to beginning (most recent first)
history.insert(0, trimmedTerm);
// Limit to max items
if (history.length > maxHistoryItems) {
history = history.take(maxHistoryItems).toList();
}
// Save updated history
await prefs.setString(historyKey, jsonEncode(history));
print('SearchHistoryService: Updated $historyKey with ${history.length} terms: $history');
} catch (e) {
print('SearchHistoryService: Failed to add search term to $historyKey: $e');
log.warning('Failed to add search term to $historyKey: $e');
}
}
/// Internal method to get search history for specified type
Future<List<String>> _getSearchHistory(String historyKey) async {
try {
final prefs = await SharedPreferences.getInstance();
final historyJson = prefs.getString(historyKey);
print('SearchHistoryService: Getting history for $historyKey: $historyJson');
if (historyJson != null) {
final List<dynamic> decodedList = jsonDecode(historyJson);
final history = decodedList.cast<String>();
print('SearchHistoryService: Returning history for $historyKey: $history');
return history;
}
} catch (e) {
print('SearchHistoryService: Failed to get search history for $historyKey: $e');
}
print('SearchHistoryService: Returning empty history for $historyKey');
return [];
}
/// Internal method to clear search history for specified type
Future<void> _clearSearchHistory(String historyKey) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(historyKey);
print('SearchHistoryService: Cleared search history for $historyKey');
} catch (e) {
print('SearchHistoryService: Failed to clear search history for $historyKey: $e');
}
}
/// Internal method to remove specific term from history
Future<void> _removeSearchTerm(String historyKey, String searchTerm) async {
try {
final prefs = await SharedPreferences.getInstance();
final historyJson = prefs.getString(historyKey);
if (historyJson == null) return;
final List<dynamic> decodedList = jsonDecode(historyJson);
List<String> history = decodedList.cast<String>();
history.remove(searchTerm);
if (history.isEmpty) {
await prefs.remove(historyKey);
} else {
await prefs.setString(historyKey, jsonEncode(history));
}
print('SearchHistoryService: Removed "$searchTerm" from $historyKey');
} catch (e) {
print('SearchHistoryService: Failed to remove search term from $historyKey: $e');
}
}
}

View File

@@ -0,0 +1,277 @@
// 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:math';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/services/settings/settings_service.dart';
import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// An implementation [SettingsService] for mobile devices backed by
/// shared preferences.
class MobileSettingsService extends SettingsService {
static late SharedPreferences _sharedPreferences;
static MobileSettingsService? _instance;
final settingsNotifier = PublishSubject<String>();
MobileSettingsService._create();
static Future<MobileSettingsService?> instance() async {
if (_instance == null) {
_instance = MobileSettingsService._create();
_sharedPreferences = await SharedPreferences.getInstance();
}
return _instance;
}
@override
bool get markDeletedEpisodesAsPlayed => _sharedPreferences.getBool('markplayedasdeleted') ?? false;
@override
set markDeletedEpisodesAsPlayed(bool value) {
_sharedPreferences.setBool('markplayedasdeleted', value);
settingsNotifier.sink.add('markplayedasdeleted');
}
@override
String? get pinepodsServer => _sharedPreferences.getString('pinepods_server');
@override
set pinepodsServer(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_server');
} else {
_sharedPreferences.setString('pinepods_server', value);
}
settingsNotifier.sink.add('pinepods_server');
}
@override
String? get pinepodsApiKey => _sharedPreferences.getString('pinepods_api_key');
@override
set pinepodsApiKey(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_api_key');
} else {
_sharedPreferences.setString('pinepods_api_key', value);
}
settingsNotifier.sink.add('pinepods_api_key');
}
@override
int? get pinepodsUserId => _sharedPreferences.getInt('pinepods_user_id');
@override
set pinepodsUserId(int? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_user_id');
} else {
_sharedPreferences.setInt('pinepods_user_id', value);
}
settingsNotifier.sink.add('pinepods_user_id');
}
@override
String? get pinepodsUsername => _sharedPreferences.getString('pinepods_username');
@override
set pinepodsUsername(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_username');
} else {
_sharedPreferences.setString('pinepods_username', value);
}
settingsNotifier.sink.add('pinepods_username');
}
@override
String? get pinepodsEmail => _sharedPreferences.getString('pinepods_email');
@override
set pinepodsEmail(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_email');
} else {
_sharedPreferences.setString('pinepods_email', value);
}
settingsNotifier.sink.add('pinepods_email');
}
@override
bool get deleteDownloadedPlayedEpisodes => _sharedPreferences.getBool('deleteDownloadedPlayedEpisodes') ?? false;
@override
set deleteDownloadedPlayedEpisodes(bool value) {
_sharedPreferences.setBool('deleteDownloadedPlayedEpisodes', value);
settingsNotifier.sink.add('deleteDownloadedPlayedEpisodes');
}
@override
bool get storeDownloadsSDCard => _sharedPreferences.getBool('savesdcard') ?? false;
@override
set storeDownloadsSDCard(bool value) {
_sharedPreferences.setBool('savesdcard', value);
settingsNotifier.sink.add('savesdcard');
}
@override
bool get themeDarkMode {
var theme = _sharedPreferences.getString('theme') ?? 'Dark';
return theme == 'Dark';
}
@override
set themeDarkMode(bool value) {
_sharedPreferences.setString('theme', value ? 'Dark' : 'Light');
settingsNotifier.sink.add('theme');
}
String get theme {
return _sharedPreferences.getString('theme') ?? 'Dark';
}
set theme(String value) {
_sharedPreferences.setString('theme', value);
settingsNotifier.sink.add('theme');
}
@override
set playbackSpeed(double playbackSpeed) {
_sharedPreferences.setDouble('speed', playbackSpeed);
settingsNotifier.sink.add('speed');
}
@override
double get playbackSpeed {
var speed = _sharedPreferences.getDouble('speed') ?? 1.0;
// We used to use 0.25 increments and now we use 0.1. Round
// any setting that uses the old 0.25.
var mod = pow(10.0, 1).toDouble();
return ((speed * mod).round().toDouble() / mod);
}
@override
set searchProvider(String provider) {
_sharedPreferences.setString('search', provider);
settingsNotifier.sink.add('search');
}
@override
String get searchProvider {
// If we do not have PodcastIndex key, fallback to iTunes
if (podcastIndexKey.isEmpty) {
return 'itunes';
} else {
return _sharedPreferences.getString('search') ?? 'itunes';
}
}
@override
set externalLinkConsent(bool consent) {
_sharedPreferences.setBool('elconsent', consent);
settingsNotifier.sink.add('elconsent');
}
@override
bool get externalLinkConsent {
return _sharedPreferences.getBool('elconsent') ?? false;
}
@override
set autoOpenNowPlaying(bool autoOpenNowPlaying) {
_sharedPreferences.setBool('autoopennowplaying', autoOpenNowPlaying);
settingsNotifier.sink.add('autoopennowplaying');
}
@override
bool get autoOpenNowPlaying {
return _sharedPreferences.getBool('autoopennowplaying') ?? false;
}
@override
set showFunding(bool show) {
_sharedPreferences.setBool('showFunding', show);
settingsNotifier.sink.add('showFunding');
}
@override
bool get showFunding {
return _sharedPreferences.getBool('showFunding') ?? true;
}
@override
set autoUpdateEpisodePeriod(int period) {
_sharedPreferences.setInt('autoUpdateEpisodePeriod', period);
settingsNotifier.sink.add('autoUpdateEpisodePeriod');
}
@override
int get autoUpdateEpisodePeriod {
/// Default to 3 hours.
return _sharedPreferences.getInt('autoUpdateEpisodePeriod') ?? 180;
}
@override
set trimSilence(bool trim) {
_sharedPreferences.setBool('trimSilence', trim);
settingsNotifier.sink.add('trimSilence');
}
@override
bool get trimSilence {
return _sharedPreferences.getBool('trimSilence') ?? false;
}
@override
set volumeBoost(bool boost) {
_sharedPreferences.setBool('volumeBoost', boost);
settingsNotifier.sink.add('volumeBoost');
}
@override
bool get volumeBoost {
return _sharedPreferences.getBool('volumeBoost') ?? false;
}
@override
set layoutMode(int mode) {
_sharedPreferences.setInt('layout', mode);
settingsNotifier.sink.add('layout');
}
@override
int get layoutMode {
return _sharedPreferences.getInt('layout') ?? 0;
}
@override
List<String> get bottomBarOrder {
final orderString = _sharedPreferences.getString('bottom_bar_order');
if (orderString != null) {
return orderString.split(',');
}
return ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search'];
}
@override
set bottomBarOrder(List<String> value) {
_sharedPreferences.setString('bottom_bar_order', value.join(','));
settingsNotifier.sink.add('bottom_bar_order');
}
@override
AppSettings? settings;
@override
Stream<String> get settingsListener => settingsNotifier.stream;
}

View File

@@ -0,0 +1,87 @@
// 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/entities/app_settings.dart';
abstract class SettingsService {
AppSettings? get settings;
set settings(AppSettings? settings);
bool get themeDarkMode;
set themeDarkMode(bool value);
String get theme;
set theme(String value);
bool get markDeletedEpisodesAsPlayed;
set markDeletedEpisodesAsPlayed(bool value);
bool get deleteDownloadedPlayedEpisodes;
set deleteDownloadedPlayedEpisodes(bool value);
bool get storeDownloadsSDCard;
set storeDownloadsSDCard(bool value);
set playbackSpeed(double playbackSpeed);
double get playbackSpeed;
set searchProvider(String provider);
String get searchProvider;
set externalLinkConsent(bool consent);
bool get externalLinkConsent;
set autoOpenNowPlaying(bool autoOpenNowPlaying);
bool get autoOpenNowPlaying;
set showFunding(bool show);
bool get showFunding;
set autoUpdateEpisodePeriod(int period);
int get autoUpdateEpisodePeriod;
set trimSilence(bool trim);
bool get trimSilence;
set volumeBoost(bool boost);
bool get volumeBoost;
set layoutMode(int mode);
int get layoutMode;
Stream<String> get settingsListener;
String? get pinepodsServer;
set pinepodsServer(String? value);
String? get pinepodsApiKey;
set pinepodsApiKey(String? value);
int? get pinepodsUserId;
set pinepodsUserId(int? value);
String? get pinepodsUsername;
set pinepodsUsername(String? value);
String? get pinepodsEmail;
set pinepodsEmail(String? value);
List<String> get bottomBarOrder;
set bottomBarOrder(List<String> value);
}

View File

@@ -0,0 +1,45 @@
// 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.
/// The BLoCs in this application share common states, such as loading, error
/// or populated.
///
/// Rather than having a separate selection of state classes, we create this generic one.
enum BlocErrorType { unknown, connectivity, timeout }
abstract class BlocState<T> {}
class BlocDefaultState<T> extends BlocState<T> {}
class BlocLoadingState<T> extends BlocState<T> {
final T? data;
BlocLoadingState([this.data]);
}
class BlocBackgroundLoadingState<T> extends BlocState<T> {
final T? data;
BlocBackgroundLoadingState([this.data]);
}
class BlocSuccessfulState<T> extends BlocState<T> {}
class BlocEmptyState<T> extends BlocState<T> {}
class BlocErrorState<T> extends BlocState<T> {
final BlocErrorType error;
BlocErrorState({
this.error = BlocErrorType.unknown,
});
}
class BlocNoInputState<T> extends BlocState<T> {}
class BlocPopulatedState<T> extends BlocState<T> {
final T? results;
BlocPopulatedState({this.results});
}

View 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:pinepods_mobile/entities/episode.dart';
abstract class EpisodeState {
final Episode episode;
EpisodeState(this.episode);
}
class EpisodeUpdateState extends EpisodeState {
EpisodeUpdateState(super.episode);
}
class EpisodeDeleteState extends EpisodeState {
EpisodeDeleteState(super.episode);
}

View File

@@ -0,0 +1,95 @@
// Copyright 2020 Ben Hills. 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:convert';
import 'dart:io';
import 'package:pinepods_mobile/entities/persistable.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
class PersistentState {
static Future<void> persistState(Persistable persistable) async {
var d = await getApplicationSupportDirectory();
var file = File(join(d.path, 'state.json'));
var sink = file.openWrite();
var json = jsonEncode(persistable.toMap());
sink.write(json);
await sink.flush();
await sink.close();
}
static Future<Persistable> fetchState() async {
var d = await getApplicationSupportDirectory();
var file = File(join(d.path, 'state.json'));
var p = Persistable.empty();
if (file.existsSync()) {
var result = file.readAsStringSync();
if (result.isNotEmpty) {
var data = jsonDecode(result) as Map<String, dynamic>;
p = Persistable.fromMap(data);
}
}
return Future.value(p);
}
static Future<void> clearState() async {
var file = await _getFile();
if (file.existsSync()) {
file.delete();
}
}
static Future<void> writeInt(String name, int value) async {
return _writeValue(name, value.toString());
}
static Future<int> readInt(String name) async {
var result = await _readValue(name);
return result.isEmpty ? 0 : int.parse(result);
}
static Future<void> writeString(String name, String value) async {
return _writeValue(name, value);
}
static Future<String> readString(String name) async {
return _readValue(name);
}
static Future<String> _readValue(String name) async {
var d = await getApplicationSupportDirectory();
var file = File(join(d.path, name));
var result = file.readAsStringSync();
return result;
}
static Future<void> _writeValue(String name, String value) async {
var d = await getApplicationSupportDirectory();
var file = File(join(d.path, name));
var sink = file.openWrite();
sink.write(value.toString());
await sink.flush();
await sink.close();
}
static Future<File> _getFile() async {
var d = await getApplicationSupportDirectory();
return File(join(d.path, 'state.json'));
}
}

View File

@@ -0,0 +1,58 @@
// 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/entities/episode.dart';
abstract class QueueEvent {
Episode? episode;
int? position;
QueueEvent({
this.episode,
this.position,
});
}
class QueueAddEvent extends QueueEvent {
QueueAddEvent({required Episode super.episode, super.position});
}
class QueueRemoveEvent extends QueueEvent {
QueueRemoveEvent({required Episode episode}) : super(episode: episode);
}
class QueueMoveEvent extends QueueEvent {
final int oldIndex;
final int newIndex;
QueueMoveEvent({
required Episode episode,
required this.oldIndex,
required this.newIndex,
}) : super(episode: episode);
}
class QueueClearEvent extends QueueEvent {}
abstract class QueueState {
final Episode? playing;
final List<Episode> queue;
QueueState({
required this.playing,
required this.queue,
});
}
class QueueListState extends QueueState {
QueueListState({
required super.playing,
required super.queue,
});
}
class QueueEmptyState extends QueueState {
QueueEmptyState()
: super(playing: Episode(guid: '', pguid: '', podcast: '', title: '', description: ''), queue: <Episode>[]);
}

View File

@@ -0,0 +1,35 @@
// 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/entities/transcript.dart';
/// Events
abstract class TranscriptEvent {}
class TranscriptClearEvent extends TranscriptEvent {}
class TranscriptFilterEvent extends TranscriptEvent {
final String search;
TranscriptFilterEvent({required this.search});
}
/// State
abstract class TranscriptState {
final Transcript? transcript;
final bool isFiltered;
TranscriptState({
this.transcript,
this.isFiltered = false,
});
}
class TranscriptUnavailableState extends TranscriptState {}
class TranscriptLoadingState extends TranscriptState {}
class TranscriptUpdateState extends TranscriptState {
TranscriptUpdateState({required Transcript transcript}) : super(transcript: transcript);
}

View File

@@ -0,0 +1,163 @@
// lib/ui/auth/auth_wrapper.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/ui/auth/pinepods_startup_login.dart';
import 'package:provider/provider.dart';
class AuthWrapper extends StatefulWidget {
final Widget child;
const AuthWrapper({
Key? key,
required this.child,
}) : super(key: key);
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
bool _hasInitializedTheme = false;
@override
Widget build(BuildContext context) {
return Consumer<SettingsBloc>(
builder: (context, settingsBloc, _) {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: settingsBloc.currentSettings,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
final settings = snapshot.data!;
// Check if PinePods server is configured
final hasServer = settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty;
final hasApiKey = settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty;
if (hasServer && hasApiKey) {
// User is logged in, fetch theme from server if not already done
if (!_hasInitializedTheme) {
_hasInitializedTheme = true;
// Fetch theme from server on next frame to avoid modifying state during build
WidgetsBinding.instance.addPostFrameCallback((_) {
settingsBloc.fetchThemeFromServer();
});
}
// Show main app
return widget.child;
} else {
// User needs to login, reset theme initialization flag
_hasInitializedTheme = false;
// Show startup login
return PinepodsStartupLogin(
onLoginSuccess: () {
// Force rebuild to check auth state again
// The StreamBuilder will automatically rebuild when settings change
},
);
}
},
);
},
);
}
}
// Alternative version if you want more explicit control
class AuthChecker extends StatefulWidget {
final Widget authenticatedChild;
final Widget? unauthenticatedChild;
const AuthChecker({
Key? key,
required this.authenticatedChild,
this.unauthenticatedChild,
}) : super(key: key);
@override
State<AuthChecker> createState() => _AuthCheckerState();
}
class _AuthCheckerState extends State<AuthChecker> {
bool _isCheckingAuth = true;
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_checkAuthStatus();
}
void _checkAuthStatus() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final hasServer = settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty;
final hasApiKey = settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty;
setState(() {
_isAuthenticated = hasServer && hasApiKey;
_isCheckingAuth = false;
});
}
void _onLoginSuccess() {
setState(() {
_isAuthenticated = true;
});
}
@override
Widget build(BuildContext context) {
if (_isCheckingAuth) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (_isAuthenticated) {
return widget.authenticatedChild;
} else {
return widget.unauthenticatedChild ??
PinepodsStartupLogin(onLoginSuccess: _onLoginSuccess);
}
}
}
// Simple authentication status provider
class AuthStatus extends InheritedWidget {
final bool isAuthenticated;
final VoidCallback? onAuthChanged;
const AuthStatus({
Key? key,
required this.isAuthenticated,
this.onAuthChanged,
required Widget child,
}) : super(key: key, child: child);
static AuthStatus? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AuthStatus>();
}
@override
bool updateShouldNotify(AuthStatus oldWidget) {
return isAuthenticated != oldWidget.isAuthenticated;
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
class OidcBrowser extends StatefulWidget {
final String authUrl;
final String serverUrl;
final Function(String apiKey) onSuccess;
final Function(String error) onError;
const OidcBrowser({
super.key,
required this.authUrl,
required this.serverUrl,
required this.onSuccess,
required this.onError,
});
@override
State<OidcBrowser> createState() => _OidcBrowserState();
}
class _OidcBrowserState extends State<OidcBrowser> {
late final WebViewController _controller;
bool _isLoading = true;
String _currentUrl = '';
bool _callbackTriggered = false; // Prevent duplicate callbacks
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
setState(() {
_currentUrl = url;
_isLoading = true;
});
_checkForCallback(url);
},
onPageFinished: (String url) {
setState(() {
_isLoading = false;
});
_checkForCallback(url);
},
onNavigationRequest: (NavigationRequest request) {
_checkForCallback(request.url);
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(widget.authUrl));
}
void _checkForCallback(String url) {
if (_callbackTriggered) return; // Prevent duplicate callbacks
// Check if we've reached the callback URL with an API key
final apiKey = OidcService.extractApiKeyFromUrl(url);
if (apiKey != null) {
_callbackTriggered = true; // Mark callback as triggered
widget.onSuccess(apiKey);
return;
}
// Check for error in callback URL
final uri = Uri.tryParse(url);
if (uri != null && uri.path.contains('/oauth/callback')) {
final error = uri.queryParameters['error'];
if (error != null) {
_callbackTriggered = true; // Mark callback as triggered
final errorDescription = uri.queryParameters['description'] ?? uri.queryParameters['details'] ?? 'Authentication failed';
widget.onError('$error: $errorDescription');
return;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sign In'),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
widget.onError('User cancelled authentication');
},
),
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
],
),
body: Column(
children: [
// URL bar for debugging
if (MediaQuery.of(context).size.height > 600)
Container(
padding: const EdgeInsets.all(8.0),
color: Colors.grey[200],
child: Row(
children: [
const Icon(Icons.link, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_currentUrl,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// WebView
Expanded(
child: WebViewWidget(
controller: _controller,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,777 @@
// lib/ui/auth/pinepods_startup_login.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/login_service.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
import 'package:pinepods_mobile/services/auth_notifier.dart';
import 'package:pinepods_mobile/ui/auth/oidc_browser.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:math';
import 'dart:async';
class PinepodsStartupLogin extends StatefulWidget {
final VoidCallback? onLoginSuccess;
const PinepodsStartupLogin({
Key? key,
this.onLoginSuccess,
}) : super(key: key);
@override
State<PinepodsStartupLogin> createState() => _PinepodsStartupLoginState();
}
class _PinepodsStartupLoginState extends State<PinepodsStartupLogin> {
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _mfaController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showMfaField = false;
bool _isLoadingOidc = false;
String _errorMessage = '';
String? _tempServerUrl;
String? _tempUsername;
int? _tempUserId;
String? _tempMfaSessionToken;
List<OidcProvider> _oidcProviders = [];
bool _hasCheckedOidc = false;
Timer? _oidcCheckTimer;
// List of background images - you can add your own images to assets/images/
final List<String> _backgroundImages = [
'assets/images/1.webp',
'assets/images/2.webp',
'assets/images/3.webp',
'assets/images/4.webp',
'assets/images/5.webp',
'assets/images/6.webp',
'assets/images/7.webp',
'assets/images/8.webp',
'assets/images/9.webp',
];
late String _selectedBackground;
@override
void initState() {
super.initState();
// Select a random background image
final random = Random();
_selectedBackground = _backgroundImages[random.nextInt(_backgroundImages.length)];
// Listen for server URL changes to check OIDC providers
_serverController.addListener(_onServerUrlChanged);
// Register global login success callback
AuthNotifier.setGlobalLoginSuccessCallback(_handleLoginSuccess);
}
void _onServerUrlChanged() {
final serverUrl = _serverController.text.trim();
// Cancel any existing timer
_oidcCheckTimer?.cancel();
// Reset OIDC state
setState(() {
_oidcProviders.clear();
_hasCheckedOidc = false;
_isLoadingOidc = false;
});
// Only check if URL looks complete and valid
if (serverUrl.isNotEmpty &&
(serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) &&
_isValidUrl(serverUrl)) {
// Debounce the API call - wait 1 second after user stops typing
_oidcCheckTimer = Timer(const Duration(seconds: 1), () {
_checkOidcProviders(serverUrl);
});
}
}
bool _isValidUrl(String url) {
try {
final uri = Uri.parse(url);
// Check if it has a proper host (not just protocol)
return uri.hasScheme &&
uri.host.isNotEmpty &&
uri.host.contains('.') && // Must have at least one dot for domain
uri.host.length > 3; // Minimum reasonable length
} catch (e) {
return false;
}
}
Future<void> _checkOidcProviders(String serverUrl) async {
// Allow rechecking if server URL changed
final currentUrl = _serverController.text.trim();
if (currentUrl != serverUrl) return; // URL changed while we were waiting
setState(() {
_isLoadingOidc = true;
});
try {
final providers = await OidcService.getPublicProviders(serverUrl);
// Double-check the URL hasn't changed during the API call
if (mounted && _serverController.text.trim() == serverUrl) {
setState(() {
_oidcProviders = providers;
_hasCheckedOidc = true;
_isLoadingOidc = false;
});
}
} catch (e) {
// Only update state if URL hasn't changed
if (mounted && _serverController.text.trim() == serverUrl) {
setState(() {
_oidcProviders.clear();
_hasCheckedOidc = true;
_isLoadingOidc = false;
});
}
}
}
// Manual retry when user focuses on other fields (like username)
void _retryOidcCheck() {
final serverUrl = _serverController.text.trim();
if (serverUrl.isNotEmpty &&
_isValidUrl(serverUrl) &&
!_hasCheckedOidc &&
!_isLoadingOidc) {
_checkOidcProviders(serverUrl);
}
}
Future<void> _handleOidcLogin(OidcProvider provider) async {
final serverUrl = _serverController.text.trim();
if (serverUrl.isEmpty) {
setState(() {
_errorMessage = 'Please enter a server URL first';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Generate PKCE and state parameters for security
final pkce = OidcService.generatePkce();
final state = OidcService.generateState();
// Build authorization URL for in-app browser
final authUrl = await OidcService.buildOidcLoginUrl(
provider: provider,
serverUrl: serverUrl,
state: state,
pkce: pkce,
);
if (authUrl == null) {
setState(() {
_errorMessage = 'Failed to prepare OIDC authentication URL';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = false;
});
// Launch in-app browser
if (mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => OidcBrowser(
authUrl: authUrl,
serverUrl: serverUrl,
onSuccess: (apiKey) async {
Navigator.of(context).pop(); // Close the browser
await _completeOidcLogin(apiKey, serverUrl);
},
onError: (error) {
Navigator.of(context).pop(); // Close the browser
setState(() {
_errorMessage = 'Authentication failed: $error';
});
},
),
),
);
}
} catch (e) {
setState(() {
_errorMessage = 'OIDC login error: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _completeOidcLogin(String apiKey, String serverUrl) async {
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Verify API key
final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
throw Exception('API key verification failed');
}
// Get user ID
final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey);
if (userId == null) {
throw Exception('Failed to get user ID');
}
// Get user details
final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
throw Exception('Failed to get user details');
}
// Store credentials
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(serverUrl);
settingsBloc.setPinepodsApiKey(apiKey);
settingsBloc.setPinepodsUserId(userId);
// Set additional user details if available
if (userDetails.username != null) {
settingsBloc.setPinepodsUsername(userDetails.username!);
}
if (userDetails.email != null) {
settingsBloc.setPinepodsEmail(userDetails.email!);
}
// Fetch theme from server
try {
await settingsBloc.fetchThemeFromServer();
} catch (e) {
// Theme fetch failure is non-critical
}
// Notify login success
AuthNotifier.notifyLoginSuccess();
// Call the callback if provided
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to complete login: ${e.toString()}';
_isLoading = false;
});
}
}
}
Future<void> _connectToPinepods() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
if (_showMfaField && _tempMfaSessionToken != null) {
// Complete MFA login flow
final mfaCode = _mfaController.text.trim();
final result = await PinepodsLoginService.completeMfaLogin(
serverUrl: _tempServerUrl!,
username: _tempUsername!,
mfaSessionToken: _tempMfaSessionToken!,
mfaCode: mfaCode,
);
if (result.isSuccess) {
// Save the connection details including user ID
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
// Fetch theme from server after successful login
await settingsBloc.fetchThemeFromServer();
setState(() {
_isLoading = false;
});
// Call success callback
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'MFA verification failed';
_isLoading = false;
});
}
} else {
// Initial login flow
final serverUrl = _serverController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text;
final result = await PinepodsLoginService.login(
serverUrl,
username,
password,
);
if (result.isSuccess) {
// Save the connection details including user ID
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
// Fetch theme from server after successful login
await settingsBloc.fetchThemeFromServer();
setState(() {
_isLoading = false;
});
// Call success callback
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} else if (result.requiresMfa) {
// Store MFA session info and show MFA field
setState(() {
_tempServerUrl = result.serverUrl;
_tempUsername = result.username;
_tempUserId = result.userId;
_tempMfaSessionToken = result.mfaSessionToken;
_showMfaField = true;
_isLoading = false;
_errorMessage = 'Please enter your MFA code';
});
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'Login failed';
_isLoading = false;
});
}
}
} catch (e) {
setState(() {
_errorMessage = 'Error: ${e.toString()}';
_isLoading = false;
});
}
}
void _resetMfa() {
setState(() {
_showMfaField = false;
_tempServerUrl = null;
_tempUsername = null;
_tempUserId = null;
_tempMfaSessionToken = null;
_mfaController.clear();
_errorMessage = '';
});
}
/// Parse hex color string to Color object
Color _parseColor(String hexColor) {
try {
final hex = hexColor.replaceAll('#', '');
if (hex.length == 6) {
return Color(int.parse('FF$hex', radix: 16));
} else if (hex.length == 8) {
return Color(int.parse(hex, radix: 16));
}
} catch (e) {
// Fallback to default color on parsing error
}
return Theme.of(context).primaryColor;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(_selectedBackground),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.6),
BlendMode.darken,
),
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App Logo/Title
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/favicon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.headset,
size: 48,
color: Colors.white,
),
);
},
),
),
),
),
const SizedBox(height: 16),
Text(
'Welcome to PinePods',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Connect to your PinePods server to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Server URL Field
TextFormField(
controller: _serverController,
decoration: InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-pinepods-server.com',
prefixIcon: const Icon(Icons.dns),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a server URL';
}
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
return null;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Username Field
Focus(
onFocusChange: (hasFocus) {
if (hasFocus) {
// User focused on username field, retry OIDC check if needed
_retryOidcCheck();
}
},
child: TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your username';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
textInputAction: _showMfaField ? TextInputAction.next : TextInputAction.done,
onFieldSubmitted: (_) {
if (!_showMfaField) {
_connectToPinepods();
}
},
enabled: !_showMfaField,
),
// MFA Field (shown when MFA is required)
if (_showMfaField) ...[
const SizedBox(height: 16),
TextFormField(
controller: _mfaController,
decoration: InputDecoration(
labelText: 'MFA Code',
hintText: 'Enter 6-digit code',
prefixIcon: const Icon(Icons.security),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: _resetMfa,
tooltip: 'Cancel MFA',
),
),
keyboardType: TextInputType.number,
maxLength: 6,
validator: (value) {
if (_showMfaField && (value == null || value.isEmpty)) {
return 'Please enter your MFA code';
}
if (_showMfaField && value!.length != 6) {
return 'MFA code must be 6 digits';
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _connectToPinepods(),
),
],
// Error Message
if (_errorMessage.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
],
const SizedBox(height: 24),
// Connect Button
ElevatedButton(
onPressed: _isLoading ? null : _connectToPinepods,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
_showMfaField ? 'Verify MFA Code' : 'Connect to PinePods',
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// OIDC Providers Section
if (_oidcProviders.isNotEmpty && !_showMfaField) ...[
// Divider
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Or continue with',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
// OIDC Provider Buttons
..._oidcProviders.map((provider) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _handleOidcLogin(provider),
style: ElevatedButton.styleFrom(
backgroundColor: _parseColor(provider.buttonColorHex),
foregroundColor: _parseColor(provider.buttonTextColorHex),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (provider.iconSvg != null && provider.iconSvg!.isNotEmpty)
Container(
width: 20,
height: 20,
margin: const EdgeInsets.only(right: 8),
child: const Icon(Icons.account_circle, size: 20),
),
Text(
provider.displayText,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
)),
const SizedBox(height: 16),
],
// Loading indicator for OIDC discovery
if (_isLoadingOidc) ...[
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 16),
],
// Additional Info
Text(
'Don\'t have a PinePods server? Visit pinepods.online to learn more.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
),
),
);
}
/// Handle login success from any source (traditional or OIDC)
void _handleLoginSuccess() {
if (mounted) {
widget.onLoginSuccess?.call();
}
}
@override
void dispose() {
_oidcCheckTimer?.cancel();
_serverController.removeListener(_onServerUrlChanged);
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_mfaController.dispose();
// Clear global callback to prevent memory leaks
AuthNotifier.clearGlobalLoginSuccessCallback();
super.dispose();
}
}

View File

@@ -0,0 +1,656 @@
// lib/ui/debug/debug_logs_page.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
class DebugLogsPage extends StatefulWidget {
const DebugLogsPage({Key? key}) : super(key: key);
@override
State<DebugLogsPage> createState() => _DebugLogsPageState();
}
class _DebugLogsPageState extends State<DebugLogsPage> {
final AppLogger _logger = AppLogger();
final ScrollController _scrollController = ScrollController();
List<LogEntry> _logs = [];
LogLevel? _selectedLevel;
bool _showDeviceInfo = true;
List<File> _sessionFiles = [];
bool _hasPreviousCrash = false;
@override
void initState() {
super.initState();
_loadLogs();
_loadSessionFiles();
}
void _loadLogs() {
setState(() {
if (_selectedLevel == null) {
_logs = _logger.logs;
} else {
_logs = _logger.getLogsByLevel(_selectedLevel!);
}
});
}
Future<void> _copyLogsToClipboard() async {
try {
final formattedLogs = _logger.getFormattedLogs();
await Clipboard.setData(ClipboardData(text: formattedLogs));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy logs: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _loadSessionFiles() async {
try {
final files = await _logger.getSessionFiles();
final hasCrash = await _logger.hasPreviousCrash();
setState(() {
_sessionFiles = files;
_hasPreviousCrash = hasCrash;
});
} catch (e) {
print('Failed to load session files: $e');
}
}
Future<void> _copyCurrentSessionToClipboard() async {
try {
final formattedLogs = _logger.getFormattedLogsWithSessionInfo();
await Clipboard.setData(ClipboardData(text: formattedLogs));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Current session logs copied to clipboard!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy logs: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _copySessionFileToClipboard(File sessionFile) async {
try {
final content = await sessionFile.readAsString();
final deviceInfo = _logger.deviceInfo?.formattedInfo ?? 'Device info not available';
final formattedContent = '$deviceInfo\n\n${'=' * 50}\nSession File: ${sessionFile.path.split('/').last}\n${'=' * 50}\n\n$content';
await Clipboard.setData(ClipboardData(text: formattedContent));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Session ${sessionFile.path.split('/').last} copied to clipboard!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy session file: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _copyCrashLogToClipboard() async {
try {
final crashPath = _logger.crashLogPath;
if (crashPath == null) {
throw Exception('Crash log path not available');
}
final crashFile = File(crashPath);
final content = await crashFile.readAsString();
await Clipboard.setData(ClipboardData(text: content));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Crash log copied to clipboard!'),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy crash log: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _openBugTracker() async {
const url = 'https://github.com/madeofpendletonwool/pinepods/issues';
try {
final uri = Uri.parse(url);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open bug tracker: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _clearLogs() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
_logger.clearLogs();
_loadLogs();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs cleared'),
backgroundColor: Colors.orange,
),
);
},
child: const Text('Clear'),
),
],
),
);
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
Color _getLevelColor(LogLevel level) {
switch (level) {
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
return Colors.blue;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return Colors.red;
case LogLevel.critical:
return Colors.purple;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug Logs'),
elevation: 0,
actions: [
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'filter':
_showFilterDialog();
break;
case 'clear':
_clearLogs();
break;
case 'refresh':
_loadLogs();
break;
case 'scroll_bottom':
_scrollToBottom();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'filter',
child: Row(
children: [
Icon(Icons.filter_list),
SizedBox(width: 8),
Text('Filter'),
],
),
),
const PopupMenuItem(
value: 'refresh',
child: Row(
children: [
Icon(Icons.refresh),
SizedBox(width: 8),
Text('Refresh'),
],
),
),
const PopupMenuItem(
value: 'scroll_bottom',
child: Row(
children: [
Icon(Icons.vertical_align_bottom),
SizedBox(width: 8),
Text('Scroll to Bottom'),
],
),
),
const PopupMenuItem(
value: 'clear',
child: Row(
children: [
Icon(Icons.clear_all),
SizedBox(width: 8),
Text('Clear Logs'),
],
),
),
],
),
],
),
body: Column(
children: [
// Header with device info toggle and stats
Container(
padding: const EdgeInsets.all(16.0),
color: Theme.of(context).cardColor,
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'Total Entries: ${_logs.length}',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (_selectedLevel != null)
Chip(
label: Text(_selectedLevel!.name.toUpperCase()),
backgroundColor: _getLevelColor(_selectedLevel!).withOpacity(0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
_selectedLevel = null;
});
_loadLogs();
},
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _copyCurrentSessionToClipboard,
icon: const Icon(Icons.copy),
label: const Text('Copy Current'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _openBugTracker,
icon: const Icon(Icons.bug_report),
label: const Text('Report Bug'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
const Divider(height: 1),
// Session Files Section
if (_sessionFiles.isNotEmpty || _hasPreviousCrash)
ExpansionTile(
title: const Text('Session Files & Crash Logs'),
leading: const Icon(Icons.folder),
initiallyExpanded: false,
children: [
if (_hasPreviousCrash)
ListTile(
leading: const Icon(Icons.warning, color: Colors.red),
title: const Text('Previous Crash Log'),
subtitle: const Text('Tap to copy crash log to clipboard'),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: _copyCrashLogToClipboard,
),
onTap: _copyCrashLogToClipboard,
),
..._sessionFiles.map((file) {
final fileName = file.path.split('/').last;
final isCurrentSession = fileName.contains(_logger.currentSessionPath?.split('/').last?.replaceFirst('session_', '').replaceFirst('.log', '') ?? '');
return ListTile(
leading: Icon(
isCurrentSession ? Icons.play_circle : Icons.history,
color: isCurrentSession ? Colors.green : Colors.grey,
),
title: Text(fileName),
subtitle: Text(
'Modified: ${file.lastModifiedSync().toString().substring(0, 16)}${isCurrentSession ? ' (Current)' : ''}',
style: TextStyle(
fontSize: 12,
color: isCurrentSession ? Colors.green : Colors.grey[600],
),
),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copySessionFileToClipboard(file),
),
onTap: () => _copySessionFileToClipboard(file),
);
}).toList(),
if (_sessionFiles.isEmpty && !_hasPreviousCrash)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'No session files available yet',
style: TextStyle(color: Colors.grey),
),
),
],
),
// Device info section (collapsible)
if (_showDeviceInfo && _logger.deviceInfo != null)
ExpansionTile(
title: const Text('Device Information'),
leading: const Icon(Icons.phone_android),
initiallyExpanded: false,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Text(
_logger.deviceInfo!.formattedInfo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
),
],
),
// Logs list
Expanded(
child: _logs.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No logs found',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
SizedBox(height: 8),
Text(
'Use the app to generate logs',
style: TextStyle(color: Colors.grey),
),
],
),
)
: ListView.builder(
controller: _scrollController,
itemCount: _logs.length,
itemBuilder: (context, index) {
final log = _logs[index];
return _buildLogEntry(log);
},
),
),
],
),
floatingActionButton: _logs.isNotEmpty
? FloatingActionButton(
onPressed: _scrollToBottom,
tooltip: 'Scroll to bottom',
child: const Icon(Icons.vertical_align_bottom),
)
: null,
);
}
Widget _buildLogEntry(LogEntry log) {
final levelColor = _getLevelColor(log.level);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Card(
elevation: 1,
child: ExpansionTile(
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: levelColor,
shape: BoxShape.circle,
),
),
title: Text(
log.message,
style: const TextStyle(fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${log.timestamp.toString().substring(0, 19)}${log.levelString}${log.tag}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
log.formattedMessage,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
if (log.stackTrace != null && log.stackTrace!.isNotEmpty) ...[
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
const Text(
'Stack Trace:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
SelectableText(
log.stackTrace!,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
),
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: log.formattedMessage));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Log entry copied to clipboard')),
);
},
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy'),
),
],
),
],
),
),
),
],
),
),
);
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filter Logs'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Show only logs of level:'),
const SizedBox(height: 16),
...LogLevel.values.map((level) => RadioListTile<LogLevel?>(
title: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _getLevelColor(level),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(level.name.toUpperCase()),
],
),
value: level,
groupValue: _selectedLevel,
onChanged: (value) {
setState(() {
_selectedLevel = value;
});
},
)),
RadioListTile<LogLevel?>(
title: const Text('All Levels'),
value: null,
groupValue: _selectedLevel,
onChanged: (value) {
setState(() {
_selectedLevel = null;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
_loadLogs();
Navigator.of(context).pop();
},
child: const Text('Apply'),
),
],
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}

View 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:pinepods_mobile/ui/pinepods/downloads.dart';
import 'package:flutter/material.dart';
/// Displays a list of currently downloaded podcast episodes.
/// This is a wrapper that redirects to the new PinePods downloads implementation.
class Downloads extends StatelessWidget {
const Downloads({
super.key,
});
@override
Widget build(BuildContext context) {
return const PinepodsDownloads();
}
}

View File

@@ -0,0 +1,115 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This class displays the list of podcasts the user is currently following.
class Library extends StatefulWidget {
const Library({
super.key,
});
@override
State<Library> createState() => _LibraryState();
}
class _LibraryState extends State<Library> {
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
final settingsBloc = Provider.of<SettingsBloc>(context);
return StreamBuilder<List<Podcast>>(
stream: podcastBloc.subscriptions,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.headset,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
L.of(context)!.no_subscriptions_message,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
} else {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
var mode = settingsSnapshot.data!.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PodcastTile(podcast: snapshot.data!.elementAt(index));
},
childCount: snapshot.data!.length,
addAutomaticKeepAlives: false,
));
}
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PodcastGridTile(podcast: snapshot.data!.elementAt(index));
},
childCount: snapshot.data!.length,
),
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
});
}
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
});
}
}

View File

@@ -0,0 +1,390 @@
// lib/ui/pinepods/create_playlist.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:provider/provider.dart';
class CreatePlaylistPage extends StatefulWidget {
const CreatePlaylistPage({Key? key}) : super(key: key);
@override
State<CreatePlaylistPage> createState() => _CreatePlaylistPageState();
}
class _CreatePlaylistPageState extends State<CreatePlaylistPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final PinepodsService _pinepodsService = PinepodsService();
bool _isLoading = false;
String _selectedIcon = 'ph-playlist';
bool _includeUnplayed = true;
bool _includePartiallyPlayed = true;
bool _includePlayed = false;
String _minDuration = '';
String _maxDuration = '';
String _sortOrder = 'newest_first';
bool _groupByPodcast = false;
String _maxEpisodes = '';
final List<Map<String, String>> _availableIcons = [
{'name': 'ph-playlist', 'icon': '🎵'},
{'name': 'ph-music-notes', 'icon': '🎶'},
{'name': 'ph-play-circle', 'icon': '▶️'},
{'name': 'ph-headphones', 'icon': '🎧'},
{'name': 'ph-star', 'icon': ''},
{'name': 'ph-heart', 'icon': '❤️'},
{'name': 'ph-bookmark', 'icon': '🔖'},
{'name': 'ph-clock', 'icon': ''},
{'name': 'ph-calendar', 'icon': '📅'},
{'name': 'ph-timer', 'icon': '⏲️'},
{'name': 'ph-shuffle', 'icon': '🔀'},
{'name': 'ph-repeat', 'icon': '🔁'},
{'name': 'ph-microphone', 'icon': '🎤'},
{'name': 'ph-queue', 'icon': '📋'},
{'name': 'ph-fire', 'icon': '🔥'},
{'name': 'ph-lightning', 'icon': ''},
{'name': 'ph-coffee', 'icon': ''},
{'name': 'ph-moon', 'icon': '🌙'},
{'name': 'ph-sun', 'icon': '☀️'},
{'name': 'ph-rocket', 'icon': '🚀'},
];
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _createPlaylist() async {
if (!_formKey.currentState!.validate()) {
return;
}
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not connected to PinePods server')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final request = CreatePlaylistRequest(
userId: settings.pinepodsUserId!,
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
podcastIds: const [], // For now, we'll create without podcast filtering
includeUnplayed: _includeUnplayed,
includePartiallyPlayed: _includePartiallyPlayed,
includePlayed: _includePlayed,
minDuration: _minDuration.isNotEmpty ? int.tryParse(_minDuration) : null,
maxDuration: _maxDuration.isNotEmpty ? int.tryParse(_maxDuration) : null,
sortOrder: _sortOrder,
groupByPodcast: _groupByPodcast,
maxEpisodes: _maxEpisodes.isNotEmpty ? int.tryParse(_maxEpisodes) : null,
iconName: _selectedIcon,
playProgressMin: null, // Simplified for now
playProgressMax: null,
timeFilterHours: null,
);
final success = await _pinepodsService.createPlaylist(request);
if (success) {
if (mounted) {
Navigator.of(context).pop(true); // Return true to indicate success
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlist created successfully!')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to create playlist')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating playlist: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create Playlist'),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton(
onPressed: _createPlaylist,
child: const Text('Create'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Name field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Playlist Name',
border: OutlineInputBorder(),
hintText: 'Enter playlist name',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a playlist name';
}
return null;
},
),
const SizedBox(height: 16),
// Description field
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (Optional)',
border: OutlineInputBorder(),
hintText: 'Enter playlist description',
),
maxLines: 3,
),
const SizedBox(height: 16),
// Icon selector
Text(
'Icon',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Container(
height: 120,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _availableIcons.length,
itemBuilder: (context, index) {
final icon = _availableIcons[index];
final isSelected = _selectedIcon == icon['name'];
return GestureDetector(
onTap: () {
setState(() {
_selectedIcon = icon['name']!;
});
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.2)
: null,
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
icon['icon']!,
style: const TextStyle(fontSize: 20),
),
),
),
);
},
),
),
const SizedBox(height: 24),
Text(
'Episode Filters',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Episode filters
CheckboxListTile(
title: const Text('Include Unplayed'),
value: _includeUnplayed,
onChanged: (value) {
setState(() {
_includeUnplayed = value ?? true;
});
},
),
CheckboxListTile(
title: const Text('Include Partially Played'),
value: _includePartiallyPlayed,
onChanged: (value) {
setState(() {
_includePartiallyPlayed = value ?? true;
});
},
),
CheckboxListTile(
title: const Text('Include Played'),
value: _includePlayed,
onChanged: (value) {
setState(() {
_includePlayed = value ?? false;
});
},
),
const SizedBox(height: 16),
// Duration range
Text(
'Duration Range (minutes)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Min',
border: OutlineInputBorder(),
hintText: 'Any',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_minDuration = value;
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Max',
border: OutlineInputBorder(),
hintText: 'Any',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_maxDuration = value;
},
),
),
],
),
const SizedBox(height: 16),
// Sort order
DropdownButtonFormField<String>(
value: _sortOrder,
decoration: const InputDecoration(
labelText: 'Sort Order',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'newest_first', child: Text('Newest First')),
DropdownMenuItem(value: 'oldest_first', child: Text('Oldest First')),
DropdownMenuItem(value: 'shortest_first', child: Text('Shortest First')),
DropdownMenuItem(value: 'longest_first', child: Text('Longest First')),
],
onChanged: (value) {
setState(() {
_sortOrder = value!;
});
},
),
const SizedBox(height: 16),
// Max episodes
TextFormField(
decoration: const InputDecoration(
labelText: 'Max Episodes (Optional)',
border: OutlineInputBorder(),
hintText: 'Leave blank for no limit',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_maxEpisodes = value;
},
),
const SizedBox(height: 16),
// Group by podcast
CheckboxListTile(
title: const Text('Group by Podcast'),
subtitle: const Text('Group episodes by their podcast'),
value: _groupByPodcast,
onChanged: (value) {
setState(() {
_groupByPodcast = value ?? false;
});
},
),
const SizedBox(height: 32),
],
),
),
);
}
}

View File

@@ -0,0 +1,968 @@
// lib/ui/pinepods/downloads.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/download/download_service.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
import 'package:logging/logging.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsDownloads extends StatefulWidget {
const PinepodsDownloads({super.key});
@override
State<PinepodsDownloads> createState() => _PinepodsDownloadsState();
}
class _PinepodsDownloadsState extends State<PinepodsDownloads> {
final log = Logger('PinepodsDownloads');
final PinepodsService _pinepodsService = PinepodsService();
List<PinepodsEpisode> _serverDownloads = [];
List<Episode> _localDownloads = [];
Map<String, List<PinepodsEpisode>> _serverDownloadsByPodcast = {};
Map<String, List<Episode>> _localDownloadsByPodcast = {};
bool _isLoadingServerDownloads = false;
bool _isLoadingLocalDownloads = false;
String? _errorMessage;
Set<String> _expandedPodcasts = {};
int? _contextMenuEpisodeIndex;
bool _isServerEpisode = false;
// Search functionality
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
Map<String, List<PinepodsEpisode>> _filteredServerDownloadsByPodcast = {};
Map<String, List<Episode>> _filteredLocalDownloadsByPodcast = {};
@override
void initState() {
super.initState();
_loadDownloads();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterDownloads();
});
}
void _filterDownloads() {
// Filter server downloads
_filteredServerDownloadsByPodcast = {};
for (final entry in _serverDownloadsByPodcast.entries) {
final podcastName = entry.key;
final episodes = entry.value;
if (_searchQuery.isEmpty) {
_filteredServerDownloadsByPodcast[podcastName] = List.from(episodes);
} else {
final filteredEpisodes = episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
if (filteredEpisodes.isNotEmpty) {
_filteredServerDownloadsByPodcast[podcastName] = filteredEpisodes;
}
}
}
// Filter local downloads (will be called when local downloads are loaded)
_filterLocalDownloads();
}
void _filterLocalDownloads([Map<String, List<Episode>>? localDownloadsByPodcast]) {
final downloadsToFilter = localDownloadsByPodcast ?? _localDownloadsByPodcast;
_filteredLocalDownloadsByPodcast = {};
for (final entry in downloadsToFilter.entries) {
final podcastName = entry.key;
final episodes = entry.value;
if (_searchQuery.isEmpty) {
_filteredLocalDownloadsByPodcast[podcastName] = List.from(episodes);
} else {
final filteredEpisodes = episodes.where((episode) {
return (episode.title ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
if (filteredEpisodes.isNotEmpty) {
_filteredLocalDownloadsByPodcast[podcastName] = filteredEpisodes;
}
}
}
}
Future<void> _loadDownloads() async {
await Future.wait([
_loadServerDownloads(),
_loadLocalDownloads(),
]);
}
Future<void> _loadServerDownloads() async {
setState(() {
_isLoadingServerDownloads = true;
_errorMessage = null;
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final downloads = await _pinepodsService.getServerDownloads(settings.pinepodsUserId!);
setState(() {
_serverDownloads = downloads;
_serverDownloadsByPodcast = _groupEpisodesByPodcast(downloads);
_filterDownloads(); // Initialize filtered data
_isLoadingServerDownloads = false;
});
} else {
setState(() {
_isLoadingServerDownloads = false;
});
}
} catch (e) {
log.severe('Error loading server downloads: $e');
setState(() {
_errorMessage = 'Failed to load server downloads: $e';
_isLoadingServerDownloads = false;
});
}
}
Future<void> _loadLocalDownloads() async {
setState(() {
_isLoadingLocalDownloads = true;
});
try {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
episodeBloc.fetchDownloads(false);
// Debug: Let's also directly check what the repository returns
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
final directDownloads = await podcastBloc.podcastService.loadDownloads();
print('DEBUG: Direct downloads from repository: ${directDownloads.length} episodes');
for (var episode in directDownloads) {
print('DEBUG: Episode: ${episode.title}, GUID: ${episode.guid}, Downloaded: ${episode.downloaded}, Percentage: ${episode.downloadPercentage}');
}
setState(() {
_isLoadingLocalDownloads = false;
});
} catch (e) {
log.severe('Error loading local downloads: $e');
setState(() {
_isLoadingLocalDownloads = false;
});
}
}
Map<String, List<PinepodsEpisode>> _groupEpisodesByPodcast(List<PinepodsEpisode> episodes) {
final grouped = <String, List<PinepodsEpisode>>{};
for (final episode in episodes) {
final podcastName = episode.podcastName;
if (!grouped.containsKey(podcastName)) {
grouped[podcastName] = [];
}
grouped[podcastName]!.add(episode);
}
// Sort episodes within each podcast by publication date (newest first)
for (final episodes in grouped.values) {
episodes.sort((a, b) {
try {
final dateA = DateTime.parse(a.episodePubDate);
final dateB = DateTime.parse(b.episodePubDate);
return dateB.compareTo(dateA); // newest first
} catch (e) {
return 0;
}
});
}
return grouped;
}
Map<String, List<Episode>> _groupLocalEpisodesByPodcast(List<Episode> episodes) {
final grouped = <String, List<Episode>>{};
for (final episode in episodes) {
final podcastName = episode.podcast ?? 'Unknown Podcast';
if (!grouped.containsKey(podcastName)) {
grouped[podcastName] = [];
}
grouped[podcastName]!.add(episode);
}
// Sort episodes within each podcast by publication date (newest first)
for (final episodes in grouped.values) {
episodes.sort((a, b) {
if (a.publicationDate == null || b.publicationDate == null) {
return 0;
}
return b.publicationDate!.compareTo(a.publicationDate!);
});
}
return grouped;
}
void _togglePodcastExpansion(String podcastKey) {
setState(() {
if (_expandedPodcasts.contains(podcastKey)) {
_expandedPodcasts.remove(podcastKey);
} else {
_expandedPodcasts.add(podcastKey);
}
});
}
Future<void> _handleServerEpisodeDelete(PinepodsEpisode episode) async {
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsUserId != null) {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
settings.pinepodsUserId!,
episode.isYoutube,
);
if (success) {
// Remove from local state
setState(() {
_serverDownloads.removeWhere((e) => e.episodeId == episode.episodeId);
_serverDownloadsByPodcast = _groupEpisodesByPodcast(_serverDownloads);
_filterDownloads(); // Update filtered lists after removal
});
} else {
_showErrorSnackBar('Failed to delete episode from server');
}
}
} catch (e) {
log.severe('Error deleting server episode: $e');
_showErrorSnackBar('Error deleting episode: $e');
}
}
void _handleLocalEpisodeDelete(Episode episode) {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
episodeBloc.deleteDownload(episode);
// The episode bloc will automatically update the downloads stream
// which will trigger a UI refresh
}
void _showContextMenu(int episodeIndex, bool isServerEpisode) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
_isServerEpisode = isServerEpisode;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
_isServerEpisode = false;
});
}
Future<void> _localDownloadServerEpisode(int episodeIndex) async {
final episode = _serverDownloads[episodeIndex];
try {
// Convert PinepodsEpisode to Episode for local download
final localEpisode = Episode(
guid: 'pinepods_${episode.episodeId}_${DateTime.now().millisecondsSinceEpoch}',
pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}',
podcast: episode.podcastName,
title: episode.episodeTitle,
description: episode.episodeDescription,
imageUrl: episode.episodeArtwork,
contentUrl: episode.episodeUrl,
duration: episode.episodeDuration,
publicationDate: DateTime.tryParse(episode.episodePubDate),
author: episode.podcastName,
season: 0,
episode: 0,
position: episode.listenDuration ?? 0,
played: episode.completed,
chapters: [],
transcriptUrls: [],
);
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
// First save the episode to the repository so it can be tracked
await podcastBloc.podcastService.saveEpisode(localEpisode);
// Use the download service from podcast bloc
final success = await podcastBloc.downloadService.downloadEpisode(localEpisode);
if (success) {
_showSnackBar('Episode download started', Colors.green);
} else {
_showSnackBar('Failed to start download', Colors.red);
}
} catch (e) {
_showSnackBar('Error starting local download: $e', Colors.red);
}
_hideContextMenu();
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Widget _buildPodcastDropdown(String podcastKey, List<dynamic> episodes, {bool isServerDownload = false, String? displayName}) {
final isExpanded = _expandedPodcasts.contains(podcastKey);
final title = displayName ?? podcastKey;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Column(
children: [
ListTile(
leading: Icon(
isServerDownload ? Icons.cloud_download : Icons.file_download,
color: isServerDownload ? Colors.blue : Colors.green,
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${episodes.length} episode${episodes.length != 1 ? 's' : ''}' +
(episodes.length > 20 ? ' (showing 20 at a time)' : '')
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episodes.length > 20)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Large',
style: TextStyle(
fontSize: 10,
color: Colors.orange[800],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
),
],
),
onTap: () => _togglePodcastExpansion(podcastKey),
),
if (isExpanded)
PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: isServerDownload,
onEpisodeTap: isServerDownload
? (episode) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
}
: null,
onEpisodeLongPress: isServerDownload
? (episode, globalIndex) {
// Find the index in the full _serverDownloads list
final serverIndex = _serverDownloads.indexWhere((e) => e.episodeId == episode.episodeId);
_showContextMenu(serverIndex >= 0 ? serverIndex : globalIndex, true);
}
: null,
onPlayPressed: isServerDownload
? (episode) => _playServerEpisode(episode)
: (episode) => _playLocalEpisode(episode),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final episodeBloc = Provider.of<EpisodeBloc>(context);
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isServerEpisode) {
// Show server episode context menu
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: _serverDownloads[episodeIndex],
onDownload: () {
Navigator.of(context).pop();
_handleServerEpisodeDelete(_serverDownloads[episodeIndex]);
_hideContextMenu();
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadServerEpisode(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
}
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return StreamBuilder<BlocState>(
stream: episodeBloc.downloads,
builder: (context, snapshot) {
final localDownloadsState = snapshot.data;
List<Episode> currentLocalDownloads = [];
Map<String, List<Episode>> currentLocalDownloadsByPodcast = {};
if (localDownloadsState is BlocPopulatedState<List<Episode>>) {
currentLocalDownloads = localDownloadsState.results ?? [];
currentLocalDownloadsByPodcast = _groupLocalEpisodesByPodcast(currentLocalDownloads);
}
final isLoading = _isLoadingServerDownloads ||
_isLoadingLocalDownloads ||
(localDownloadsState is BlocLoadingState);
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: PlatformProgressIndicator()),
);
}
// Update filtered local downloads when local downloads change
_filterLocalDownloads(currentLocalDownloadsByPodcast);
if (_errorMessage != null) {
// Check if this is a server connection error - show offline mode for downloads
if (_errorMessage!.isServerConnectionError) {
// Show offline downloads only with special UI
return _buildOfflineDownloadsView(_filteredLocalDownloadsByPodcast);
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!.userFriendlyMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadDownloads,
child: const Text('Retry'),
),
],
),
),
);
}
}
if (_filteredLocalDownloadsByPodcast.isEmpty && _filteredServerDownloadsByPodcast.isEmpty) {
if (_searchQuery.isNotEmpty) {
// Show no search results message
return MultiSliver(
children: [
_buildSearchBar(),
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No downloads found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'No downloads match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
);
} else {
// Show empty downloads message
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No downloads found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Downloaded episodes will appear here',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildDownloadsList(),
],
);
},
);
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter episodes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
);
}
Widget _buildDownloadsList() {
return SliverList(
delegate: SliverChildListDelegate([
// Local Downloads Section
if (_filteredLocalDownloadsByPodcast.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.smartphone, color: Colors.green[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Local Downloads'
: 'Local Downloads (${_countFilteredEpisodes(_filteredLocalDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green[600],
),
),
],
),
),
..._filteredLocalDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'local_$podcastName';
return _buildPodcastDropdown(
podcastKey,
episodes,
isServerDownload: false,
displayName: podcastName,
);
}).toList(),
],
// Server Downloads Section
if (_filteredServerDownloadsByPodcast.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
Icon(Icons.cloud_download, color: Colors.blue[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Server Downloads'
: 'Server Downloads (${_countFilteredEpisodes(_filteredServerDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue[600],
),
),
],
),
),
..._filteredServerDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'server_$podcastName';
return _buildPodcastDropdown(
podcastKey,
episodes,
isServerDownload: true,
displayName: podcastName,
);
}).toList(),
],
// Bottom padding
const SizedBox(height: 100),
]),
);
}
int _countFilteredEpisodes(Map<String, List<dynamic>> downloadsByPodcast) {
return downloadsByPodcast.values.fold(0, (sum, episodes) => sum + episodes.length);
}
void _playServerEpisode(PinepodsEpisode episode) {
// TODO: Implement server episode playback
// This would involve getting the stream URL from the server
// and playing it through the audio service
log.info('Playing server episode: ${episode.episodeTitle}');
_showErrorSnackBar('Server episode playback not yet implemented');
}
Future<void> _playLocalEpisode(Episode episode) async {
try {
log.info('Playing local episode: ${episode.title}');
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Use the regular audio player service for offline playback
// This bypasses the PinePods service and server dependencies
await audioPlayerService.playEpisode(episode: episode, resume: true);
log.info('Successfully started local episode playback');
} catch (e) {
log.severe('Error playing local episode: $e');
_showErrorSnackBar('Failed to play episode: $e');
}
}
Widget _buildOfflinePodcastDropdown(String podcastKey, List<Episode> episodes, {String? displayName}) {
final isExpanded = _expandedPodcasts.contains(podcastKey);
final title = displayName ?? podcastKey;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Column(
children: [
ListTile(
leading: Icon(
Icons.offline_pin,
color: Colors.green[700],
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${episodes.length} episode${episodes.length != 1 ? 's' : ''} available offline'
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Offline',
style: TextStyle(
fontSize: 10,
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
),
],
),
onTap: () => _togglePodcastExpansion(podcastKey),
),
if (isExpanded)
PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: false,
isOfflineMode: true,
onPlayPressed: (episode) => _playLocalEpisode(episode),
),
],
),
);
}
Widget _buildOfflineDownloadsView(Map<String, List<Episode>> localDownloadsByPodcast) {
return MultiSliver(
children: [
// Offline banner
SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Colors.orange[100],
border: Border.all(color: Colors.orange[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.cloud_off,
color: Colors.orange[800],
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offline Mode',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange[800],
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'Server unavailable. Showing local downloads only.',
style: TextStyle(
color: Colors.orange[700],
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () {
setState(() {
_errorMessage = null;
});
_loadDownloads();
},
icon: Icon(
Icons.refresh,
size: 16,
color: Colors.orange[800],
),
label: Text(
'Retry',
style: TextStyle(
color: Colors.orange[800],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange[50],
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
),
// Search bar for filtering local downloads
_buildSearchBar(),
// Local downloads content
if (localDownloadsByPodcast.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No local downloads',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Download episodes while online to access them here',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildListDelegate([
// Local downloads header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.smartphone, color: Colors.green[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Local Downloads'
: 'Local Downloads (${_countFilteredEpisodes(localDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green[600],
),
),
],
),
),
// Local downloads by podcast
...localDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'offline_local_$podcastName';
return _buildOfflinePodcastDropdown(
podcastKey,
episodes,
displayName: podcastName,
);
}).toList(),
// Bottom padding
const SizedBox(height: 100),
]),
),
],
);
}
}

View File

@@ -0,0 +1,963 @@
// lib/ui/pinepods/episode_details.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/episode_description.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/podcast/mini_player.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
class PinepodsEpisodeDetails extends StatefulWidget {
final PinepodsEpisode initialEpisode;
const PinepodsEpisodeDetails({
Key? key,
required this.initialEpisode,
}) : super(key: key);
@override
State<PinepodsEpisodeDetails> createState() => _PinepodsEpisodeDetailsState();
}
class _PinepodsEpisodeDetailsState extends State<PinepodsEpisodeDetails> {
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
PinepodsEpisode? _episode;
bool _isLoading = true;
String _errorMessage = '';
List<Person> _persons = [];
bool _isDownloadedLocally = false;
@override
void initState() {
super.initState();
_episode = widget.initialEpisode;
_loadEpisodeDetails();
_checkLocalDownloadStatus();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _checkLocalDownloadStatus() async {
if (_episode == null) return;
final isDownloaded = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, _episode!);
if (mounted) {
setState(() {
_isDownloadedLocally = isDownloaded;
});
}
}
Future<void> _localDownloadEpisode() async {
if (_episode == null) return;
final success = await LocalDownloadUtils.localDownloadEpisode(context, _episode!);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
await _checkLocalDownloadStatus(); // Update button state
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload() async {
if (_episode == null) return;
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, _episode!);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
await _checkLocalDownloadStatus(); // Update button state
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
Future<void> _loadEpisodeDetails() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodeDetails = await _pinepodsService.getEpisodeMetadata(
_episode!.episodeId,
userId,
isYoutube: _episode!.isYoutube,
personEpisode: false, // Adjust if needed
);
if (episodeDetails != null) {
// Fetch podcast 2.0 data for persons information
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(
episodeDetails.episodeId,
userId,
);
List<Person> persons = [];
if (podcast2Data != null) {
final personsData = podcast2Data['people'] as List<dynamic>?;
if (personsData != null) {
try {
persons = personsData.map((personData) {
return Person(
name: personData['name'] ?? '',
role: personData['role'] ?? '',
group: personData['group'] ?? '',
image: personData['img'],
link: personData['href'],
);
}).toList();
print('Loaded ${persons.length} persons from episode 2.0 data');
} catch (e) {
print('Error parsing persons data: $e');
}
}
}
setState(() {
_episode = episodeDetails;
_persons = persons;
_isLoading = false;
});
} else {
setState(() {
_errorMessage = 'Failed to load episode details';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_errorMessage = 'Error loading episode details: ${e.toString()}';
_isLoading = false;
});
}
}
bool _isCurrentEpisodePlaying() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
final currentEpisode = audioPlayerService.nowPlaying;
return currentEpisode != null && currentEpisode.guid == _episode!.episodeUrl;
} catch (e) {
return false;
}
}
bool _isAudioPlaying() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// This method is no longer needed since we're using StreamBuilder
return false;
} catch (e) {
return false;
}
}
Future<void> _togglePlayPause() async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Check if this episode is currently playing
if (_isCurrentEpisodePlaying()) {
// This episode is loaded, check current state and toggle
final currentState = audioPlayerService.playingState;
if (currentState != null) {
// Listen to the current state
final state = await currentState.first;
if (state == AudioState.playing) {
await audioPlayerService.pause();
} else {
await audioPlayerService.play();
}
} else {
await audioPlayerService.play();
}
} else {
// Start playing this episode
await playPinepodsEpisodeWithOptionalFullScreen(
context,
_audioService!,
_episode!,
resume: _episode!.isStarted,
);
}
} catch (e) {
_showSnackBar('Failed to control playback: ${e.toString()}', Colors.red);
}
}
Future<void> _handleTimestampTap(Duration timestamp) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Check if this episode is currently playing
final currentEpisode = audioPlayerService.nowPlaying;
final isCurrentEpisode = currentEpisode != null &&
currentEpisode.guid == _episode!.episodeUrl;
if (!isCurrentEpisode) {
// Start playing the episode first
await playPinepodsEpisodeWithOptionalFullScreen(
context,
_audioService!,
_episode!,
resume: false, // Start from beginning initially
);
// Wait a moment for the episode to start loading
await Future.delayed(const Duration(milliseconds: 500));
}
// Seek to the timestamp (convert Duration to seconds as int)
await audioPlayerService.seek(position: timestamp.inSeconds);
} catch (e) {
_showSnackBar('Failed to jump to timestamp: ${e.toString()}', Colors.red);
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
Future<void> _saveEpisode() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
Future<void> _removeSavedEpisode() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, saved: false);
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
Future<void> _toggleQueue() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.queued) {
success = await _pinepodsService.removeQueuedEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, queued: false);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
Future<void> _toggleDownload() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.downloaded) {
success = await _pinepodsService.deleteEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
}
} else {
success = await _pinepodsService.downloadEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update download', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating download: $e', Colors.red);
}
}
Future<void> _toggleComplete() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, completed: true);
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
podcastId: episode.podcastId,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Future<void> _navigateToPodcast() async {
if (_episode!.podcastId == null) {
_showSnackBar('Podcast ID not available', Colors.orange);
return;
}
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
// Fetch the actual podcast details to get correct episode count
final podcastDetails = await _pinepodsService.getPodcastDetailsById(_episode!.podcastId!, userId);
final podcast = UnifiedPinepodsPodcast(
id: _episode!.podcastId!,
indexId: 0,
title: _episode!.podcastName,
url: podcastDetails?['feedurl'] ?? '',
originalUrl: podcastDetails?['feedurl'] ?? '',
link: podcastDetails?['websiteurl'] ?? '',
description: podcastDetails?['description'] ?? '',
author: podcastDetails?['author'] ?? '',
ownerName: podcastDetails?['author'] ?? '',
image: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
artwork: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
lastUpdateTime: 0,
explicit: podcastDetails?['explicit'] ?? false,
episodeCount: podcastDetails?['episodecount'] ?? 0,
);
// Navigate to podcast details - same as podcast tile does
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: podcast,
isFollowing: true, // Assume following since we have a podcast ID
),
),
);
} catch (e) {
_showSnackBar('Error navigating to podcast: $e', Colors.red);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(
title: const Text('Episode Details'),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading episode details...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return Scaffold(
appBar: AppBar(
title: const Text('Episode Details'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadEpisodeDetails,
child: const Text('Retry'),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_episode!.podcastName),
elevation: 0,
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork and basic info
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _episode!.episodeArtwork.isNotEmpty
? Image.network(
_episode!.episodeArtwork,
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 48,
),
);
},
)
: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 48,
),
),
),
const SizedBox(width: 16),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Clickable podcast name
GestureDetector(
onTap: () => _navigateToPodcast(),
child: Text(
_episode!.podcastName,
style: Theme.of(context).textTheme.titleMedium!.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).primaryColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
_episode!.episodeTitle,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
_episode!.formattedDuration,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_episode!.formattedPubDate,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.grey[600],
),
),
if (_episode!.isStarted) ...[
const SizedBox(height: 8),
Text(
'Listened: ${_episode!.formattedListenDuration}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).primaryColor,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: _episode!.progressPercentage / 100,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
],
],
),
),
],
),
const SizedBox(height: 24),
// Action buttons
Column(
children: [
// First row: Play, Save, Queue (3 buttons, each 1/3 width)
Row(
children: [
// Play/Pause button
Expanded(
child: StreamBuilder<AudioState>(
stream: Provider.of<AudioPlayerService>(context, listen: false).playingState,
builder: (context, snapshot) {
final isCurrentEpisode = _isCurrentEpisodePlaying();
final isPlaying = snapshot.data == AudioState.playing;
final isCurrentlyPlaying = isCurrentEpisode && isPlaying;
IconData icon;
String label;
if (_episode!.completed) {
icon = Icons.replay;
label = 'Replay';
} else if (isCurrentlyPlaying) {
icon = Icons.pause;
label = 'Pause';
} else {
icon = Icons.play_arrow;
label = 'Play';
}
return OutlinedButton.icon(
onPressed: _togglePlayPause,
icon: Icon(icon),
label: Text(label),
);
},
),
),
const SizedBox(width: 8),
// Save/Unsave button
Expanded(
child: OutlinedButton.icon(
onPressed: _episode!.saved ? _removeSavedEpisode : _saveEpisode,
icon: Icon(
_episode!.saved ? Icons.bookmark : Icons.bookmark_outline,
color: _episode!.saved ? Colors.orange : null,
),
label: Text(_episode!.saved ? 'Saved' : 'Save'),
),
),
const SizedBox(width: 8),
// Queue button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleQueue,
icon: Icon(
_episode!.queued ? Icons.queue_music : Icons.queue_music_outlined,
color: _episode!.queued ? Colors.purple : null,
),
label: Text(_episode!.queued ? 'Queued' : 'Queue'),
),
),
],
),
const SizedBox(height: 8),
// Second row: Download, Complete (2 buttons, each 1/2 width)
Row(
children: [
// Download button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleDownload,
icon: Icon(
_episode!.downloaded ? Icons.download_done : Icons.download_outlined,
color: _episode!.downloaded ? Colors.blue : null,
),
label: Text(_episode!.downloaded ? 'Downloaded' : 'Download'),
),
),
const SizedBox(width: 8),
// Complete button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleComplete,
icon: Icon(
_episode!.completed ? Icons.check_circle : Icons.check_circle_outline,
color: _episode!.completed ? Colors.green : null,
),
label: Text(_episode!.completed ? 'Complete' : 'Mark Complete'),
),
),
],
),
const SizedBox(height: 8),
// Third row: Local Download (full width)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isDownloadedLocally ? _deleteLocalDownload : _localDownloadEpisode,
icon: Icon(
_isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined,
color: _isDownloadedLocally ? Colors.red : Colors.green,
),
label: Text(_isDownloadedLocally ? 'Delete Local Download' : 'Download Locally'),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: _isDownloadedLocally ? Colors.red : Colors.green,
),
),
),
),
],
),
],
),
// Hosts/Guests section
if (_persons.isNotEmpty) ...[
const SizedBox(height: 24),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Hosts & Guests',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _persons.length,
itemBuilder: (context, index) {
final person = _persons[index];
return Container(
width: 70,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey[300],
),
child: person.image != null && person.image!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(25),
child: PodcastImage(
url: person.image!,
width: 50,
height: 50,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.person,
size: 30,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Text(
person.name,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
),
],
const SizedBox(height: 32),
// Episode description
Text(
'Description',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
EpisodeDescription(
content: _episode!.episodeDescription,
onTimestampTap: _handleTimestampTap,
),
],
),
),
),
const MiniPlayer(),
],
),
);
}
@override
void dispose() {
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
}

View File

@@ -0,0 +1,817 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:pinepods_mobile/services/search_history_service.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
/// Episode search page for finding episodes in user's subscriptions
///
/// This page allows users to search through episodes in their subscribed podcasts
/// with debounced search input and animated loading states.
class EpisodeSearchPage extends StatefulWidget {
const EpisodeSearchPage({Key? key}) : super(key: key);
@override
State<EpisodeSearchPage> createState() => _EpisodeSearchPageState();
}
class _EpisodeSearchPageState extends State<EpisodeSearchPage> with TickerProviderStateMixin {
final PinepodsService _pinepodsService = PinepodsService();
final SearchHistoryService _searchHistoryService = SearchHistoryService();
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
Timer? _debounceTimer;
List<SearchEpisodeResult> _searchResults = [];
List<String> _searchHistory = [];
bool _isLoading = false;
bool _hasSearched = false;
bool _showHistory = false;
String? _errorMessage;
String _currentQuery = '';
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
// Animation controllers
late AnimationController _fadeAnimationController;
late AnimationController _slideAnimationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_setupAnimations();
_setupSearch();
}
void _setupAnimations() {
// Fade animation for results
_fadeAnimationController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeAnimationController,
curve: Curves.easeInOut,
));
// Slide animation for search bar
_slideAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0),
end: const Offset(0, -0.2),
).animate(CurvedAnimation(
parent: _slideAnimationController,
curve: Curves.easeInOut,
));
}
void _setupSearch() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
}
_searchController.addListener(_onSearchChanged);
_loadSearchHistory();
}
Future<void> _loadSearchHistory() async {
final history = await _searchHistoryService.getEpisodeSearchHistory();
if (mounted) {
setState(() {
_searchHistory = history;
});
}
}
void _selectHistoryItem(String searchTerm) {
_searchController.text = searchTerm;
_performSearch(searchTerm);
}
Future<void> _removeHistoryItem(String searchTerm) async {
await _searchHistoryService.removeEpisodeSearchTerm(searchTerm);
await _loadSearchHistory();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${episode.episodeTitle}'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showContextMenu(int episodeIndex) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode saved', Colors.green);
// Update local state
setState(() {
_searchResults[episodeIndex] = SearchEpisodeResult(
podcastId: _searchResults[episodeIndex].podcastId,
podcastName: _searchResults[episodeIndex].podcastName,
artworkUrl: _searchResults[episodeIndex].artworkUrl,
author: _searchResults[episodeIndex].author,
categories: _searchResults[episodeIndex].categories,
description: _searchResults[episodeIndex].description,
episodeCount: _searchResults[episodeIndex].episodeCount,
feedUrl: _searchResults[episodeIndex].feedUrl,
websiteUrl: _searchResults[episodeIndex].websiteUrl,
explicit: _searchResults[episodeIndex].explicit,
userId: _searchResults[episodeIndex].userId,
episodeId: _searchResults[episodeIndex].episodeId,
episodeTitle: _searchResults[episodeIndex].episodeTitle,
episodeDescription: _searchResults[episodeIndex].episodeDescription,
episodePubDate: _searchResults[episodeIndex].episodePubDate,
episodeArtwork: _searchResults[episodeIndex].episodeArtwork,
episodeUrl: _searchResults[episodeIndex].episodeUrl,
episodeDuration: _searchResults[episodeIndex].episodeDuration,
completed: _searchResults[episodeIndex].completed,
saved: true, // We just saved it
queued: _searchResults[episodeIndex].queued,
downloaded: _searchResults[episodeIndex].downloaded,
isYoutube: _searchResults[episodeIndex].isYoutube,
listenDuration: _searchResults[episodeIndex].listenDuration,
);
});
} else if (mounted) {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from saved', Colors.orange);
} else if (mounted) {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
_showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual download implementation would depend on download service integration
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
_showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange);
// Note: Actual delete implementation would depend on download service integration
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
_showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual local download implementation would depend on download service integration
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.queued) {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from queue', Colors.orange);
}
} else {
final success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode added to queue', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.completed) {
final success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as incomplete', Colors.orange);
}
} else {
final success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as complete', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating completion status: $e', Colors.red);
}
}
}
void _showSnackBar(String message, Color backgroundColor) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
}
void _onSearchChanged() {
final query = _searchController.text.trim();
setState(() {
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
});
if (_debounceTimer?.isActive ?? false) {
_debounceTimer!.cancel();
}
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
if (query.isNotEmpty && query != _currentQuery) {
_currentQuery = query;
_performSearch(query);
} else if (query.isEmpty) {
_clearResults();
}
});
}
Future<void> _performSearch(String query) async {
setState(() {
_isLoading = true;
_errorMessage = null;
_showHistory = false;
});
// Save search term to history
await _searchHistoryService.addEpisodeSearchTerm(query);
await _loadSearchHistory();
// Animate search bar to top
_slideAnimationController.forward();
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
throw Exception('Not logged in');
}
final results = await _pinepodsService.searchEpisodes(userId, query);
setState(() {
_searchResults = results;
_isLoading = false;
_hasSearched = true;
});
// Animate results in
_fadeAnimationController.forward();
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
_hasSearched = true;
_searchResults = [];
});
}
}
void _clearResults() {
setState(() {
_searchResults = [];
_hasSearched = false;
_errorMessage = null;
_currentQuery = '';
_showHistory = _searchHistory.isNotEmpty;
});
_fadeAnimationController.reset();
_slideAnimationController.reverse();
}
Widget _buildSearchBar() {
return SlideTransition(
position: _slideAnimation,
child: Container(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor.withOpacity(0.1),
Theme.of(context).primaryColor.withOpacity(0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: Theme.of(context).textTheme.bodyLarge,
onTap: () {
setState(() {
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
});
},
decoration: InputDecoration(
hintText: 'Search for episodes...',
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
prefixIcon: Icon(
Icons.search,
color: Theme.of(context).primaryColor,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: Theme.of(context).primaryColor,
),
onPressed: () {
_searchController.clear();
_clearResults();
_focusNode.requestFocus();
},
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
),
),
),
),
);
}
Widget _buildLoadingIndicator() {
return Container(
padding: const EdgeInsets.all(64),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Searching...',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).primaryColor,
),
),
],
),
);
}
Widget _buildEmptyState() {
if (!_hasSearched) {
return Container(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Search Your Episodes',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Find episodes from your subscribed podcasts',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.center,
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).hintColor,
),
const SizedBox(height: 16),
Text(
'No Episodes Found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Try adjusting your search terms',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
),
],
),
);
}
Widget _buildErrorState() {
return Container(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Search Error',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Unknown error occurred',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_currentQuery.isNotEmpty) {
_performSearch(_currentQuery);
}
},
child: const Text('Try Again'),
),
],
),
);
}
Widget _buildResults() {
// Convert search results to PinepodsEpisode objects
final episodes = _searchResults.map((result) => result.toPinepodsEpisode()).toList();
return FadeTransition(
opacity: _fadeAnimation,
child: PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: true,
pageSize: 20, // Show 20 episodes at a time for good performance
onEpisodeTap: (episode) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onEpisodeLongPress: (episode, globalIndex) {
// Find the original index in _searchResults for context menu
final originalIndex = _searchResults.indexWhere(
(result) => result.episodeId == episode.episodeId
);
if (originalIndex != -1) {
_showContextMenu(originalIndex);
}
},
onPlayPressed: (episode) => _playEpisode(episode),
),
);
}
Widget _buildSearchHistory() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Recent Searches',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_searchHistory.isNotEmpty)
TextButton(
onPressed: () async {
await _searchHistoryService.clearEpisodeSearchHistory();
await _loadSearchHistory();
},
child: Text(
'Clear All',
style: TextStyle(
color: Theme.of(context).hintColor,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 8),
..._searchHistory.take(10).map((searchTerm) => Card(
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
dense: true,
leading: Icon(
Icons.history,
color: Theme.of(context).hintColor,
size: 20,
),
title: Text(
searchTerm,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: Icon(
Icons.close,
size: 18,
color: Theme.of(context).hintColor,
),
onPressed: () => _removeHistoryItem(searchTerm),
),
onTap: () => _selectHistoryItem(searchTerm),
),
)).toList(),
],
),
);
}
@override
Widget build(BuildContext context) {
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!; // Store locally to avoid null issues
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return SliverFillRemaining(
child: GestureDetector(
onTap: () {
// Dismiss keyboard when tapping outside
FocusScope.of(context).unfocus();
},
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: SingleChildScrollView(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _showHistory
? _buildSearchHistory()
: _isLoading
? _buildLoadingIndicator()
: _errorMessage != null
? _buildErrorState()
: _searchResults.isEmpty
? _buildEmptyState()
: _buildResults(),
),
),
),
],
),
),
);
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_focusNode.dispose();
_fadeAnimationController.dispose();
_slideAnimationController.dispose();
super.dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,745 @@
// lib/ui/pinepods/history.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsHistory extends StatefulWidget {
const PinepodsHistory({Key? key}) : super(key: key);
@override
State<PinepodsHistory> createState() => _PinepodsHistoryState();
}
class _PinepodsHistoryState extends State<PinepodsHistory> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
List<PinepodsEpisode> _filteredEpisodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadHistory();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterEpisodes();
});
}
void _filterEpisodes() {
if (_searchQuery.isEmpty) {
_filteredEpisodes = List.from(_episodes);
} else {
_filteredEpisodes = _episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) ||
episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadHistory() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodes = await _pinepodsService.getUserHistory(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
// Sort episodes by publication date (newest first)
_episodes.sort((a, b) {
try {
final dateA = DateTime.parse(a.episodePubDate);
final dateB = DateTime.parse(b.episodePubDate);
return dateB.compareTo(dateA); // Newest first
} catch (e) {
return 0; // Keep original order if parsing fails
}
});
_filterEpisodes(); // Initialize filtered list
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load listening history: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadHistory();
}
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('Starting ${episode.episodeTitle}...'),
],
),
duration: const Duration(seconds: 2),
),
);
await _audioService!.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: episode.isStarted,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now playing: ${episode.episodeTitle}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
isDownloadedLocally: isDownloadedLocally,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onDeleteLocalDownload: () {
Navigator.of(context).pop();
_deleteLocalDownload(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
},
),
);
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.downloadEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Episode download queued!', Colors.green);
} else {
_showSnackBar('Failed to queue download', Colors.red);
}
} catch (e) {
_showSnackBar('Error downloading episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Episode deleted from server', Colors.orange);
} else {
_showSnackBar('Failed to delete episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error deleting episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.queued) {
success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
_hideContextMenu();
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading listening history...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverServerErrorPage(
errorMessage: _errorMessage.isServerConnectionError
? null
: _errorMessage,
onRetry: _refresh,
title: 'History Unavailable',
subtitle: _errorMessage.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load listening history',
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No listening history',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you listen to will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildEpisodesList(),
],
);
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter episodes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
);
}
Widget _buildEpisodesList() {
// Check if search returned no results
if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No episodes match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
// Header
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_searchQuery.isEmpty
? 'Listening History'
: 'Search Results (${_filteredEpisodes.length})',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
);
}
// Episodes (index - 1 because of header)
final episodeIndex = index - 1;
final episode = _filteredEpisodes[episodeIndex];
// Find the original index for context menu operations
final originalIndex = _episodes.indexOf(episode);
return PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: originalIndex >= 0 ? () => _showContextMenu(originalIndex) : null,
onPlayPressed: () => _playEpisode(episode),
);
},
childCount: _filteredEpisodes.length + 1, // +1 for header
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
// lib/ui/pinepods/more_menu.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/ui/library/downloads.dart';
import 'package:pinepods_mobile/ui/settings/settings.dart';
import 'package:pinepods_mobile/ui/pinepods/saved.dart';
import 'package:pinepods_mobile/ui/pinepods/history.dart';
class PinepodsMoreMenu extends StatelessWidget {
// Constructor with optional key parameter
const PinepodsMoreMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'More Options',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildMenuItem(
context,
'Downloads',
Icons.download_outlined,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Downloads')),
body: const CustomScrollView(
slivers: [Downloads()],
),
),
),
),
),
_buildMenuItem(
context,
'Saved Episodes',
Icons.bookmark_outline,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Saved Episodes')),
body: const CustomScrollView(
slivers: [PinepodsSaved()],
),
),
),
),
),
_buildMenuItem(
context,
'History',
Icons.history,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('History')),
body: const CustomScrollView(
slivers: [PinepodsHistory()],
),
),
),
),
),
_buildMenuItem(
context,
'Settings',
Icons.settings_outlined,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: true,
settings: const RouteSettings(name: 'settings'),
builder: (context) => const Settings(),
),
),
),
],
),
),
]),
);
}
Widget _buildMenuItem(
BuildContext context,
String title,
IconData icon,
VoidCallback onTap,
) {
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
child: ListTile(
leading: Icon(icon),
title: Text(title),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
),
);
}
}

View File

@@ -0,0 +1,572 @@
// lib/ui/pinepods/playlist_episodes.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
class PlaylistEpisodesPage extends StatefulWidget {
final PlaylistData playlist;
const PlaylistEpisodesPage({
Key? key,
required this.playlist,
}) : super(key: key);
@override
State<PlaylistEpisodesPage> createState() => _PlaylistEpisodesPageState();
}
class _PlaylistEpisodesPageState extends State<PlaylistEpisodesPage> {
final PinepodsService _pinepodsService = PinepodsService();
PlaylistEpisodesResponse? _playlistResponse;
bool _isLoading = true;
String? _errorMessage;
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
@override
void initState() {
super.initState();
_loadPlaylistEpisodes();
}
Future<void> _loadPlaylistEpisodes() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final response = await _pinepodsService.getPlaylistEpisodes(
settings.pinepodsUserId!,
widget.playlist.playlistId,
);
setState(() {
_playlistResponse = response;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
String _getEmptyStateMessage() {
switch (widget.playlist.name) {
case 'Fresh Releases':
return 'No new episodes have been released in the last 24 hours. Check back later for fresh content!';
case 'Currently Listening':
return 'Start listening to some episodes and they\'ll appear here for easy access.';
case 'Almost Done':
return 'You don\'t have any episodes that are near completion. Keep listening!';
default:
return 'No episodes match the current playlist criteria. Try adjusting the filters or add more podcasts.';
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
} catch (e) {
if (mounted) {
_showSnackBar('Failed to play episode: $e', Colors.red);
}
}
}
void _showContextMenu(int episodeIndex) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode saved', Colors.green);
} else if (mounted) {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from saved', Colors.orange);
} else if (mounted) {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual download implementation would depend on download service integration
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange);
// Note: Actual delete implementation would depend on download service integration
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual local download implementation would depend on download service integration
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.queued) {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from queue', Colors.orange);
}
} else {
final success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode added to queue', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.completed) {
final success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as incomplete', Colors.orange);
}
} else {
final success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as complete', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating completion status: $e', Colors.red);
}
}
}
void _showSnackBar(String message, Color backgroundColor) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
final episode = _playlistResponse!.episodes[episodeIndex];
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return Scaffold(
appBar: AppBar(
title: Text(widget.playlist.name),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
PlatformProgressIndicator(),
SizedBox(height: 16),
Text('Loading playlist episodes...'),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 75,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading playlist',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPlaylistEpisodes,
child: const Text('Retry'),
),
],
),
),
);
}
if (_playlistResponse == null) {
return const Center(
child: Text('No data available'),
);
}
return CustomScrollView(
slivers: [
// Playlist header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(_playlistResponse!.playlistInfo.iconName),
size: 48,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_playlistResponse!.playlistInfo.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (_playlistResponse!.playlistInfo.description != null &&
_playlistResponse!.playlistInfo.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_playlistResponse!.playlistInfo.description!,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${_playlistResponse!.playlistInfo.episodeCount ?? _playlistResponse!.episodes.length} episodes',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
],
),
),
],
),
],
),
),
),
// Episodes list
if (_playlistResponse!.episodes.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.playlist_remove,
size: 75,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'No Episodes Found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_getEmptyStateMessage(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _playlistResponse!.episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
childCount: _playlistResponse!.episodes.length,
),
),
],
);
}
}

View File

@@ -0,0 +1,546 @@
// lib/ui/pinepods/playlists.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/ui/pinepods/playlist_episodes.dart';
import 'package:pinepods_mobile/ui/pinepods/create_playlist.dart';
import 'package:provider/provider.dart';
class PinepodsPlaylists extends StatefulWidget {
const PinepodsPlaylists({Key? key}) : super(key: key);
@override
State<PinepodsPlaylists> createState() => _PinepodsPlaylistsState();
}
class _PinepodsPlaylistsState extends State<PinepodsPlaylists> {
final PinepodsService _pinepodsService = PinepodsService();
List<PlaylistData>? _playlists;
bool _isLoading = true;
String? _errorMessage;
Set<int> _selectedPlaylists = {};
bool _isSelectionMode = false;
@override
void initState() {
super.initState();
_loadPlaylists();
}
/// Calculate responsive cross axis count for playlist grid
int _getPlaylistCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for playlist cards
double _getPlaylistAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for multi-line descriptions + padding
return 1.8; // Allows space for title + 2-3 lines of description + proper padding
}
return 1.1; // Standard aspect ratio for multi-column layouts
}
Future<void> _loadPlaylists() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final playlists = await _pinepodsService.getUserPlaylists(settings.pinepodsUserId!);
setState(() {
_playlists = playlists;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedPlaylists.clear();
}
});
}
void _togglePlaylistSelection(int playlistId) {
setState(() {
if (_selectedPlaylists.contains(playlistId)) {
_selectedPlaylists.remove(playlistId);
} else {
_selectedPlaylists.add(playlistId);
}
});
}
Future<void> _deleteSelectedPlaylists() async {
if (_selectedPlaylists.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playlists'),
content: Text('Are you sure you want to delete ${_selectedPlaylists.length} playlist${_selectedPlaylists.length == 1 ? '' : 's'}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
try {
for (final playlistId in _selectedPlaylists) {
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlistId);
}
setState(() {
_selectedPlaylists.clear();
_isSelectionMode = false;
});
_loadPlaylists(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlists deleted successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting playlists: $e')),
);
}
}
}
Future<void> _deletePlaylist(PlaylistData playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playlist'),
content: Text('Are you sure you want to delete "${playlist.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
try {
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlist.playlistId);
_loadPlaylists(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlist deleted successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting playlist: $e')),
);
}
}
}
void _openPlaylist(PlaylistData playlist) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistEpisodesPage(playlist: playlist),
),
);
}
void _createPlaylist() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreatePlaylistPage(),
),
);
if (result == true) {
_loadPlaylists(); // Refresh the list
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
if (_errorMessage != null) {
return SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: _loadPlaylists,
title: 'Playlists Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load your playlists',
);
}
if (_playlists == null || _playlists!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.playlist_play,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No playlists found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Create a smart playlist to get started!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _createPlaylist,
icon: const Icon(Icons.add),
label: const Text('Create Playlist'),
),
],
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with action buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Smart Playlists',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
if (_isSelectionMode) ...[
IconButton(
icon: const Icon(Icons.close),
onPressed: _toggleSelectionMode,
tooltip: 'Cancel',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _selectedPlaylists.isNotEmpty ? _deleteSelectedPlaylists : null,
tooltip: 'Delete selected (${_selectedPlaylists.length})',
),
] else ...[
IconButton(
icon: const Icon(Icons.select_all),
onPressed: _toggleSelectionMode,
tooltip: 'Select multiple',
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _createPlaylist,
tooltip: 'Create playlist',
),
],
],
),
],
),
// Info banner for selection mode
if (_isSelectionMode)
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).primaryColor.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'System playlists cannot be deleted.',
style: TextStyle(fontSize: 14),
),
),
],
),
),
const SizedBox(height: 8),
// Playlists grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _getPlaylistCrossAxisCount(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: _getPlaylistAspectRatio(context),
),
itemCount: _playlists!.length,
itemBuilder: (context, index) {
final playlist = _playlists![index];
final isSelected = _selectedPlaylists.contains(playlist.playlistId);
final canSelect = _isSelectionMode && !playlist.isSystemPlaylist;
return GestureDetector(
onTap: () {
if (_isSelectionMode && !playlist.isSystemPlaylist) {
_togglePlaylistSelection(playlist.playlistId);
} else if (!_isSelectionMode) {
_openPlaylist(playlist);
}
},
child: Card(
elevation: isSelected ? 8 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(playlist.iconName),
size: 32,
color: Theme.of(context).primaryColor,
),
const Spacer(),
if (playlist.isSystemPlaylist)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'System',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.secondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${playlist.episodeCount ?? 0} episodes',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
if (playlist.description != null && playlist.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
playlist.description!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Selection checkbox
if (canSelect)
Positioned(
top: 8,
left: 8,
child: Checkbox(
value: isSelected,
onChanged: (value) {
_togglePlaylistSelection(playlist.playlistId);
},
),
),
// Delete button for non-system playlists (when not in selection mode)
if (!_isSelectionMode && !playlist.isSystemPlaylist)
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: () => _deletePlaylist(playlist),
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
),
),
],
),
),
);
},
),
],
),
),
]),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_tile.dart';
import 'package:pinepods_mobile/ui/widgets/layout_selector.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
/// This class displays the list of podcasts the user is subscribed to on the PinePods server.
class PinepodsPodcasts extends StatefulWidget {
const PinepodsPodcasts({
super.key,
});
@override
State<PinepodsPodcasts> createState() => _PinepodsPodcastsState();
}
class _PinepodsPodcastsState extends State<PinepodsPodcasts> {
List<Podcast>? _podcasts;
List<Podcast>? _filteredPodcasts;
bool _isLoading = true;
String? _errorMessage;
final PinepodsService _pinepodsService = PinepodsService();
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadPodcasts();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterPodcasts();
});
}
void _filterPodcasts() {
if (_podcasts == null) {
_filteredPodcasts = null;
return;
}
if (_searchQuery.isEmpty) {
_filteredPodcasts = List.from(_podcasts!);
} else {
_filteredPodcasts = _podcasts!.where((podcast) {
return podcast.title.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
Future<void> _loadPodcasts() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Initialize the service with the stored credentials
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final podcasts = await _pinepodsService.getUserPodcasts(settings.pinepodsUserId!);
setState(() {
_podcasts = podcasts;
_filterPodcasts(); // Initialize filtered list
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter podcasts...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
const SizedBox(width: 12),
Material(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Theme.of(context).secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_layout_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) => const LayoutSelectorWidget(),
);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.dashboard,
size: 20,
),
),
),
),
],
),
),
);
}
Widget _buildPodcastList(AppSettings settings) {
final podcasts = _filteredPodcasts ?? [];
if (podcasts.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search_off,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No podcasts match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
var mode = settings.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
// List view
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PinepodsPodcastTile(podcast: podcasts[index]);
},
childCount: podcasts.length,
addAutomaticKeepAlives: false,
),
);
}
// Grid view
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PinepodsPodcastGridTile(podcast: podcasts[index]);
},
childCount: podcasts.length,
),
);
}
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
if (_isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
if (_errorMessage != null) {
return SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: _loadPodcasts,
title: 'Podcasts Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load your podcasts',
);
}
if (_podcasts == null || _podcasts!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.podcasts,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'You haven\'t subscribed to any podcasts yet. Search for podcasts to get started!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
return MultiSliver(
children: [
_buildSearchBar(),
_buildPodcastList(settingsSnapshot.data!),
],
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
},
);
}
}

View File

@@ -0,0 +1,805 @@
// lib/ui/pinepods/queue.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
class PinepodsQueue extends StatefulWidget {
const PinepodsQueue({Key? key}) : super(key: key);
@override
State<PinepodsQueue> createState() => _PinepodsQueueState();
}
class _PinepodsQueueState extends State<PinepodsQueue> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
// Auto-scroll related variables
bool _isDragging = false;
bool _isAutoScrolling = false;
@override
void initState() {
super.initState();
_loadQueuedEpisodes();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadQueuedEpisodes() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodes = await _pinepodsService.getQueuedEpisodes(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load queued episodes: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadQueuedEpisodes();
}
Future<void> _reorderEpisodes(int oldIndex, int newIndex) async {
// Adjust indices if moving down the list
if (newIndex > oldIndex) {
newIndex -= 1;
}
// Update local state immediately for smooth UI
setState(() {
final episode = _episodes.removeAt(oldIndex);
_episodes.insert(newIndex, episode);
});
// Get episode IDs in new order
final episodeIds = _episodes.map((e) => e.episodeId).toList();
// Call API to update order on server
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
if (!success) {
_showSnackBar('Failed to update queue order', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
}
} catch (e) {
_showSnackBar('Error updating queue order: $e', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
}
}
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('Starting ${episode.episodeTitle}...'),
],
),
duration: const Duration(seconds: 2),
),
);
await _audioService!.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: episode.isStarted,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now playing: ${episode.episodeTitle}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
isDownloadedLocally: isDownloadedLocally,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onDeleteLocalDownload: () {
Navigator.of(context).pop();
_deleteLocalDownload(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
},
),
);
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false);
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.downloadEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
} else {
_showSnackBar('Failed to queue download', Colors.red);
}
} catch (e) {
_showSnackBar('Error downloading episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
} else {
_showSnackBar('Failed to delete episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error deleting episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.queued) {
success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
// REMOVE the episode from the list since it's no longer queued
setState(() {
_episodes.removeAt(episodeIndex);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
// This shouldn't happen since all episodes here are already queued
// But just in case, we'll handle it
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
_hideContextMenu();
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
void _startAutoScroll(bool scrollUp) async {
if (_isAutoScrolling) return;
_isAutoScrolling = true;
while (_isDragging && _isAutoScrolling) {
// Find the nearest ScrollView controller
final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller;
if (scrollController != null && scrollController.hasClients) {
final currentOffset = scrollController.offset;
final maxScrollExtent = scrollController.position.maxScrollExtent;
if (scrollUp && currentOffset > 0) {
// Scroll up
final newOffset = (currentOffset - 8.0).clamp(0.0, maxScrollExtent);
scrollController.jumpTo(newOffset);
} else if (!scrollUp && currentOffset < maxScrollExtent) {
// Scroll down
final newOffset = (currentOffset + 8.0).clamp(0.0, maxScrollExtent);
scrollController.jumpTo(newOffset);
} else {
break; // Reached the edge
}
}
await Future.delayed(const Duration(milliseconds: 16));
}
_isAutoScrolling = false;
}
void _stopAutoScroll() {
_isAutoScrolling = false;
}
void _checkAutoScroll(double globalY) {
if (!_isDragging) return;
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double screenHeight = mediaQuery.size.height;
final double topPadding = mediaQuery.padding.top;
final double bottomPadding = mediaQuery.padding.bottom;
const double autoScrollThreshold = 80.0;
if (globalY < topPadding + autoScrollThreshold) {
// Near top, scroll up
if (!_isAutoScrolling) {
_startAutoScroll(true);
}
} else if (globalY > screenHeight - bottomPadding - autoScrollThreshold) {
// Near bottom, scroll down
if (!_isAutoScrolling) {
_startAutoScroll(false);
}
} else {
// In the middle, stop auto-scrolling
_stopAutoScroll();
}
}
@override
void dispose() {
_stopAutoScroll();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading queue...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refresh,
child: const Text('Retry'),
),
],
),
),
),
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No queued episodes',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you queue will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return _buildEpisodesList();
}
Widget _buildEpisodesList() {
return SliverMainAxisGroup(
slivers: [
// Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Queue',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Text(
'Drag to reorder',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
],
),
),
),
// Auto-scrolling reorderable episodes list wrapped with pointer detection
SliverToBoxAdapter(
child: Listener(
onPointerMove: (details) {
if (_isDragging) {
_checkAutoScroll(details.position.dy);
}
},
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderStart: (index) {
setState(() {
_isDragging = true;
});
},
onReorderEnd: (index) {
setState(() {
_isDragging = false;
});
_stopAutoScroll();
},
onReorder: _reorderEpisodes,
itemCount: _episodes.length,
itemBuilder: (context, index) {
final episode = _episodes[index];
return Container(
key: ValueKey(episode.episodeId),
margin: const EdgeInsets.only(bottom: 4),
child: DraggableQueueEpisodeCard(
episode: episode,
index: index,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
),
),
),
],
);
}
}

View File

@@ -0,0 +1,730 @@
// lib/ui/pinepods/saved.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsSaved extends StatefulWidget {
const PinepodsSaved({Key? key}) : super(key: key);
@override
State<PinepodsSaved> createState() => _PinepodsSavedState();
}
class _PinepodsSavedState extends State<PinepodsSaved> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
List<PinepodsEpisode> _filteredEpisodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadSavedEpisodes();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterEpisodes();
});
}
void _filterEpisodes() {
if (_searchQuery.isEmpty) {
_filteredEpisodes = List.from(_episodes);
} else {
_filteredEpisodes = _episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) ||
episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadSavedEpisodes() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodes = await _pinepodsService.getSavedEpisodes(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
_filterEpisodes(); // Initialize filtered list
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load saved episodes: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadSavedEpisodes();
}
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('Starting ${episode.episodeTitle}...'),
],
),
duration: const Duration(seconds: 2),
),
);
await _audioService!.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: episode.isStarted,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now playing: ${episode.episodeTitle}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
isDownloadedLocally: isDownloadedLocally,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onDeleteLocalDownload: () {
Navigator.of(context).pop();
_deleteLocalDownload(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
},
),
);
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
// This shouldn't be called since all episodes here are already saved
// But just in case, we'll handle it
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
// REMOVE the episode from the list since it's no longer saved
setState(() {
_episodes.removeAt(episodeIndex);
_filterEpisodes(); // Update filtered list after removal
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.downloadEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
} else {
_showSnackBar('Failed to queue download', Colors.red);
}
} catch (e) {
_showSnackBar('Error downloading episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
} else {
_showSnackBar('Failed to delete episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error deleting episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.queued) {
success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
_hideContextMenu();
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading saved episodes...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverServerErrorPage(
errorMessage: _errorMessage.isServerConnectionError
? null
: _errorMessage,
onRetry: _refresh,
title: 'Saved Episodes Unavailable',
subtitle: _errorMessage.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load saved episodes',
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_outline,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No saved episodes',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you save will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildEpisodesList(),
],
);
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter episodes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
);
}
Widget _buildEpisodesList() {
// Check if search returned no results
if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No episodes match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
// Header
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_searchQuery.isEmpty
? 'Saved Episodes'
: 'Search Results (${_filteredEpisodes.length})',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
);
}
// Episodes (index - 1 because of header)
final episodeIndex = index - 1;
final episode = _filteredEpisodes[episodeIndex];
// Find the original index for context menu operations
final originalIndex = _episodes.indexOf(episode);
return PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(originalIndex),
onPlayPressed: () => _playEpisode(episode),
);
},
childCount: _filteredEpisodes.length + 1, // +1 for header
),
);
}
}

View File

@@ -0,0 +1,674 @@
// lib/ui/pinepods/search.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/search_history_service.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:provider/provider.dart';
class PinepodsSearch extends StatefulWidget {
final String? searchTerm;
const PinepodsSearch({
super.key,
this.searchTerm,
});
@override
State<PinepodsSearch> createState() => _PinepodsSearchState();
}
class _PinepodsSearchState extends State<PinepodsSearch> {
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
final PinepodsService _pinepodsService = PinepodsService();
final SearchHistoryService _searchHistoryService = SearchHistoryService();
SearchProvider _selectedProvider = SearchProvider.podcastIndex;
bool _isLoading = false;
bool _showHistory = false;
String? _errorMessage;
List<UnifiedPinepodsPodcast> _searchResults = [];
List<String> _searchHistory = [];
Set<String> _addedPodcastUrls = {};
@override
void initState() {
super.initState();
_searchFocusNode = FocusNode();
_searchController = TextEditingController();
if (widget.searchTerm != null) {
_searchController.text = widget.searchTerm!;
_performSearch(widget.searchTerm!);
} else {
_loadSearchHistory();
}
_initializeCredentials();
_searchController.addListener(_onSearchChanged);
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
@override
void dispose() {
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadSearchHistory() async {
final history = await _searchHistoryService.getPodcastSearchHistory();
if (mounted) {
setState(() {
_searchHistory = history;
_showHistory = _searchController.text.isEmpty && history.isNotEmpty;
});
}
}
void _onSearchChanged() {
final query = _searchController.text.trim();
setState(() {
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
});
}
void _selectHistoryItem(String searchTerm) {
_searchController.text = searchTerm;
_performSearch(searchTerm);
}
Future<void> _removeHistoryItem(String searchTerm) async {
await _searchHistoryService.removePodcastSearchTerm(searchTerm);
await _loadSearchHistory();
}
Future<void> _performSearch(String query) async {
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_errorMessage = null;
_showHistory = _searchHistory.isNotEmpty;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_showHistory = false;
});
// Save search term to history
await _searchHistoryService.addPodcastSearchTerm(query);
await _loadSearchHistory();
try {
final result = await _pinepodsService.searchPodcasts(query, _selectedProvider);
final podcasts = result.getUnifiedPodcasts();
setState(() {
_searchResults = podcasts;
_isLoading = false;
});
// Check which podcasts are already added
await _checkAddedPodcasts();
} catch (e) {
setState(() {
_errorMessage = 'Search failed: $e';
_isLoading = false;
_searchResults = [];
});
}
}
Future<void> _checkAddedPodcasts() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) return;
for (final podcast in _searchResults) {
try {
final exists = await _pinepodsService.checkPodcastExists(
podcast.title,
podcast.url,
userId,
);
if (exists) {
setState(() {
_addedPodcastUrls.add(podcast.url);
});
}
} catch (e) {
// Ignore individual check failures
print('Failed to check podcast ${podcast.title}: $e');
}
}
}
Future<void> _togglePodcast(UnifiedPinepodsPodcast podcast) async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in to PinePods server', Colors.red);
return;
}
final isAdded = _addedPodcastUrls.contains(podcast.url);
try {
bool success;
if (isAdded) {
success = await _pinepodsService.removePodcast(
podcast.title,
podcast.url,
userId,
);
if (success) {
setState(() {
_addedPodcastUrls.remove(podcast.url);
});
_showSnackBar('Podcast removed', Colors.orange);
}
} else {
success = await _pinepodsService.addPodcast(podcast, userId);
if (success) {
setState(() {
_addedPodcastUrls.add(podcast.url);
});
_showSnackBar('Podcast added', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to ${isAdded ? 'remove' : 'add'} podcast', Colors.red);
}
} catch (e) {
_showSnackBar('Error: $e', Colors.red);
}
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Widget _buildSearchHistorySliver() {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Recent Podcast Searches',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_searchHistory.isNotEmpty)
TextButton(
onPressed: () async {
await _searchHistoryService.clearPodcastSearchHistory();
await _loadSearchHistory();
},
child: Text(
'Clear All',
style: TextStyle(
color: Theme.of(context).hintColor,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
if (_searchHistory.isEmpty)
Center(
child: Column(
children: [
const SizedBox(height: 50),
Icon(
Icons.search,
size: 64,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Search for Podcasts',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Enter a search term above to find new podcasts to subscribe to',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.center,
),
],
),
)
else
..._searchHistory.take(10).map((searchTerm) => Card(
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
dense: true,
leading: Icon(
Icons.history,
color: Theme.of(context).hintColor,
size: 20,
),
title: Text(
searchTerm,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: Icon(
Icons.close,
size: 18,
color: Theme.of(context).hintColor,
),
onPressed: () => _removeHistoryItem(searchTerm),
),
onTap: () => _selectHistoryItem(searchTerm),
),
)).toList(),
],
),
),
);
}
Widget _buildPodcastCard(UnifiedPinepodsPodcast podcast) {
final isAdded = _addedPodcastUrls.contains(podcast.url);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsPodcastDetails(
podcast: podcast,
isFollowing: isAdded,
onFollowChanged: (following) {
setState(() {
if (following) {
_addedPodcastUrls.add(podcast.url);
} else {
_addedPodcastUrls.remove(podcast.url);
}
});
},
),
),
);
},
child: Column(
children: [
// Podcast image and info
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Podcast artwork
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: podcast.artwork.isNotEmpty
? Image.network(
podcast.artwork,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 32,
),
);
},
)
: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 32,
),
),
),
const SizedBox(width: 12),
// Podcast info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
podcast.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
if (podcast.author.isNotEmpty)
Text(
'By ${podcast.author}',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
podcast.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.mic,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
'${podcast.episodeCount} episode${podcast.episodeCount != 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 16),
if (podcast.explicit)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'E',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
// Follow/Unfollow button
IconButton(
onPressed: () => _togglePodcast(podcast),
icon: Icon(
isAdded ? Icons.remove_circle : Icons.add_circle,
color: isAdded ? Colors.red : Colors.green,
),
tooltip: isAdded ? 'Remove podcast' : 'Add podcast',
),
],
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
leading: IconButton(
tooltip: 'Back',
icon: Platform.isAndroid
? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor)
: const Icon(Icons.arrow_back_ios),
onPressed: () => Navigator.pop(context),
),
title: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: widget.searchTerm != null ? false : true,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
onTap: () {
setState(() {
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
});
},
decoration: const InputDecoration(
hintText: 'Search for podcasts',
border: InputBorder.none,
),
style: TextStyle(
color: Theme.of(context).primaryIconTheme.color,
fontSize: 18.0,
decorationColor: Theme.of(context).scaffoldBackgroundColor,
),
onSubmitted: _performSearch,
),
floating: false,
pinned: true,
snap: false,
actions: <Widget>[
IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchResults = [];
_errorMessage = null;
_showHistory = _searchHistory.isNotEmpty;
});
FocusScope.of(context).requestFocus(_searchFocusNode);
SystemChannels.textInput.invokeMethod<String>('TextInput.show');
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const Text(
'Search Provider: ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Expanded(
child: DropdownButton<SearchProvider>(
value: _selectedProvider,
isExpanded: true,
items: SearchProvider.values.map((provider) {
return DropdownMenuItem(
value: provider,
child: Text(provider.name),
);
}).toList(),
onChanged: (provider) {
if (provider != null) {
setState(() {
_selectedProvider = provider;
});
// Re-search with new provider if there's a current search
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
},
),
),
],
),
),
),
),
// Search results or history
if (_showHistory)
_buildSearchHistorySliver()
else if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: PlatformProgressIndicator()),
)
else if (_errorMessage != null)
SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: () => _performSearch(_searchController.text),
title: 'Search Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to search for podcasts',
)
else if (_searchResults.isEmpty && _searchController.text.isNotEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Try searching with different keywords or switch search provider',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else if (_searchResults.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Search for podcasts',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Enter a search term to find podcasts',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _buildPodcastCard(_searchResults[index]);
},
childCount: _searchResults.length,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,503 @@
// lib/ui/pinepods/user_stats.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/user_stats.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class PinepodsUserStats extends StatefulWidget {
const PinepodsUserStats({super.key});
@override
State<PinepodsUserStats> createState() => _PinepodsUserStatsState();
}
class _PinepodsUserStatsState extends State<PinepodsUserStats> {
final PinepodsService _pinepodsService = PinepodsService();
UserStats? _userStats;
String? _pinepodsVersion;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializeCredentials();
_loadUserStats();
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
/// Calculate responsive cross axis count for stats grid
int _getStatsCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for stats cards
double _getStatsAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for content + proper padding
return 2.2; // Allows space for icon + title + value + padding, handles text wrapping
}
return 1.0; // Square aspect ratio for multi-column layouts
}
Future<void> _loadUserStats() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
setState(() {
_errorMessage = 'Not logged in';
_isLoading = false;
});
return;
}
try {
final futures = await Future.wait([
_pinepodsService.getUserStats(userId),
_pinepodsService.getPinepodsVersion(),
]);
setState(() {
_userStats = futures[0] as UserStats;
_pinepodsVersion = futures[1] as String;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load stats: $e';
_isLoading = false;
});
}
}
Future<void> _launchUrl(String url) async {
final logger = AppLogger();
logger.info('UserStats', 'Attempting to launch URL: $url');
try {
final uri = Uri.parse(url);
// Try to launch directly first (works better on Android)
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
logger.warning('UserStats', 'Direct URL launch failed, checking if URL can be launched');
// If direct launch fails, check if URL can be launched
final canLaunch = await canLaunchUrl(uri);
if (!canLaunch) {
throw Exception('No app available to handle this URL');
}
} else {
logger.info('UserStats', 'Successfully launched URL: $url');
}
} catch (e) {
logger.error('UserStats', 'Failed to launch URL: $url', e.toString());
// Show error if URL can't be launched
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open link: $url'),
backgroundColor: Colors.red,
),
);
}
}
}
Widget _buildStatCard(String label, String value, {IconData? icon, Color? iconColor}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 32,
color: iconColor ?? Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
],
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Build sync status card that fits in the grid with consistent styling
Widget _buildSyncStatCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return _buildStatCard(
'Sync Status',
stats.syncStatusDescription,
icon: isNotSyncing ? Icons.sync_disabled : Icons.sync,
iconColor: isNotSyncing ? Colors.grey : null,
);
}
Widget _buildSyncStatusCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(
isNotSyncing ? Icons.sync_disabled : Icons.sync,
size: 32,
color: isNotSyncing ? Colors.grey : Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
Text(
'Podcast Sync Status',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
stats.syncStatusDescription,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (!isNotSyncing && stats.gpodderUrl.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
stats.gpodderUrl,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// PinePods Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
image: const DecorationImage(
image: AssetImage('assets/images/pinepods-logo.png'),
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 16),
Text(
'App Version: v${Environment.projectVersion}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Server Version: ${_pinepodsVersion ?? "Unknown"}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Thanks for using PinePods! This app was born from a love for podcasts, of homelabs, and a desire to have a secure and central location to manage personal data.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Copyright © 2025 Gooseberry Development',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The PinePods Mobile App is an open-source podcast player adapted from the Anytime Podcast Player (© 2020 Ben Hills). Portions of this application retain the original BSD 3-Clause license.',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
height: 1.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _launchUrl('https://github.com/amugofjava/anytime_podcast_player'),
child: Text(
'View original project on GitHub',
style: TextStyle(
fontSize: 12,
decoration: TextDecoration.underline,
color: Theme.of(context).primaryColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
// Buttons
Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://pinepods.online'),
icon: const Icon(Icons.description),
label: const Text('PinePods Documentation'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://github.com/madeofpendletonwool/pinepods'),
icon: const Icon(Icons.code),
label: const Text('PinePods GitHub Repo'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://www.buymeacoffee.com/collinscoffee'),
icon: const Icon(Icons.coffee),
label: const Text('Buy me a Coffee'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
showLicensePage(context: context);
},
icon: const Icon(Icons.article_outlined),
label: const Text('Open Source Licenses'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('User Statistics'),
centerTitle: true,
),
body: _isLoading
? const Center(child: PlatformProgressIndicator())
: _errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadUserStats();
},
child: const Text('Retry'),
),
],
),
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Statistics Grid
GridView.count(
crossAxisCount: _getStatsCrossAxisCount(context),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: _getStatsAspectRatio(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: [
_buildStatCard(
'User Created',
_userStats?.formattedUserCreated ?? '',
icon: Icons.calendar_today,
),
_buildStatCard(
'Podcasts Played',
_userStats?.podcastsPlayed.toString() ?? '',
icon: Icons.play_circle,
),
_buildStatCard(
'Time Listened',
_userStats?.formattedTimeListened ?? '',
icon: Icons.access_time,
),
_buildStatCard(
'Podcasts Added',
_userStats?.podcastsAdded.toString() ?? '',
icon: Icons.library_add,
),
_buildStatCard(
'Episodes Saved',
_userStats?.episodesSaved.toString() ?? '',
icon: Icons.bookmark,
),
_buildStatCard(
'Episodes Downloaded',
_userStats?.episodesDownloaded.toString() ?? '',
icon: Icons.download,
),
// Add sync status as a stat card to maintain consistent layout
_buildSyncStatCard(),
],
),
const SizedBox(height: 16),
// Info Card
_buildInfoCard(),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
// 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/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
/// A [Widget] for displaying a list of Podcast chapters for those
/// podcasts that support that chapter tag.
// ignore: must_be_immutable
class ChapterSelector extends StatefulWidget {
final ItemScrollController itemScrollController = ItemScrollController();
Episode episode;
Chapter? chapter;
StreamSubscription? positionSubscription;
var chapters = <Chapter>[];
ChapterSelector({
super.key,
required this.episode,
}) {
chapters = episode.chapters.where((c) => c.toc).toList(growable: false);
}
@override
State<ChapterSelector> createState() => _ChapterSelectorState();
}
class _ChapterSelectorState extends State<ChapterSelector> {
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
Chapter? lastChapter;
bool first = true;
// Listen for changes in position. If the change in position results in
// a change in chapter we scroll to it. This ensures that the current
// chapter is always visible.
// TODO: Jump only if current chapter is not visible.
widget.positionSubscription = audioBloc.playPosition!.listen((event) {
var episode = event.episode;
if (widget.itemScrollController.isAttached) {
lastChapter ??= episode!.currentChapter;
if (lastChapter != episode!.currentChapter) {
lastChapter = episode.currentChapter;
if (!episode.chaptersLoading && episode.chapters.isNotEmpty) {
var index = widget.chapters.indexWhere((element) => element == lastChapter);
if (index >= 0) {
if (first) {
widget.itemScrollController.jumpTo(index: index);
first = false;
}
// Removed auto-scroll to current chapter during playback
// to prevent annoying bouncing behavior
}
}
}
}
});
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
return StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
return !snapshot.hasData || snapshot.data!.chaptersLoading
? const Align(
alignment: Alignment.center,
child: PlatformProgressIndicator(),
)
: ScrollablePositionedList.builder(
initialScrollIndex: _initialIndex(snapshot.data),
itemScrollController: widget.itemScrollController,
itemCount: widget.chapters.length,
itemBuilder: (context, i) {
final index = i < 0 ? 0 : i;
final chapter = widget.chapters[index];
final chapterSelected = chapter == snapshot.data!.currentChapter;
final textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 14,
fontWeight: FontWeight.normal,
);
/// We should be able to use the selectedTileColor property but, if we do, when
/// we scroll the currently selected item out of view, the selected colour is
/// still visible behind the transport control. This is a little hack, but fixes
/// the issue until I can get ListTile to work correctly.
return Padding(
padding: const EdgeInsets.fromLTRB(4.0, 0.0, 4.0, 0.0),
child: ListTile(
selectedTileColor: Theme.of(context).cardTheme.color,
onTap: () {
audioBloc.transitionPosition(chapter.startTime);
},
selected: chapterSelected,
leading: Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
'${index + 1}.',
style: textStyle,
),
),
title: Text(
widget.chapters[index].title.trim(),
overflow: TextOverflow.ellipsis,
softWrap: false,
maxLines: 3,
style: textStyle,
),
trailing: Text(
_formatStartTime(widget.chapters[index].startTime),
style: textStyle,
),
),
);
},
);
});
}
@override
void dispose() {
widget.positionSubscription?.cancel();
super.dispose();
}
int _initialIndex(Episode? e) {
var init = 0;
if (e != null && e.currentChapter != null) {
init = widget.chapters.indexWhere((c) => c == e.currentChapter);
if (init < 0) {
init = 0;
}
}
return init;
}
String _formatStartTime(double startTime) {
var time = Duration(seconds: startTime.ceil());
var result = '';
if (time.inHours > 0) {
result =
'${time.inHours}:${time.inMinutes.remainder(60).toString().padLeft(2, '0')}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}';
} else {
result = '${time.inMinutes}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}';
}
return result;
}
}

View File

@@ -0,0 +1,49 @@
// 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:flutter/widgets.dart';
/// Custom [Decoration] for the chapters, episode & notes tab selector
/// shown in the [NowPlaying] page.
class DotDecoration extends Decoration {
final Color colour;
const DotDecoration({required this.colour});
@override
BoxPainter createBoxPainter([void Function()? onChanged]) {
return _DotDecorationPainter(decoration: this);
}
}
class _DotDecorationPainter extends BoxPainter {
final DotDecoration decoration;
_DotDecorationPainter({required this.decoration});
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
const double pillWidth = 8.0;
const double pillHeight = 3.0;
final center = configuration.size!.center(offset);
final height = configuration.size!.height;
final newOffset = Offset(center.dx, height - 8);
final paint = Paint();
paint.color = decoration.colour;
paint.style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromLTRBR(
newOffset.dx - pillWidth,
newOffset.dy - pillHeight,
newOffset.dx + pillWidth,
newOffset.dy + pillHeight,
const Radius.circular(12.0),
),
paint);
}
}

View File

@@ -0,0 +1,101 @@
// 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/entities/episode.dart';
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
import 'package:pinepods_mobile/ui/podcast/transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
/// This class renders the more info widget that is accessed from the 'more'
/// button on an episode.
///
/// The widget is displayed as a draggable, scrollable sheet. This contains
/// episode icon and play/pause control, below which the episode title, show
/// notes and person(s) details (if available).
class EpisodeDetails extends StatefulWidget {
final Episode episode;
const EpisodeDetails({
super.key,
required this.episode,
});
@override
State<EpisodeDetails> createState() => _EpisodeDetailsState();
}
class _EpisodeDetailsState extends State<EpisodeDetails> {
@override
Widget build(BuildContext context) {
final episode = widget.episode;
/// Ensure we do not highlight this as a new episode
episode.highlight = false;
return DraggableScrollableSheet(
initialChildSize: 0.6,
expand: false,
builder: (BuildContext context, ScrollController scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ExpansionTile(
key: const Key('episodemoreinfo'),
trailing: PlayControl(
episode: episode,
),
leading: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl!,
size: 56.0,
highlight: episode.highlight,
),
subtitle: EpisodeSubtitle(episode),
title: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: Theme.of(context).textTheme.bodyMedium,
)),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
episode.title!,
style: Theme.of(context).textTheme.titleLarge!.copyWith(fontWeight: FontWeight.bold),
),
),
),
if (episode.persons.isNotEmpty)
SizedBox(
height: 120.0,
child: ListView.builder(
itemCount: episode.persons.length,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
return PersonAvatar(person: episode.persons[index]);
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: PodcastHtml(content: episode.content ?? episode.description!),
)
],
),
);
});
}
}

View File

@@ -0,0 +1,222 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is responsible for rendering the funding menu on the podcast details page.
///
/// It returns either a Material or Cupertino style menu instance depending upon which
/// platform we are running on.
///
/// The target platform is based on the current [Theme]: [ThemeData.platform].
class FundingMenu extends StatelessWidget {
final List<Funding>? funding;
const FundingMenu(
this.funding, {
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return _MaterialFundingMenu(funding);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _CupertinoFundingMenu(funding);
}
}
}
/// This is the material design version of the context menu. This will be rendered
/// for all platforms that are not iOS.
class _MaterialFundingMenu extends StatelessWidget {
final List<Funding>? funding;
const _MaterialFundingMenu(this.funding);
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
return funding == null || funding!.isEmpty
? const SizedBox(
width: 0.0,
height: 0.0,
)
: StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Semantics(
label: L.of(context)!.podcast_funding_dialog_header,
child: PopupMenuButton<String>(
onSelected: (url) {
FundingLink.fundingLink(
url,
snapshot.data!.externalLinkConsent,
context,
).then((value) {
settingsBloc.setExternalLinkConsent(value);
});
},
icon: const Icon(
Icons.payment,
),
itemBuilder: (BuildContext context) {
return List<PopupMenuEntry<String>>.generate(funding!.length, (index) {
return PopupMenuItem<String>(
value: funding![index].url,
enabled: true,
child: Text(funding![index].value),
);
});
},
),
);
});
}
}
/// This is the Cupertino context menu and is rendered only when running on
/// an iOS device.
class _CupertinoFundingMenu extends StatelessWidget {
final List<Funding>? funding;
const _CupertinoFundingMenu(this.funding);
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
return funding == null || funding!.isEmpty
? const SizedBox(
width: 0.0,
height: 0.0,
)
: StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return IconButton(
tooltip: L.of(context)!.podcast_funding_dialog_header,
icon: const Icon(Icons.payment),
visualDensity: VisualDensity.compact,
onPressed: () => showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
...List<CupertinoActionSheetAction>.generate(funding!.length, (index) {
return CupertinoActionSheetAction(
onPressed: () {
FundingLink.fundingLink(
funding![index].url,
snapshot.data!.externalLinkConsent,
context,
).then((value) {
settingsBloc.setExternalLinkConsent(value);
if (context.mounted) {
Navigator.of(context).pop('Cancel');
}
});
},
child: Text(funding![index].value),
);
}),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_option_label),
),
);
},
),
);
});
}
}
class FundingLink {
/// Check the consent status. If this is the first time we have been
/// requested to open a funding link, present the user with and
/// information dialog first to make clear that the link is provided
/// by the podcast owner and not Pinepods.
static Future<bool> fundingLink(String url, bool consent, BuildContext context) async {
bool? result = false;
if (consent) {
result = true;
final uri = Uri.parse(url);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
} else {
result = await showPlatformDialog<bool>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Semantics(
header: true,
child: Text(L.of(context)!.podcast_funding_dialog_header),
),
content: Text(L.of(context)!.consent_message),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.go_back_button_label,
),
onPressed: () {
Navigator.pop(context, false);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.continue_button_label,
),
iosIsDefaultAction: true,
onPressed: () {
Navigator.pop(context, true);
},
),
],
),
);
if (result!) {
var uri = Uri.parse(url);
unawaited(
canLaunchUrl(uri).then((value) => launchUrl(uri)),
);
}
}
return Future.value(result);
}
}

View File

@@ -0,0 +1,360 @@
// 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 'dart:ui';
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Displays a mini podcast player widget if a podcast is playing or paused.
///
/// If stopped a zero height box is built instead. Tapping on the mini player
/// will open the main player window.
class MiniPlayer extends StatelessWidget {
const MiniPlayer({
super.key,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
initialData: AudioState.stopped,
builder: (context, snapshot) {
return snapshot.data != AudioState.stopped &&
snapshot.data != AudioState.none &&
snapshot.data != AudioState.error
? _MiniPlayerBuilder()
: const SizedBox(
height: 0.0,
);
});
}
}
class _MiniPlayerBuilder extends StatefulWidget {
@override
_MiniPlayerBuilderState createState() => _MiniPlayerBuilderState();
}
class _MiniPlayerBuilderState extends State<_MiniPlayerBuilder>
with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
@override
void initState() {
super.initState();
_playPauseController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
_playPauseController.value = 1;
_audioStateListener();
}
@override
void dispose() {
_audioStateSubscription.cancel();
_playPauseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final width = MediaQuery.of(context).size.width;
final placeholderBuilder = PlaceholderBuilder.of(context);
return Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) async {
await _audioStateSubscription.cancel();
audioBloc.transitionState(TransitionState.stop);
return true;
},
direction: DismissDirection.startToEnd,
background: Container(
color: Theme.of(context).colorScheme.surface,
height: 64.0,
),
child: GestureDetector(
key: const Key('miniplayergesture'),
onTap: () async {
await _audioStateSubscription.cancel();
if (context.mounted) {
showModalBottomSheet<void>(
context: context,
routeSettings: const RouteSettings(name: 'nowplaying'),
isScrollControlled: true,
builder: (BuildContext modalContext) {
final contextPadding = MediaQuery.of(context).padding.top;
final modalPadding = MediaQuery.of(modalContext).padding.top;
// Get the actual system safe area from the window (works on both iOS and Android)
final window = PlatformDispatcher.instance.views.first;
final systemPadding = window.padding.top / window.devicePixelRatio;
// Use the best available padding value
double topPadding;
if (contextPadding > 0) {
topPadding = contextPadding;
} else if (modalPadding > 0) {
topPadding = modalPadding;
} else {
// Fall back to system padding if both contexts have 0
topPadding = systemPadding;
}
return Padding(
padding: EdgeInsets.only(top: topPadding),
child: const NowPlaying(),
);
},
).then((_) {
_audioStateListener();
});
}
},
child: Semantics(
header: true,
label: L.of(context)!.semantics_mini_player_header,
child: Container(
height: 66,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: Divider.createBorderSide(context,
width: 1.0, color: Theme.of(context).dividerColor),
bottom: Divider.createBorderSide(context,
width: 0.0, color: Theme.of(context).dividerColor),
)),
child: Padding(
padding: const EdgeInsets.only(left: 4.0, right: 4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
initialData: audioBloc.nowPlaying?.valueOrNull,
builder: (context, snapshot) {
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, stateSnapshot) {
var playing =
stateSnapshot.data == AudioState.playing;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
SizedBox(
height: 58.0,
width: 58.0,
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: snapshot.hasData
? PodcastImage(
key: Key(
'mini${snapshot.data!.imageUrl}'),
url: snapshot.data!.imageUrl!,
width: 58.0,
height: 58.0,
borderRadius: 4.0,
placeholder: placeholderBuilder !=
null
? placeholderBuilder
.builder()(context)
: const Image(
image: AssetImage(
'assets/images/favicon.png')),
errorPlaceholder:
placeholderBuilder != null
? placeholderBuilder
.errorBuilder()(
context)
: const Image(
image: AssetImage(
'assets/images/favicon.png')),
)
: Container(),
),
),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
snapshot.data?.title ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium,
),
Padding(
padding:
const EdgeInsets.only(top: 4.0),
child: Text(
snapshot.data?.author ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall,
),
),
],
)),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(
color: Theme.of(context)
.colorScheme
.surface,
width: 0.0)),
),
onPressed: () {
if (playing) {
audioBloc.transitionState(
TransitionState.fastforward);
}
},
child: Icon(
Icons.forward_30,
semanticLabel: L
.of(context)!
.fast_forward_button_label,
size: 36.0,
),
),
),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(
color: Theme.of(context)
.colorScheme
.surface,
width: 0.0)),
),
onPressed: () {
if (playing) {
_pause(audioBloc);
} else {
_play(audioBloc);
}
},
child: AnimatedIcon(
semanticLabel: playing
? L.of(context)!.pause_button_label
: L.of(context)!.play_button_label,
size: 48.0,
icon: AnimatedIcons.play_pause,
color:
Theme.of(context).iconTheme.color,
progress: _playPauseController,
),
),
),
],
);
});
}),
StreamBuilder<PositionState>(
stream: audioBloc.playPosition,
initialData: audioBloc.playPosition?.valueOrNull,
builder: (context, snapshot) {
var cw = 0.0;
var position = snapshot.hasData
? snapshot.data!.position
: const Duration(seconds: 0);
var length = snapshot.hasData
? snapshot.data!.length
: const Duration(seconds: 0);
if (length.inSeconds > 0) {
final pc = length.inSeconds / position.inSeconds;
cw = width / pc;
}
return Container(
width: cw,
height: 1.0,
color: Theme.of(context).primaryColor,
);
}),
],
),
),
),
),
),
);
}
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
/// little odd.
void _audioStateListener() {
if (mounted) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var firstEvent = true;
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (firstEvent) {
_playPauseController.value = 1;
firstEvent = false;
} else {
_playPauseController.forward();
}
} else {
if (firstEvent) {
_playPauseController.value = 0;
firstEvent = false;
} else {
_playPauseController.reverse();
}
}
});
}
}
void _play(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _pause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
}

Some files were not shown because too many files have changed in this diff Show More