added cargo files
This commit is contained in:
247
PinePods-0.8.2/mobile/lib/api/podcast/mobile_podcast_api.dart
Normal file
247
PinePods-0.8.2/mobile/lib/api/podcast/mobile_podcast_api.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
51
PinePods-0.8.2/mobile/lib/api/podcast/podcast_api.dart
Normal file
51
PinePods-0.8.2/mobile/lib/api/podcast/podcast_api.dart
Normal 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);
|
||||
}
|
||||
43
PinePods-0.8.2/mobile/lib/bloc/bloc.dart
Normal file
43
PinePods-0.8.2/mobile/lib/bloc/bloc.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Base class for all BLoCs to give each a hook into the mobile
|
||||
/// lifecycle state of paused, resume or detached.
|
||||
abstract class Bloc {
|
||||
/// Handle lifecycle events
|
||||
final PublishSubject<LifecycleState> _lifecycleSubject = PublishSubject<LifecycleState>(sync: true);
|
||||
|
||||
Bloc() {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_lifecycleSubject.listen((state) async {
|
||||
if (state == LifecycleState.resume) {
|
||||
resume();
|
||||
} else if (state == LifecycleState.pause) {
|
||||
pause();
|
||||
} else if (state == LifecycleState.detach) {
|
||||
detach();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_lifecycleSubject.hasListener) {
|
||||
_lifecycleSubject.close();
|
||||
}
|
||||
}
|
||||
|
||||
void resume() {}
|
||||
|
||||
void pause() {}
|
||||
|
||||
void detach() {}
|
||||
|
||||
void Function(LifecycleState) get transitionLifecycleState => _lifecycleSubject.sink.add;
|
||||
}
|
||||
238
PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart
Normal file
238
PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/core/extensions.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/entities/sleep.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/state/transcript_state_event.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
enum TransitionState {
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
fastforward,
|
||||
rewind,
|
||||
}
|
||||
|
||||
enum LifecycleState {
|
||||
pause,
|
||||
resume,
|
||||
detach,
|
||||
}
|
||||
|
||||
/// A BLoC to handle interactions between the audio service and the client.
|
||||
class AudioBloc extends Bloc {
|
||||
final log = Logger('AudioBloc');
|
||||
|
||||
/// Listen for new episode play requests.
|
||||
final BehaviorSubject<Episode?> _play = BehaviorSubject<Episode?>();
|
||||
|
||||
/// Move from one playing state to another such as from paused to play
|
||||
final PublishSubject<TransitionState> _transitionPlayingState = PublishSubject<TransitionState>();
|
||||
|
||||
/// Sink to update our position
|
||||
final PublishSubject<double> _transitionPosition = PublishSubject<double>();
|
||||
|
||||
/// Handles persisting data to storage.
|
||||
final AudioPlayerService audioPlayerService;
|
||||
|
||||
/// Listens for playback speed change requests.
|
||||
final PublishSubject<double> _playbackSpeedSubject = PublishSubject<double>();
|
||||
|
||||
/// Listen for toggling of trim silence requests.
|
||||
final PublishSubject<bool> _trimSilence = PublishSubject<bool>();
|
||||
|
||||
/// Listen for toggling of volume boost silence requests.
|
||||
final PublishSubject<bool> _volumeBoost = PublishSubject<bool>();
|
||||
|
||||
/// Listen for transcript filtering events.
|
||||
final PublishSubject<TranscriptEvent> _transcriptEvent = PublishSubject<TranscriptEvent>();
|
||||
|
||||
final BehaviorSubject<Sleep> _sleepEvent = BehaviorSubject<Sleep>();
|
||||
|
||||
AudioBloc({
|
||||
required this.audioPlayerService,
|
||||
}) {
|
||||
/// Listen for transition events from the client.
|
||||
_handlePlayingStateTransitions();
|
||||
|
||||
/// Listen for events requesting the start of a new episode.
|
||||
_handleEpisodeRequests();
|
||||
|
||||
/// Listen for requests to move the play position within the episode.
|
||||
_handlePositionTransitions();
|
||||
|
||||
/// Listen for playback speed changes
|
||||
_handlePlaybackSpeedTransitions();
|
||||
|
||||
/// Listen to trim silence requests
|
||||
_handleTrimSilenceTransitions();
|
||||
|
||||
/// Listen to volume boost silence requests
|
||||
_handleVolumeBoostTransitions();
|
||||
|
||||
/// Listen to transcript filtering events
|
||||
_handleTranscriptEvents();
|
||||
|
||||
/// Listen to sleep timer events;
|
||||
_handleSleepTimer();
|
||||
}
|
||||
|
||||
/// Listens to events from the UI (or any client) to transition from one
|
||||
/// audio state to another. For example, to pause the current playback
|
||||
/// a [TransitionState.pause] event should be sent. To ensure the underlying
|
||||
/// audio service processes one state request at a time we push events
|
||||
/// on to a queue and execute them sequentially. Each state maps to a call
|
||||
/// to the Audio Service plugin.
|
||||
void _handlePlayingStateTransitions() {
|
||||
_transitionPlayingState.asyncMap((event) => Future.value(event)).listen((state) async {
|
||||
switch (state) {
|
||||
case TransitionState.play:
|
||||
await audioPlayerService.play();
|
||||
break;
|
||||
case TransitionState.pause:
|
||||
await audioPlayerService.pause();
|
||||
break;
|
||||
case TransitionState.fastforward:
|
||||
await audioPlayerService.fastForward();
|
||||
break;
|
||||
case TransitionState.rewind:
|
||||
await audioPlayerService.rewind();
|
||||
break;
|
||||
case TransitionState.stop:
|
||||
await audioPlayerService.stop();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Setup a listener for episode requests and then connect to the
|
||||
/// underlying audio service.
|
||||
void _handleEpisodeRequests() async {
|
||||
_play.listen((episode) {
|
||||
audioPlayerService.playEpisode(episode: episode!, resume: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to change the position of the current episode.
|
||||
void _handlePositionTransitions() async {
|
||||
_transitionPosition.listen((pos) async {
|
||||
await audioPlayerService.seek(position: pos.ceil());
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to adjust the playback speed.
|
||||
void _handlePlaybackSpeedTransitions() {
|
||||
_playbackSpeedSubject.listen((double speed) async {
|
||||
await audioPlayerService.setPlaybackSpeed(speed.toTenth);
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to toggle trim silence mode. This is currently disabled until
|
||||
/// [issue](https://github.com/ryanheise/just_audio/issues/558) is resolved.
|
||||
void _handleTrimSilenceTransitions() {
|
||||
_trimSilence.listen((bool trim) async {
|
||||
await audioPlayerService.trimSilence(trim);
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for requests to toggle the volume boost feature. Android only.
|
||||
void _handleVolumeBoostTransitions() {
|
||||
_volumeBoost.listen((bool boost) async {
|
||||
await audioPlayerService.volumeBoost(boost);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTranscriptEvents() {
|
||||
_transcriptEvent.listen((TranscriptEvent event) {
|
||||
if (event is TranscriptFilterEvent) {
|
||||
audioPlayerService.searchTranscript(event.search);
|
||||
} else if (event is TranscriptClearEvent) {
|
||||
audioPlayerService.clearTranscript();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSleepTimer() {
|
||||
_sleepEvent.listen((Sleep sleep) {
|
||||
audioPlayerService.sleep(sleep);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void pause() async {
|
||||
log.fine('Audio lifecycle pause');
|
||||
await audioPlayerService.suspend();
|
||||
}
|
||||
|
||||
@override
|
||||
void resume() async {
|
||||
log.fine('Audio lifecycle resume');
|
||||
var ep = await audioPlayerService.resume();
|
||||
|
||||
if (ep != null) {
|
||||
log.fine('Resuming with episode ${ep.title} - ${ep.position} - ${ep.played}');
|
||||
} else {
|
||||
log.fine('Resuming without an episode');
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the specified track now
|
||||
void Function(Episode?) get play => _play.add;
|
||||
|
||||
/// Transition the state from connecting, to play, pause, stop etc.
|
||||
void Function(TransitionState) get transitionState => _transitionPlayingState.add;
|
||||
|
||||
/// Move the play position.
|
||||
void Function(double) get transitionPosition => _transitionPosition.sink.add;
|
||||
|
||||
/// Get the current playing state
|
||||
Stream<AudioState>? get playingState => audioPlayerService.playingState;
|
||||
|
||||
/// Listen for any playback errors
|
||||
Stream<int>? get playbackError => audioPlayerService.playbackError;
|
||||
|
||||
/// Get the current playing episode
|
||||
ValueStream<Episode?>? get nowPlaying => audioPlayerService.episodeEvent;
|
||||
|
||||
/// Get the current transcript (if there is one).
|
||||
Stream<TranscriptState>? get nowPlayingTranscript => audioPlayerService.transcriptEvent;
|
||||
|
||||
/// Get position and percentage played of playing episode
|
||||
ValueStream<PositionState>? get playPosition => audioPlayerService.playPosition;
|
||||
|
||||
Stream<Sleep>? get sleepStream => audioPlayerService.sleepStream;
|
||||
|
||||
/// Change playback speed
|
||||
void Function(double) get playbackSpeed => _playbackSpeedSubject.sink.add;
|
||||
|
||||
/// Toggle trim silence
|
||||
void Function(bool) get trimSilence => _trimSilence.sink.add;
|
||||
|
||||
/// Toggle volume boost silence
|
||||
void Function(bool) get volumeBoost => _volumeBoost.sink.add;
|
||||
|
||||
/// Handle filtering & searching of the current transcript.
|
||||
void Function(TranscriptEvent) get filterTranscript => _transcriptEvent.sink.add;
|
||||
|
||||
void Function(Sleep) get sleep => _sleepEvent.sink.add;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_play.close();
|
||||
_transitionPlayingState.close();
|
||||
_transitionPosition.close();
|
||||
_playbackSpeedSubject.close();
|
||||
_trimSilence.close();
|
||||
_volumeBoost.close();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
125
PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart
Normal file
125
PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/state/bloc_state.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// The BLoC provides access to [Episode] details outside the direct scope
|
||||
/// of a [Podcast].
|
||||
class EpisodeBloc extends Bloc {
|
||||
final log = Logger('EpisodeBloc');
|
||||
final PodcastService podcastService;
|
||||
final AudioPlayerService audioPlayerService;
|
||||
|
||||
/// Add to sink to fetch list of current downloaded episodes.
|
||||
final BehaviorSubject<bool> _downloadsInput = BehaviorSubject<bool>();
|
||||
|
||||
/// Add to sink to fetch list of current episodes.
|
||||
final BehaviorSubject<bool> _episodesInput = BehaviorSubject<bool>();
|
||||
|
||||
/// Add to sink to delete the passed [Episode] from storage.
|
||||
final PublishSubject<Episode?> _deleteDownload = PublishSubject<Episode>();
|
||||
|
||||
/// Add to sink to toggle played status of the [Episode].
|
||||
final PublishSubject<Episode?> _togglePlayed = PublishSubject<Episode>();
|
||||
|
||||
/// Stream of currently downloaded episodes
|
||||
Stream<BlocState<List<Episode>>>? _downloadsOutput;
|
||||
|
||||
/// Stream of current episodes
|
||||
Stream<BlocState<List<Episode>>>? _episodesOutput;
|
||||
|
||||
/// Cache of our currently downloaded episodes.
|
||||
List<Episode>? _episodes;
|
||||
|
||||
EpisodeBloc({
|
||||
required this.podcastService,
|
||||
required this.audioPlayerService,
|
||||
}) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_downloadsOutput = _downloadsInput.switchMap<BlocState<List<Episode>>>((bool silent) => _loadDownloads(silent));
|
||||
_episodesOutput = _episodesInput.switchMap<BlocState<List<Episode>>>((bool silent) => _loadEpisodes(silent));
|
||||
|
||||
_handleDeleteDownloads();
|
||||
_handleMarkAsPlayed();
|
||||
_listenEpisodeEvents();
|
||||
}
|
||||
|
||||
void _handleDeleteDownloads() async {
|
||||
_deleteDownload.stream.listen((episode) async {
|
||||
var nowPlaying = audioPlayerService.nowPlaying?.guid == episode?.guid;
|
||||
|
||||
/// If we are attempting to delete the episode we are currently playing, we need to stop the audio.
|
||||
if (nowPlaying) {
|
||||
await audioPlayerService.stop();
|
||||
}
|
||||
|
||||
await podcastService.deleteDownload(episode!);
|
||||
|
||||
fetchDownloads(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleMarkAsPlayed() async {
|
||||
_togglePlayed.stream.listen((episode) async {
|
||||
await podcastService.toggleEpisodePlayed(episode!);
|
||||
|
||||
fetchDownloads(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _listenEpisodeEvents() {
|
||||
// Listen for episode updates. If the episode is downloaded, we need to update.
|
||||
podcastService.episodeListener!.where((event) => event.episode.downloaded || event.episode.played).listen((event) => fetchDownloads(true));
|
||||
}
|
||||
|
||||
Stream<BlocState<List<Episode>>> _loadDownloads(bool silent) async* {
|
||||
if (!silent) {
|
||||
yield BlocLoadingState();
|
||||
}
|
||||
|
||||
_episodes = await podcastService.loadDownloads();
|
||||
|
||||
yield BlocPopulatedState<List<Episode>>(results: _episodes);
|
||||
}
|
||||
|
||||
Stream<BlocState<List<Episode>>> _loadEpisodes(bool silent) async* {
|
||||
if (!silent) {
|
||||
yield BlocLoadingState();
|
||||
}
|
||||
|
||||
_episodes = await podcastService.loadEpisodes();
|
||||
|
||||
yield BlocPopulatedState<List<Episode>>(results: _episodes);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_downloadsInput.close();
|
||||
_deleteDownload.close();
|
||||
_togglePlayed.close();
|
||||
}
|
||||
|
||||
void Function(bool) get fetchDownloads => _downloadsInput.add;
|
||||
|
||||
void Function(bool) get fetchEpisodes => _episodesInput.add;
|
||||
|
||||
Stream<BlocState<List<Episode>>>? get downloads => _downloadsOutput;
|
||||
|
||||
Stream<BlocState<List<Episode>>>? get episodes => _episodesOutput;
|
||||
|
||||
void Function(Episode?) get deleteDownload => _deleteDownload.add;
|
||||
|
||||
void Function(Episode?) get togglePlayed => _togglePlayed.add;
|
||||
}
|
||||
517
PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart
Normal file
517
PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/downloadable.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/entities/feed.dart';
|
||||
import 'package:pinepods_mobile/entities/podcast.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/services/download/download_service.dart';
|
||||
import 'package:pinepods_mobile/services/download/mobile_download_service.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/services/settings/settings_service.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_search.dart';
|
||||
import 'package:pinepods_mobile/state/bloc_state.dart';
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
enum PodcastEvent {
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
markAllPlayed,
|
||||
clearAllPlayed,
|
||||
reloadSubscriptions,
|
||||
refresh,
|
||||
// Filter
|
||||
episodeFilterNone,
|
||||
episodeFilterStarted,
|
||||
episodeFilterNotFinished,
|
||||
episodeFilterFinished,
|
||||
// Sort
|
||||
episodeSortDefault,
|
||||
episodeSortLatest,
|
||||
episodeSortEarliest,
|
||||
episodeSortAlphabeticalAscending,
|
||||
episodeSortAlphabeticalDescending,
|
||||
}
|
||||
|
||||
/// This BLoC provides access to the details of a given Podcast.
|
||||
///
|
||||
/// It takes a feed URL and creates a [Podcast] instance. There are several listeners that
|
||||
/// handle actions on a podcast such as requesting an episode download, following/unfollowing
|
||||
/// a podcast and marking/un-marking all episodes as played.
|
||||
class PodcastBloc extends Bloc {
|
||||
final log = Logger('PodcastBloc');
|
||||
final PodcastService podcastService;
|
||||
final AudioPlayerService audioPlayerService;
|
||||
final DownloadService downloadService;
|
||||
final SettingsService settingsService;
|
||||
final BehaviorSubject<Feed> _podcastFeed = BehaviorSubject<Feed>(sync: true);
|
||||
|
||||
/// Add to sink to start an Episode download
|
||||
final PublishSubject<Episode?> _downloadEpisode = PublishSubject<Episode?>();
|
||||
|
||||
/// Listen to this subject's stream to obtain list of current subscriptions.
|
||||
late PublishSubject<List<Podcast>> _subscriptions;
|
||||
|
||||
/// Stream containing details of the current podcast.
|
||||
final BehaviorSubject<BlocState<Podcast>> _podcastStream = BehaviorSubject<BlocState<Podcast>>(sync: true);
|
||||
|
||||
/// A separate stream that allows us to listen to changes in the podcast's episodes.
|
||||
final BehaviorSubject<List<Episode?>?> _episodesStream = BehaviorSubject<List<Episode?>?>();
|
||||
|
||||
/// Receives subscription and mark/clear as played events.
|
||||
final PublishSubject<PodcastEvent> _podcastEvent = PublishSubject<PodcastEvent>();
|
||||
|
||||
final BehaviorSubject<String> _podcastSearchEvent = BehaviorSubject<String>();
|
||||
|
||||
final BehaviorSubject<BlocState<void>> _backgroundLoadStream = BehaviorSubject<BlocState<void>>();
|
||||
|
||||
Podcast? _podcast;
|
||||
List<Episode> _episodes = <Episode>[];
|
||||
String _searchTerm = '';
|
||||
late Feed lastFeed;
|
||||
bool first = true;
|
||||
|
||||
PodcastBloc({
|
||||
required this.podcastService,
|
||||
required this.audioPlayerService,
|
||||
required this.downloadService,
|
||||
required this.settingsService,
|
||||
}) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
/// When someone starts listening for subscriptions, load them.
|
||||
_subscriptions = PublishSubject<List<Podcast>>(onListen: _loadSubscriptions);
|
||||
|
||||
/// When we receive a load podcast request, send back a BlocState.
|
||||
_listenPodcastLoad();
|
||||
|
||||
/// Listen to an Episode download request
|
||||
_listenDownloadRequest();
|
||||
|
||||
/// Listen to active downloads
|
||||
_listenDownloads();
|
||||
|
||||
/// Listen to episode change events sent by the [Repository]
|
||||
_listenEpisodeRepositoryEvents();
|
||||
|
||||
/// Listen to Podcast subscription, mark/cleared played events
|
||||
_listenPodcastStateEvents();
|
||||
|
||||
/// Listen for episode search requests
|
||||
_listenPodcastSearchEvents();
|
||||
}
|
||||
|
||||
void _loadSubscriptions() async {
|
||||
_subscriptions.add(await podcastService.subscriptions());
|
||||
}
|
||||
|
||||
/// Sets up a listener to handle Podcast load requests. We first push a [BlocLoadingState] to
|
||||
/// indicate that the Podcast is being loaded, before calling the [PodcastService] to handle
|
||||
/// the loading. Once loaded, we extract the episodes from the Podcast and push them out via
|
||||
/// the episode stream before pushing a [BlocPopulatedState] containing the Podcast.
|
||||
void _listenPodcastLoad() async {
|
||||
_podcastFeed.listen((feed) async {
|
||||
var silent = false;
|
||||
lastFeed = feed;
|
||||
|
||||
_episodes = [];
|
||||
_refresh();
|
||||
|
||||
_podcastStream.sink.add(BlocLoadingState<Podcast>(feed.podcast));
|
||||
|
||||
try {
|
||||
await _loadEpisodes(feed, feed.refresh);
|
||||
|
||||
/// Do we also need to perform a background refresh?
|
||||
if (feed.podcast.id != null && feed.backgroundFresh && _shouldAutoRefresh()) {
|
||||
silent = feed.silently;
|
||||
log.fine('Performing background refresh of ${feed.podcast.url}');
|
||||
_backgroundLoadStream.sink.add(BlocLoadingState<void>());
|
||||
|
||||
await _loadNewEpisodes(feed);
|
||||
}
|
||||
|
||||
_backgroundLoadStream.sink.add(BlocSuccessfulState<void>());
|
||||
} catch (e) {
|
||||
_backgroundLoadStream.sink.add(BlocDefaultState<void>());
|
||||
|
||||
// For now we'll assume a network error as this is the most likely.
|
||||
if ((_podcast == null || lastFeed.podcast.url == _podcast!.url) && !silent) {
|
||||
_podcastStream.sink.add(BlocErrorState<Podcast>());
|
||||
log.fine('Error loading podcast', e);
|
||||
log.fine(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Determines if the current feed should be updated in the background.
|
||||
///
|
||||
/// If the autoUpdatePeriod is -1 this means never; 0 means always and any other
|
||||
/// value is the time in minutes.
|
||||
bool _shouldAutoRefresh() {
|
||||
/// If we are currently following this podcast it will have an id. At
|
||||
/// this point we can compare the last updated time to the update
|
||||
/// after setting time.
|
||||
if (settingsService.autoUpdateEpisodePeriod == -1) {
|
||||
return false;
|
||||
} else if (_podcast == null || settingsService.autoUpdateEpisodePeriod == 0) {
|
||||
return true;
|
||||
} else if (_podcast != null && _podcast!.id != null) {
|
||||
var currentTime = DateTime.now().subtract(Duration(minutes: settingsService.autoUpdateEpisodePeriod));
|
||||
var lastUpdated = _podcast!.lastUpdated;
|
||||
|
||||
return currentTime.isAfter(lastUpdated);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodes(Feed feed, bool force) async {
|
||||
_podcast = await podcastService.loadPodcast(
|
||||
podcast: feed.podcast,
|
||||
refresh: force,
|
||||
);
|
||||
|
||||
/// Only populate episodes if the ID we started the load with is the
|
||||
/// same as the one we have ended up with.
|
||||
if (_podcast != null && _podcast?.url != null) {
|
||||
if (lastFeed.podcast.url == _podcast!.url) {
|
||||
_episodes = _podcast!.episodes;
|
||||
_refresh();
|
||||
|
||||
_podcastStream.sink.add(BlocPopulatedState<Podcast>(results: _podcast));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
applySearchFilter();
|
||||
}
|
||||
|
||||
Future<void> _loadNewEpisodes(Feed feed) async {
|
||||
_podcast = await podcastService.loadPodcast(
|
||||
podcast: feed.podcast,
|
||||
highlightNewEpisodes: true,
|
||||
refresh: true,
|
||||
);
|
||||
|
||||
/// Only populate episodes if the ID we started the load with is the
|
||||
/// same as the one we have ended up with.
|
||||
if (_podcast != null && lastFeed.podcast.url == _podcast!.url) {
|
||||
_episodes = _podcast!.episodes;
|
||||
|
||||
if (_podcast!.newEpisodes) {
|
||||
log.fine('We have new episodes to display');
|
||||
_backgroundLoadStream.sink.add(BlocPopulatedState<void>());
|
||||
_podcastStream.sink.add(BlocPopulatedState<Podcast>(results: _podcast));
|
||||
} else if (_podcast!.updatedEpisodes) {
|
||||
log.fine('We have updated episodes to re-display');
|
||||
_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
log.fine('Background loading successful state');
|
||||
_backgroundLoadStream.sink.add(BlocSuccessfulState<void>());
|
||||
}
|
||||
|
||||
Future<void> _loadFilteredEpisodes() async {
|
||||
if (_podcast != null) {
|
||||
_podcast = await podcastService.loadPodcast(
|
||||
podcast: _podcast!,
|
||||
highlightNewEpisodes: false,
|
||||
refresh: false,
|
||||
);
|
||||
|
||||
_episodes = _podcast!.episodes;
|
||||
_podcastStream.add(BlocPopulatedState<Podcast>(results: _podcast));
|
||||
_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up a listener to handle requests to download an episode.
|
||||
void _listenDownloadRequest() {
|
||||
_downloadEpisode.listen((Episode? e) async {
|
||||
log.fine('Received download request for ${e!.title}');
|
||||
|
||||
// To prevent a pause between the user tapping the download icon and
|
||||
// the UI showing some sort of progress, set it to queued now.
|
||||
var episode = _episodes.firstWhereOrNull((ep) => ep.guid == e.guid);
|
||||
|
||||
if (episode != null) {
|
||||
episode.downloadState = e.downloadState = DownloadState.queued;
|
||||
|
||||
_refresh();
|
||||
|
||||
var result = await downloadService.downloadEpisode(e);
|
||||
|
||||
// If there was an error downloading the episode, push an error state
|
||||
// and then restore to none.
|
||||
if (!result) {
|
||||
episode.downloadState = e.downloadState = DownloadState.failed;
|
||||
_refresh();
|
||||
episode.downloadState = e.downloadState = DownloadState.none;
|
||||
_refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets up a listener to listen for status updates from any currently downloading episode.
|
||||
///
|
||||
/// If the ID of a current download matches that of an episode currently in
|
||||
/// use, we update the status of the episode and push it back into the episode stream.
|
||||
void _listenDownloads() {
|
||||
// Listen to download progress
|
||||
MobileDownloadService.downloadProgress.listen((downloadProgress) {
|
||||
downloadService.findEpisodeByTaskId(downloadProgress.id).then((downloadable) {
|
||||
if (downloadable != null) {
|
||||
// If the download matches a current episode push the update back into the stream.
|
||||
var episode = _episodes.firstWhereOrNull((e) => e.downloadTaskId == downloadProgress.id);
|
||||
|
||||
if (episode != null) {
|
||||
// Update the stream.
|
||||
_refresh();
|
||||
}
|
||||
} else {
|
||||
log.severe('Downloadable not found with id ${downloadProgress.id}');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen to episode change events sent by the [Repository]
|
||||
void _listenEpisodeRepositoryEvents() {
|
||||
podcastService.episodeListener!.listen((state) {
|
||||
// Do we have this episode?
|
||||
var eidx = _episodes.indexWhere((e) => e.guid == state.episode.guid && e.pguid == state.episode.pguid);
|
||||
|
||||
if (eidx != -1) {
|
||||
_episodes[eidx] = state.episode;
|
||||
_refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: This needs refactoring to simplify the long switch statement.
|
||||
void _listenPodcastStateEvents() async {
|
||||
_podcastEvent.listen((event) async {
|
||||
switch (event) {
|
||||
case PodcastEvent.subscribe:
|
||||
if (_podcast != null) {
|
||||
// Emit loading state for subscription
|
||||
_podcastStream.add(BlocLoadingState<Podcast>(_podcast));
|
||||
|
||||
// First, subscribe locally
|
||||
_podcast = await podcastService.subscribe(_podcast!);
|
||||
|
||||
// Check if we're in a PinePods environment and also add to server
|
||||
if (_podcast != null) {
|
||||
try {
|
||||
final settings = settingsService.settings;
|
||||
if (settings != null &&
|
||||
settings.pinepodsServer != null &&
|
||||
settings.pinepodsApiKey != null &&
|
||||
settings.pinepodsUserId != null) {
|
||||
|
||||
// Also add to PinePods server
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
final unifiedPodcast = UnifiedPinepodsPodcast(
|
||||
id: 0,
|
||||
indexId: 0,
|
||||
title: _podcast!.title,
|
||||
url: _podcast!.url ?? '',
|
||||
originalUrl: _podcast!.url ?? '',
|
||||
link: _podcast!.link ?? '',
|
||||
description: _podcast!.description ?? '',
|
||||
author: _podcast!.copyright ?? '',
|
||||
ownerName: _podcast!.copyright ?? '',
|
||||
image: _podcast!.imageUrl ?? '',
|
||||
artwork: _podcast!.imageUrl ?? '',
|
||||
lastUpdateTime: 0,
|
||||
explicit: false,
|
||||
episodeCount: 0,
|
||||
);
|
||||
|
||||
await pinepodsService.addPodcast(unifiedPodcast, settings.pinepodsUserId!);
|
||||
log.fine('Added podcast to PinePods server');
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Failed to add podcast to PinePods server: $e');
|
||||
// Continue with local subscription even if server add fails
|
||||
}
|
||||
|
||||
_episodes = _podcast!.episodes;
|
||||
_podcastStream.add(BlocPopulatedState<Podcast>(results: _podcast));
|
||||
_loadSubscriptions();
|
||||
_refresh(); // Use _refresh to apply filters and update episode stream properly
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PodcastEvent.unsubscribe:
|
||||
if (_podcast != null) {
|
||||
await podcastService.unsubscribe(_podcast!);
|
||||
_podcast!.id = null;
|
||||
_episodes = _podcast!.episodes;
|
||||
_podcastStream.add(BlocPopulatedState<Podcast>(results: _podcast));
|
||||
_loadSubscriptions();
|
||||
_refresh(); // Use _refresh to apply filters and update episode stream properly
|
||||
}
|
||||
break;
|
||||
case PodcastEvent.markAllPlayed:
|
||||
if (_podcast != null && _podcast?.episodes != null) {
|
||||
final changedEpisodes = <Episode>[];
|
||||
|
||||
for (var e in _podcast!.episodes) {
|
||||
if (!e.played) {
|
||||
e.played = true;
|
||||
e.position = 0;
|
||||
|
||||
changedEpisodes.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
await podcastService.saveEpisodes(changedEpisodes);
|
||||
_episodesStream.add(_podcast!.episodes);
|
||||
}
|
||||
break;
|
||||
case PodcastEvent.clearAllPlayed:
|
||||
if (_podcast != null && _podcast?.episodes != null) {
|
||||
final changedEpisodes = <Episode>[];
|
||||
|
||||
for (var e in _podcast!.episodes) {
|
||||
if (e.played) {
|
||||
e.played = false;
|
||||
e.position = 0;
|
||||
|
||||
changedEpisodes.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
await podcastService.saveEpisodes(changedEpisodes);
|
||||
_episodesStream.add(_podcast!.episodes);
|
||||
}
|
||||
break;
|
||||
case PodcastEvent.reloadSubscriptions:
|
||||
_loadSubscriptions();
|
||||
break;
|
||||
case PodcastEvent.refresh:
|
||||
_refresh();
|
||||
break;
|
||||
case PodcastEvent.episodeFilterNone:
|
||||
if (_podcast != null) {
|
||||
_podcast!.filter = PodcastEpisodeFilter.none;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
}
|
||||
break;
|
||||
case PodcastEvent.episodeFilterStarted:
|
||||
_podcast!.filter = PodcastEpisodeFilter.started;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeFilterFinished:
|
||||
_podcast!.filter = PodcastEpisodeFilter.played;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeFilterNotFinished:
|
||||
_podcast!.filter = PodcastEpisodeFilter.notPlayed;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeSortDefault:
|
||||
_podcast!.sort = PodcastEpisodeSort.none;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeSortLatest:
|
||||
_podcast!.sort = PodcastEpisodeSort.latestFirst;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeSortEarliest:
|
||||
_podcast!.sort = PodcastEpisodeSort.earliestFirst;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeSortAlphabeticalAscending:
|
||||
_podcast!.sort = PodcastEpisodeSort.alphabeticalAscending;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
case PodcastEvent.episodeSortAlphabeticalDescending:
|
||||
_podcast!.sort = PodcastEpisodeSort.alphabeticalDescending;
|
||||
_podcast = await podcastService.save(_podcast!, withEpisodes: false);
|
||||
await _loadFilteredEpisodes();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _listenPodcastSearchEvents() {
|
||||
_podcastSearchEvent.debounceTime(const Duration(milliseconds: 200)).listen((search) {
|
||||
_searchTerm = search;
|
||||
applySearchFilter();
|
||||
});
|
||||
}
|
||||
|
||||
void applySearchFilter() {
|
||||
if (_searchTerm.isEmpty) {
|
||||
_episodesStream.add(_episodes);
|
||||
} else {
|
||||
var searchFilteredEpisodes =
|
||||
_episodes.where((e) => e.title!.toLowerCase().contains(_searchTerm.trim().toLowerCase())).toList();
|
||||
_episodesStream.add(searchFilteredEpisodes);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
downloadService.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_podcastFeed.close();
|
||||
_downloadEpisode.close();
|
||||
_subscriptions.close();
|
||||
_podcastStream.close();
|
||||
_episodesStream.close();
|
||||
_podcastEvent.close();
|
||||
MobileDownloadService.downloadProgress.close();
|
||||
downloadService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Sink to load a podcast.
|
||||
void Function(Feed) get load => _podcastFeed.add;
|
||||
|
||||
/// Sink to trigger an episode download.
|
||||
void Function(Episode?) get downloadEpisode => _downloadEpisode.add;
|
||||
|
||||
void Function(PodcastEvent) get podcastEvent => _podcastEvent.add;
|
||||
|
||||
void Function(String) get podcastSearchEvent => _podcastSearchEvent.add;
|
||||
|
||||
/// Stream containing the current state of the podcast load.
|
||||
Stream<BlocState<Podcast>> get details => _podcastStream.stream;
|
||||
|
||||
Stream<BlocState<void>> get backgroundLoading => _backgroundLoadStream.stream;
|
||||
|
||||
/// Stream containing the current list of Podcast episodes.
|
||||
Stream<List<Episode?>?> get episodes => _episodesStream;
|
||||
|
||||
/// Obtain a list of podcast currently subscribed to.
|
||||
Stream<List<Podcast>> get subscriptions => _subscriptions.stream;
|
||||
}
|
||||
63
PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart
Normal file
63
PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/state/queue_event_state.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Handles interaction with the Queue via an [AudioPlayerService].
|
||||
class QueueBloc extends Bloc {
|
||||
final AudioPlayerService audioPlayerService;
|
||||
final PodcastService podcastService;
|
||||
final PublishSubject<QueueEvent> _queueEvent = PublishSubject<QueueEvent>();
|
||||
|
||||
QueueBloc({
|
||||
required this.audioPlayerService,
|
||||
required this.podcastService,
|
||||
}) {
|
||||
_handleQueueEvents();
|
||||
}
|
||||
|
||||
void _handleQueueEvents() {
|
||||
_queueEvent.listen((QueueEvent event) async {
|
||||
if (event is QueueAddEvent) {
|
||||
var e = event.episode;
|
||||
if (e != null) {
|
||||
await audioPlayerService.addUpNextEpisode(e);
|
||||
}
|
||||
} else if (event is QueueRemoveEvent) {
|
||||
var e = event.episode;
|
||||
if (e != null) {
|
||||
await audioPlayerService.removeUpNextEpisode(e);
|
||||
}
|
||||
} else if (event is QueueMoveEvent) {
|
||||
var e = event.episode;
|
||||
if (e != null) {
|
||||
await audioPlayerService.moveUpNextEpisode(e, event.oldIndex, event.newIndex);
|
||||
}
|
||||
} else if (event is QueueClearEvent) {
|
||||
await audioPlayerService.clearUpNext();
|
||||
}
|
||||
});
|
||||
|
||||
audioPlayerService.queueState!.debounceTime(const Duration(seconds: 2)).listen((event) {
|
||||
podcastService.saveQueue(event.queue).then((value) {
|
||||
/// Queue saved.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Function(QueueEvent) get queueEvent => _queueEvent.sink.add;
|
||||
|
||||
Stream<QueueListState>? get queue => audioPlayerService.queueState;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_queueEvent.close();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
96
PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart
Normal file
96
PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/bloc/search/search_state_event.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:pinepods_mobile/state/bloc_state.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:podcast_search/podcast_search.dart' as pcast;
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// This BLoC interacts with the [PodcastService] to search for podcasts for
|
||||
/// a given term and to fetch the current podcast charts.
|
||||
class SearchBloc extends Bloc {
|
||||
final log = Logger('SearchBloc');
|
||||
final PodcastService podcastService;
|
||||
|
||||
/// Add to the Sink to trigger a search using the [SearchEvent].
|
||||
final BehaviorSubject<SearchEvent> _searchInput = BehaviorSubject<SearchEvent>();
|
||||
|
||||
/// Add to the Sink to fetch the current podcast top x.
|
||||
final BehaviorSubject<int> _chartsInput = BehaviorSubject<int>();
|
||||
|
||||
/// Stream of the current search results, be it from search or charts.
|
||||
Stream<BlocState<pcast.SearchResult>>? _searchResults;
|
||||
|
||||
/// Cache of last results.
|
||||
pcast.SearchResult? _resultsCache;
|
||||
|
||||
SearchBloc({required this.podcastService}) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_searchResults = _searchInput.switchMap<BlocState<pcast.SearchResult>>(
|
||||
(SearchEvent event) => _search(event),
|
||||
);
|
||||
}
|
||||
|
||||
/// Takes the [SearchEvent] to perform either a search, chart fetch or clearing
|
||||
/// of the current results cache.
|
||||
///
|
||||
/// To improve resilience, when performing a search the current network status is
|
||||
/// checked. a [BlocErrorState] is pushed if we have no connectivity.
|
||||
Stream<BlocState<pcast.SearchResult>> _search(SearchEvent event) async* {
|
||||
if (event is SearchClearEvent) {
|
||||
yield BlocDefaultState();
|
||||
} else if (event is SearchChartsEvent) {
|
||||
yield BlocLoadingState();
|
||||
|
||||
_resultsCache ??= await podcastService.charts(size: 10);
|
||||
|
||||
yield BlocPopulatedState<pcast.SearchResult>(results: _resultsCache);
|
||||
} else if (event is SearchTermEvent) {
|
||||
final term = event.term;
|
||||
|
||||
if (term.isEmpty) {
|
||||
yield BlocNoInputState();
|
||||
} else {
|
||||
yield BlocLoadingState();
|
||||
|
||||
// Check we have network
|
||||
var connectivityResult = await Connectivity().checkConnectivity();
|
||||
|
||||
// TODO: Docs do not recommend this approach as a reliable way to
|
||||
// determine if network is available.
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
yield BlocErrorState(error: BlocErrorType.connectivity);
|
||||
} else {
|
||||
final results = await podcastService.search(term: term);
|
||||
|
||||
// Was the search successful?
|
||||
if (results.successful) {
|
||||
yield BlocPopulatedState<pcast.SearchResult>(results: results);
|
||||
} else {
|
||||
yield BlocErrorState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchInput.close();
|
||||
_chartsInput.close();
|
||||
}
|
||||
|
||||
void Function(SearchEvent) get search => _searchInput.add;
|
||||
|
||||
Stream<BlocState>? get results => _searchResults;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
/// Events
|
||||
class SearchEvent {}
|
||||
|
||||
class SearchTermEvent extends SearchEvent {
|
||||
final String term;
|
||||
|
||||
SearchTermEvent(this.term);
|
||||
}
|
||||
|
||||
class SearchChartsEvent extends SearchEvent {}
|
||||
|
||||
class SearchClearEvent extends SearchEvent {}
|
||||
|
||||
/// States
|
||||
class SearchState {}
|
||||
|
||||
class SearchLoadingState extends SearchState {}
|
||||
362
PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart
Normal file
362
PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:pinepods_mobile/bloc/bloc.dart';
|
||||
import 'package:pinepods_mobile/core/environment.dart';
|
||||
import 'package:pinepods_mobile/entities/app_settings.dart';
|
||||
import 'package:pinepods_mobile/entities/search_providers.dart';
|
||||
import 'package:pinepods_mobile/services/settings/settings_service.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
class SettingsBloc extends Bloc {
|
||||
final log = Logger('SettingsBloc');
|
||||
final SettingsService _settingsService;
|
||||
final BehaviorSubject<AppSettings> _settings = BehaviorSubject<AppSettings>.seeded(AppSettings.sensibleDefaults());
|
||||
final BehaviorSubject<bool> _darkMode = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<String> _theme = BehaviorSubject<String>();
|
||||
final BehaviorSubject<bool> _markDeletedAsPlayed = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _deleteDownloadedPlayedEpisodes = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _storeDownloadOnSDCard = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<double> _playbackSpeed = BehaviorSubject<double>();
|
||||
final BehaviorSubject<String> _searchProvider = BehaviorSubject<String>();
|
||||
final BehaviorSubject<bool> _externalLinkConsent = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _autoOpenNowPlaying = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _showFunding = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _trimSilence = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<bool> _volumeBoost = BehaviorSubject<bool>();
|
||||
final BehaviorSubject<int> _autoUpdatePeriod = BehaviorSubject<int>();
|
||||
final BehaviorSubject<int> _layoutMode = BehaviorSubject<int>();
|
||||
final BehaviorSubject<String?> _pinepodsServer = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<String?> _pinepodsApiKey = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<int?> _pinepodsUserId = BehaviorSubject<int?>();
|
||||
final BehaviorSubject<String?> _pinepodsUsername = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<String?> _pinepodsEmail = BehaviorSubject<String?>();
|
||||
final BehaviorSubject<List<String>> _bottomBarOrder = BehaviorSubject<List<String>>();
|
||||
var _currentSettings = AppSettings.sensibleDefaults();
|
||||
|
||||
SettingsBloc(this._settingsService) {
|
||||
_init();
|
||||
// Check if we need to fetch user details for existing login
|
||||
_fetchUserDetailsIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> _fetchUserDetailsIfNeeded() async {
|
||||
// Only fetch if we have server/api key but no username
|
||||
if (_currentSettings.pinepodsServer != null &&
|
||||
_currentSettings.pinepodsApiKey != null &&
|
||||
(_currentSettings.pinepodsUsername == null || _currentSettings.pinepodsUsername!.isEmpty)) {
|
||||
|
||||
try {
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(_currentSettings.pinepodsServer!, _currentSettings.pinepodsApiKey!);
|
||||
|
||||
// Use stored user ID if available, otherwise we need to get it somehow
|
||||
final userId = _currentSettings.pinepodsUserId;
|
||||
print('DEBUG: User ID from settings: $userId');
|
||||
if (userId != null) {
|
||||
final userDetails = await pinepodsService.getUserDetails(userId);
|
||||
print('DEBUG: User details response: $userDetails');
|
||||
if (userDetails != null) {
|
||||
// Update settings with user details
|
||||
final username = userDetails['Username'] ?? userDetails['username'] ?? '';
|
||||
final email = userDetails['Email'] ?? userDetails['email'] ?? '';
|
||||
print('DEBUG: Parsed username: "$username", email: "$email"');
|
||||
setPinepodsUsername(username);
|
||||
setPinepodsEmail(email);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - don't break the app if this fails
|
||||
print('Failed to fetch user details on startup: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _init() {
|
||||
/// Load all settings
|
||||
// Add our available search providers.
|
||||
var providers = <SearchProvider>[SearchProvider(key: 'itunes', name: 'iTunes')];
|
||||
|
||||
if (podcastIndexKey.isNotEmpty) {
|
||||
providers.add(SearchProvider(key: 'podcastindex', name: 'PodcastIndex'));
|
||||
}
|
||||
|
||||
_currentSettings = AppSettings(
|
||||
theme: _settingsService.theme,
|
||||
markDeletedEpisodesAsPlayed: _settingsService.markDeletedEpisodesAsPlayed,
|
||||
deleteDownloadedPlayedEpisodes: _settingsService.deleteDownloadedPlayedEpisodes,
|
||||
storeDownloadsSDCard: _settingsService.storeDownloadsSDCard,
|
||||
playbackSpeed: _settingsService.playbackSpeed,
|
||||
searchProvider: _settingsService.searchProvider,
|
||||
searchProviders: providers,
|
||||
externalLinkConsent: _settingsService.externalLinkConsent,
|
||||
autoOpenNowPlaying: _settingsService.autoOpenNowPlaying,
|
||||
showFunding: _settingsService.showFunding,
|
||||
autoUpdateEpisodePeriod: _settingsService.autoUpdateEpisodePeriod,
|
||||
trimSilence: _settingsService.trimSilence,
|
||||
volumeBoost: _settingsService.volumeBoost,
|
||||
layout: _settingsService.layoutMode,
|
||||
pinepodsServer: _settingsService.pinepodsServer,
|
||||
pinepodsApiKey: _settingsService.pinepodsApiKey,
|
||||
pinepodsUserId: _settingsService.pinepodsUserId,
|
||||
pinepodsUsername: _settingsService.pinepodsUsername,
|
||||
pinepodsEmail: _settingsService.pinepodsEmail,
|
||||
bottomBarOrder: _settingsService.bottomBarOrder,
|
||||
);
|
||||
|
||||
_settings.add(_currentSettings);
|
||||
|
||||
_darkMode.listen((bool darkMode) {
|
||||
_currentSettings = _currentSettings.copyWith(theme: darkMode ? 'Dark' : 'Light');
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.themeDarkMode = darkMode;
|
||||
});
|
||||
|
||||
_theme.listen((String theme) {
|
||||
_currentSettings = _currentSettings.copyWith(theme: theme);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.theme = theme;
|
||||
|
||||
// Sync with server if authenticated
|
||||
_syncThemeToServer(theme);
|
||||
});
|
||||
|
||||
_markDeletedAsPlayed.listen((bool mark) {
|
||||
_currentSettings = _currentSettings.copyWith(markDeletedEpisodesAsPlayed: mark);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.markDeletedEpisodesAsPlayed = mark;
|
||||
});
|
||||
|
||||
_deleteDownloadedPlayedEpisodes.listen((bool delete) {
|
||||
_currentSettings = _currentSettings.copyWith(deleteDownloadedPlayedEpisodes: delete);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.deleteDownloadedPlayedEpisodes = delete;
|
||||
});
|
||||
|
||||
_storeDownloadOnSDCard.listen((bool sdcard) {
|
||||
_currentSettings = _currentSettings.copyWith(storeDownloadsSDCard: sdcard);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.storeDownloadsSDCard = sdcard;
|
||||
});
|
||||
|
||||
_playbackSpeed.listen((double speed) {
|
||||
_currentSettings = _currentSettings.copyWith(playbackSpeed: speed);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.playbackSpeed = speed;
|
||||
});
|
||||
|
||||
_autoOpenNowPlaying.listen((bool autoOpen) {
|
||||
_currentSettings = _currentSettings.copyWith(autoOpenNowPlaying: autoOpen);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.autoOpenNowPlaying = autoOpen;
|
||||
});
|
||||
|
||||
_showFunding.listen((show) {
|
||||
// If the setting has not changed, don't bother updating it
|
||||
if (show != _currentSettings.showFunding) {
|
||||
_currentSettings = _currentSettings.copyWith(showFunding: show);
|
||||
_settingsService.showFunding = show;
|
||||
}
|
||||
|
||||
_settings.add(_currentSettings);
|
||||
});
|
||||
|
||||
_searchProvider.listen((search) {
|
||||
_currentSettings = _currentSettings.copyWith(searchProvider: search);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.searchProvider = search;
|
||||
});
|
||||
|
||||
_externalLinkConsent.listen((consent) {
|
||||
// If the setting has not changed, don't bother updating it
|
||||
if (consent != _settingsService.externalLinkConsent) {
|
||||
_currentSettings = _currentSettings.copyWith(externalLinkConsent: consent);
|
||||
_settingsService.externalLinkConsent = consent;
|
||||
}
|
||||
|
||||
_settings.add(_currentSettings);
|
||||
});
|
||||
|
||||
_autoUpdatePeriod.listen((period) {
|
||||
_currentSettings = _currentSettings.copyWith(autoUpdateEpisodePeriod: period);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.autoUpdateEpisodePeriod = period;
|
||||
});
|
||||
|
||||
_trimSilence.listen((trim) {
|
||||
_currentSettings = _currentSettings.copyWith(trimSilence: trim);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.trimSilence = trim;
|
||||
});
|
||||
|
||||
_volumeBoost.listen((boost) {
|
||||
_currentSettings = _currentSettings.copyWith(volumeBoost: boost);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.volumeBoost = boost;
|
||||
});
|
||||
|
||||
_pinepodsServer.listen((server) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsServer: server);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsServer = server;
|
||||
});
|
||||
|
||||
_pinepodsApiKey.listen((apiKey) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsApiKey: apiKey);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsApiKey = apiKey;
|
||||
});
|
||||
|
||||
_pinepodsUserId.listen((userId) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsUserId: userId);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsUserId = userId;
|
||||
});
|
||||
|
||||
_pinepodsUsername.listen((username) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsUsername: username);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsUsername = username;
|
||||
});
|
||||
|
||||
_pinepodsEmail.listen((email) {
|
||||
_currentSettings = _currentSettings.copyWith(pinepodsEmail: email);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.pinepodsEmail = email;
|
||||
});
|
||||
|
||||
_layoutMode.listen((mode) {
|
||||
_currentSettings = _currentSettings.copyWith(layout: mode);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.layoutMode = mode;
|
||||
});
|
||||
|
||||
_bottomBarOrder.listen((order) {
|
||||
_currentSettings = _currentSettings.copyWith(bottomBarOrder: order);
|
||||
_settings.add(_currentSettings);
|
||||
_settingsService.bottomBarOrder = order;
|
||||
});
|
||||
}
|
||||
|
||||
Stream<AppSettings> get settings => _settings.stream;
|
||||
|
||||
void Function(bool) get darkMode => _darkMode.add;
|
||||
|
||||
void Function(bool) get storeDownloadonSDCard => _storeDownloadOnSDCard.add;
|
||||
|
||||
void Function(bool) get markDeletedAsPlayed => _markDeletedAsPlayed.add;
|
||||
|
||||
void Function(bool) get deleteDownloadedPlayedEpisodes => _deleteDownloadedPlayedEpisodes.add;
|
||||
|
||||
void Function(double) get setPlaybackSpeed => _playbackSpeed.add;
|
||||
|
||||
void Function(bool) get setAutoOpenNowPlaying => _autoOpenNowPlaying.add;
|
||||
|
||||
void Function(String) get setSearchProvider => _searchProvider.add;
|
||||
|
||||
void Function(bool) get setExternalLinkConsent => _externalLinkConsent.add;
|
||||
|
||||
void Function(bool) get setShowFunding => _showFunding.add;
|
||||
|
||||
void Function(int) get autoUpdatePeriod => _autoUpdatePeriod.add;
|
||||
|
||||
void Function(bool) get trimSilence => _trimSilence.add;
|
||||
|
||||
void Function(bool) get volumeBoost => _volumeBoost.add;
|
||||
|
||||
void Function(int) get layoutMode => _layoutMode.add;
|
||||
|
||||
void Function(String?) get setPinepodsServer => _pinepodsServer.add;
|
||||
|
||||
void Function(String?) get setPinepodsApiKey => _pinepodsApiKey.add;
|
||||
|
||||
void Function(int?) get setPinepodsUserId => _pinepodsUserId.add;
|
||||
|
||||
void Function(String?) get setPinepodsUsername => _pinepodsUsername.add;
|
||||
|
||||
void Function(String?) get setPinepodsEmail => _pinepodsEmail.add;
|
||||
|
||||
void Function(List<String>) get setBottomBarOrder => _bottomBarOrder.add;
|
||||
|
||||
void Function(String) get setTheme => _theme.add;
|
||||
|
||||
AppSettings get currentSettings => _settings.value;
|
||||
|
||||
Future<void> _syncThemeToServer(String theme) async {
|
||||
try {
|
||||
// Only sync if we have PinePods credentials
|
||||
if (_currentSettings.pinepodsServer != null &&
|
||||
_currentSettings.pinepodsApiKey != null &&
|
||||
_currentSettings.pinepodsUserId != null) {
|
||||
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(
|
||||
_currentSettings.pinepodsServer!,
|
||||
_currentSettings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
await pinepodsService.setUserTheme(_currentSettings.pinepodsUserId!, theme);
|
||||
log.info('Theme synced to server: $theme');
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Failed to sync theme to server: $e');
|
||||
// Don't throw - theme should still work locally
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchThemeFromServer() async {
|
||||
try {
|
||||
// Only fetch if we have PinePods credentials
|
||||
if (_currentSettings.pinepodsServer != null &&
|
||||
_currentSettings.pinepodsApiKey != null &&
|
||||
_currentSettings.pinepodsUserId != null) {
|
||||
|
||||
final pinepodsService = PinepodsService();
|
||||
pinepodsService.setCredentials(
|
||||
_currentSettings.pinepodsServer!,
|
||||
_currentSettings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
final serverTheme = await pinepodsService.getUserTheme(_currentSettings.pinepodsUserId!);
|
||||
if (serverTheme != null && serverTheme.isNotEmpty) {
|
||||
// Update local theme without syncing back to server
|
||||
_settingsService.theme = serverTheme;
|
||||
_currentSettings = _currentSettings.copyWith(theme: serverTheme);
|
||||
_settings.add(_currentSettings);
|
||||
log.info('Theme fetched from server: $serverTheme');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Failed to fetch theme from server: $e');
|
||||
// Don't throw - continue with local theme
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_darkMode.close();
|
||||
_theme.close();
|
||||
_markDeletedAsPlayed.close();
|
||||
_deleteDownloadedPlayedEpisodes.close();
|
||||
_storeDownloadOnSDCard.close();
|
||||
_playbackSpeed.close();
|
||||
_searchProvider.close();
|
||||
_externalLinkConsent.close();
|
||||
_autoOpenNowPlaying.close();
|
||||
_showFunding.close();
|
||||
_trimSilence.close();
|
||||
_volumeBoost.close();
|
||||
_autoUpdatePeriod.close();
|
||||
_layoutMode.close();
|
||||
_pinepodsServer.close();
|
||||
_pinepodsApiKey.close();
|
||||
_pinepodsUserId.close();
|
||||
_pinepodsUsername.close();
|
||||
_pinepodsEmail.close();
|
||||
_bottomBarOrder.close();
|
||||
_settings.close();
|
||||
}
|
||||
}
|
||||
19
PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart
Normal file
19
PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// This BLoC provides a sink and stream to set and listen for the current
|
||||
/// page/tab on a bottom navigation bar.
|
||||
class PagerBloc {
|
||||
final BehaviorSubject<int> page = BehaviorSubject<int>.seeded(0);
|
||||
|
||||
Function(int) get changePage => page.add;
|
||||
|
||||
Stream<int> get currentPage => page.stream;
|
||||
|
||||
void dispose() {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
8
PinePods-0.8.2/mobile/lib/core/annotations.dart
Normal file
8
PinePods-0.8.2/mobile/lib/core/annotations.dart
Normal 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();
|
||||
}
|
||||
54
PinePods-0.8.2/mobile/lib/core/environment.dart
Normal file
54
PinePods-0.8.2/mobile/lib/core/environment.dart
Normal 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';
|
||||
}
|
||||
60
PinePods-0.8.2/mobile/lib/core/extensions.dart
Normal file
60
PinePods-0.8.2/mobile/lib/core/extensions.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
140
PinePods-0.8.2/mobile/lib/core/utils.dart
Normal file
140
PinePods-0.8.2/mobile/lib/core/utils.dart
Normal 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();
|
||||
}
|
||||
152
PinePods-0.8.2/mobile/lib/entities/app_settings.dart
Normal file
152
PinePods-0.8.2/mobile/lib/entities/app_settings.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
71
PinePods-0.8.2/mobile/lib/entities/chapter.dart
Normal file
71
PinePods-0.8.2/mobile/lib/entities/chapter.dart
Normal 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;
|
||||
}
|
||||
98
PinePods-0.8.2/mobile/lib/entities/downloadable.dart
Normal file
98
PinePods-0.8.2/mobile/lib/entities/downloadable.dart
Normal 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;
|
||||
}
|
||||
420
PinePods-0.8.2/mobile/lib/entities/episode.dart
Normal file
420
PinePods-0.8.2/mobile/lib/entities/episode.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
39
PinePods-0.8.2/mobile/lib/entities/feed.dart
Normal file
39
PinePods-0.8.2/mobile/lib/entities/feed.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
35
PinePods-0.8.2/mobile/lib/entities/funding.dart
Normal file
35
PinePods-0.8.2/mobile/lib/entities/funding.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
238
PinePods-0.8.2/mobile/lib/entities/home_data.dart
Normal file
238
PinePods-0.8.2/mobile/lib/entities/home_data.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
81
PinePods-0.8.2/mobile/lib/entities/persistable.dart
Normal file
81
PinePods-0.8.2/mobile/lib/entities/persistable.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
PinePods-0.8.2/mobile/lib/entities/person.dart
Normal file
64
PinePods-0.8.2/mobile/lib/entities/person.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
142
PinePods-0.8.2/mobile/lib/entities/pinepods_episode.dart
Normal file
142
PinePods-0.8.2/mobile/lib/entities/pinepods_episode.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
359
PinePods-0.8.2/mobile/lib/entities/pinepods_search.dart
Normal file
359
PinePods-0.8.2/mobile/lib/entities/pinepods_search.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
248
PinePods-0.8.2/mobile/lib/entities/podcast.dart
Normal file
248
PinePods-0.8.2/mobile/lib/entities/podcast.dart
Normal 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;
|
||||
}
|
||||
31
PinePods-0.8.2/mobile/lib/entities/queue.dart
Normal file
31
PinePods-0.8.2/mobile/lib/entities/queue.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
16
PinePods-0.8.2/mobile/lib/entities/search_providers.dart
Normal file
16
PinePods-0.8.2/mobile/lib/entities/search_providers.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
32
PinePods-0.8.2/mobile/lib/entities/sleep.dart
Normal file
32
PinePods-0.8.2/mobile/lib/entities/sleep.dart
Normal 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());
|
||||
}
|
||||
213
PinePods-0.8.2/mobile/lib/entities/transcript.dart
Normal file
213
PinePods-0.8.2/mobile/lib/entities/transcript.dart
Normal 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;
|
||||
}
|
||||
91
PinePods-0.8.2/mobile/lib/entities/user_stats.dart
Normal file
91
PinePods-0.8.2/mobile/lib/entities/user_stats.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
1780
PinePods-0.8.2/mobile/lib/l10n/L.dart
Normal file
1780
PinePods-0.8.2/mobile/lib/l10n/L.dart
Normal file
File diff suppressed because it is too large
Load Diff
1068
PinePods-0.8.2/mobile/lib/l10n/intl_de.arb
Normal file
1068
PinePods-0.8.2/mobile/lib/l10n/intl_de.arb
Normal file
File diff suppressed because it is too large
Load Diff
1069
PinePods-0.8.2/mobile/lib/l10n/intl_en.arb
Normal file
1069
PinePods-0.8.2/mobile/lib/l10n/intl_en.arb
Normal file
File diff suppressed because it is too large
Load Diff
1068
PinePods-0.8.2/mobile/lib/l10n/intl_it.arb
Normal file
1068
PinePods-0.8.2/mobile/lib/l10n/intl_it.arb
Normal file
File diff suppressed because it is too large
Load Diff
1014
PinePods-0.8.2/mobile/lib/l10n/intl_messages.arb
Normal file
1014
PinePods-0.8.2/mobile/lib/l10n/intl_messages.arb
Normal file
File diff suppressed because it is too large
Load Diff
7
PinePods-0.8.2/mobile/lib/l10n/messages_all.dart
Normal file
7
PinePods-0.8.2/mobile/lib/l10n/messages_all.dart
Normal 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;
|
||||
|
||||
73
PinePods-0.8.2/mobile/lib/l10n/messages_all_locales.dart
Normal file
73
PinePods-0.8.2/mobile/lib/l10n/messages_all_locales.dart
Normal 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);
|
||||
}
|
||||
364
PinePods-0.8.2/mobile/lib/l10n/messages_de.dart
Normal file
364
PinePods-0.8.2/mobile/lib/l10n/messages_de.dart
Normal 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')
|
||||
};
|
||||
}
|
||||
350
PinePods-0.8.2/mobile/lib/l10n/messages_en.dart
Normal file
350
PinePods-0.8.2/mobile/lib/l10n/messages_en.dart
Normal 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')
|
||||
};
|
||||
}
|
||||
359
PinePods-0.8.2/mobile/lib/l10n/messages_it.dart
Normal file
359
PinePods-0.8.2/mobile/lib/l10n/messages_it.dart
Normal 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')
|
||||
};
|
||||
}
|
||||
349
PinePods-0.8.2/mobile/lib/l10n/messages_messages.dart
Normal file
349
PinePods-0.8.2/mobile/lib/l10n/messages_messages.dart
Normal 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')
|
||||
};
|
||||
}
|
||||
99
PinePods-0.8.2/mobile/lib/main.dart
Normal file
99
PinePods-0.8.2/mobile/lib/main.dart
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
70
PinePods-0.8.2/mobile/lib/repository/repository.dart
Normal file
70
PinePods-0.8.2/mobile/lib/repository/repository.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
18
PinePods-0.8.2/mobile/lib/services/auth_notifier.dart
Normal file
18
PinePods-0.8.2/mobile/lib/services/auth_notifier.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
202
PinePods-0.8.2/mobile/lib/services/error_handling_service.dart
Normal file
202
PinePods-0.8.2/mobile/lib/services/error_handling_service.dart
Normal 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);
|
||||
}
|
||||
35
PinePods-0.8.2/mobile/lib/services/global_services.dart
Normal file
35
PinePods-0.8.2/mobile/lib/services/global_services.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
488
PinePods-0.8.2/mobile/lib/services/logging/app_logger.dart
Normal file
488
PinePods-0.8.2/mobile/lib/services/logging/app_logger.dart
Normal 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;
|
||||
}
|
||||
532
PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart
Normal file
532
PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart
Normal 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
405
PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart
Normal file
405
PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
2170
PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart
Normal file
2170
PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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});
|
||||
}
|
||||
230
PinePods-0.8.2/mobile/lib/services/podcast/podcast_service.dart
Normal file
230
PinePods-0.8.2/mobile/lib/services/podcast/podcast_service.dart
Normal 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;
|
||||
}
|
||||
162
PinePods-0.8.2/mobile/lib/services/search_history_service.dart
Normal file
162
PinePods-0.8.2/mobile/lib/services/search_history_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
45
PinePods-0.8.2/mobile/lib/state/bloc_state.dart
Normal file
45
PinePods-0.8.2/mobile/lib/state/bloc_state.dart
Normal 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});
|
||||
}
|
||||
19
PinePods-0.8.2/mobile/lib/state/episode_state.dart
Normal file
19
PinePods-0.8.2/mobile/lib/state/episode_state.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package: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);
|
||||
}
|
||||
95
PinePods-0.8.2/mobile/lib/state/persistent_state.dart
Normal file
95
PinePods-0.8.2/mobile/lib/state/persistent_state.dart
Normal 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'));
|
||||
}
|
||||
}
|
||||
58
PinePods-0.8.2/mobile/lib/state/queue_event_state.dart
Normal file
58
PinePods-0.8.2/mobile/lib/state/queue_event_state.dart
Normal 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>[]);
|
||||
}
|
||||
35
PinePods-0.8.2/mobile/lib/state/transcript_state_event.dart
Normal file
35
PinePods-0.8.2/mobile/lib/state/transcript_state_event.dart
Normal 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);
|
||||
}
|
||||
163
PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart
Normal file
163
PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
147
PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart
Normal file
147
PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
777
PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
Normal file
777
PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
656
PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart
Normal file
656
PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
19
PinePods-0.8.2/mobile/lib/ui/library/downloads.dart
Normal file
19
PinePods-0.8.2/mobile/lib/ui/library/downloads.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package: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();
|
||||
}
|
||||
}
|
||||
115
PinePods-0.8.2/mobile/lib/ui/library/library.dart
Normal file
115
PinePods-0.8.2/mobile/lib/ui/library/library.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
390
PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart
Normal file
390
PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
968
PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
Normal file
968
PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
Normal 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),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
963
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart
Normal file
963
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal file
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1050
PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart
Normal file
1050
PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart
Normal file
File diff suppressed because it is too large
Load Diff
745
PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart
Normal file
745
PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart
Normal 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
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1377
PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart
Normal file
1377
PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart
Normal file
File diff suppressed because it is too large
Load Diff
116
PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart
Normal file
116
PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
572
PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
Normal file
572
PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
546
PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart
Normal file
546
PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
1227
PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart
Normal file
1227
PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart
Normal file
File diff suppressed because it is too large
Load Diff
337
PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart
Normal file
337
PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
805
PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart
Normal file
805
PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
730
PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart
Normal file
730
PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart
Normal 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
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
674
PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart
Normal file
674
PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
503
PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart
Normal file
503
PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1329
PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart
Normal file
1329
PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart
Normal file
File diff suppressed because it is too large
Load Diff
170
PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
Normal file
170
PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
49
PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart
Normal file
49
PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
101
PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart
Normal file
101
PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart
Normal 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!),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
222
PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart
Normal file
222
PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
360
PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart
Normal file
360
PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart
Normal 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
Reference in New Issue
Block a user