added cargo files
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user